Compare commits
No commits in common. "7c1016552b3424b981a428814df83600fe8ac0a8" and "5f3afa6da250d5ed1ccd296cba39f255190e5276" have entirely different histories.
7c1016552b
...
5f3afa6da2
5 changed files with 242 additions and 2389 deletions
|
|
@ -1,843 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
||||||
"""Formulaire Textual de déploiement QEMU, et vue de progression.
|
|
||||||
|
|
||||||
Deux interfaces mènent au même déploiement : les invites en ligne de
|
|
||||||
`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la
|
|
||||||
« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne
|
|
||||||
puisse l'être là-bas, et réciproquement.
|
|
||||||
|
|
||||||
- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal.
|
|
||||||
- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None.
|
|
||||||
- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le
|
|
||||||
déploiement, avec copie du log vers le presse-papiers (OSC 52).
|
|
||||||
|
|
||||||
Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
|
|
||||||
`virsh` passe par sudo et une invite de mot de passe casserait l'affichage
|
|
||||||
Textual. Les données coûteuses (domaines existants, branches distantes) sont
|
|
||||||
préchargées par l'appelant et arrivent dans `ctx`.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
try:
|
|
||||||
from script.todo.todo_i18n import t
|
|
||||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
||||||
|
|
||||||
def t(key: str) -> str:
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Logique pure — aucune dépendance à Textual, testable telle quelle
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def entry_key(entry) -> tuple:
|
|
||||||
"""Identité stable d'une entrée de catalogue, indépendante de son rang :
|
|
||||||
les surcharges par VM y survivent quand la sélection change."""
|
|
||||||
return (entry["distro"], entry["version"], entry["arch"])
|
|
||||||
|
|
||||||
|
|
||||||
def parse_disk(value):
|
|
||||||
"""« 60 », « 60G », « 1T », « 1,5T » -> « <n>G », ou None si invalide.
|
|
||||||
Même règle que `TODO._qemu_parse_disk` : tout le reste de la chaîne
|
|
||||||
raisonne en gigaoctets."""
|
|
||||||
txt = str(value).strip().upper().replace(",", ".")
|
|
||||||
factor = 1
|
|
||||||
if txt.endswith("T"):
|
|
||||||
factor, txt = 1024, txt[:-1]
|
|
||||||
elif txt.endswith("G"):
|
|
||||||
txt = txt[:-1]
|
|
||||||
try:
|
|
||||||
gigs = int(float(txt) * factor)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return f"{gigs}G" if gigs > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def disk_gb(value) -> int:
|
|
||||||
"""« 60G » -> 60 (best effort), pour les totaux."""
|
|
||||||
parsed = parse_disk(value)
|
|
||||||
return int(parsed[:-1]) if parsed else 0
|
|
||||||
|
|
||||||
|
|
||||||
def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
|
|
||||||
"""Applique le profil de ressources aux entrées choisies.
|
|
||||||
|
|
||||||
Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur
|
|
||||||
monte la RAM minimale du catalogue et les vCPU en se bornant aux cœurs de
|
|
||||||
l'hôte ; « custom » impose les mêmes valeurs à tout le parc, une valeur
|
|
||||||
absente gardant celle du catalogue."""
|
|
||||||
out = []
|
|
||||||
for e in entries:
|
|
||||||
if profile == "custom":
|
|
||||||
cus = custom or {}
|
|
||||||
ram = cus.get("ram") or e["ram"]
|
|
||||||
disk = cus.get("disk") or e["disk"]
|
|
||||||
vcpus = cus.get("vcpus") or base_vcpus
|
|
||||||
else:
|
|
||||||
mult = int(profile)
|
|
||||||
ram = e["ram"] * mult
|
|
||||||
disk = e["disk"]
|
|
||||||
vcpus = min(base_vcpus * mult, host_cpu)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"name": e["name"],
|
|
||||||
"distro": e["distro"],
|
|
||||||
"version": e["version"],
|
|
||||||
"arch": e["arch"],
|
|
||||||
"ram": ram,
|
|
||||||
"disk": disk,
|
|
||||||
"vcpus": vcpus,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def apply_overrides(vms, entries, overrides):
|
|
||||||
"""Réapplique les réglages par VM (nom, vCPU, RAM, disque) après un
|
|
||||||
recalcul du profil. `overrides` est indexé par `entry_key`."""
|
|
||||||
for vm, e in zip(vms, entries):
|
|
||||||
for field, value in (overrides.get(entry_key(e)) or {}).items():
|
|
||||||
vm[field] = value
|
|
||||||
return vms
|
|
||||||
|
|
||||||
|
|
||||||
def build_vms(entries, profile, base_vcpus, host_cpu, custom, overrides):
|
|
||||||
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
|
|
||||||
return apply_overrides(
|
|
||||||
apply_profile(entries, profile, base_vcpus, host_cpu, custom),
|
|
||||||
entries,
|
|
||||||
overrides,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def vm_status(name, domains):
|
|
||||||
"""État d'un nom face à l'existant : ('new'|'exists'|'orphan', message).
|
|
||||||
|
|
||||||
Les deux collisions n'ont pas la même gravité — une VM définie est
|
|
||||||
ignorée, un qcow2 resté seul fait échouer deploy_qemu, qui refuse
|
|
||||||
d'écraser sans --force."""
|
|
||||||
if name in domains:
|
|
||||||
return "exists", t("exists - skipped")
|
|
||||||
if os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2"):
|
|
||||||
return "orphan", t("orphan disk - will FAIL")
|
|
||||||
return "new", ""
|
|
||||||
|
|
||||||
|
|
||||||
def plan_rows(vms, domains, extra_disk_gb=0):
|
|
||||||
"""Lignes du tableau du plan : une par VM, avec son état."""
|
|
||||||
rows = []
|
|
||||||
for vm in vms:
|
|
||||||
state, note = vm_status(vm["name"], domains)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"vm": vm,
|
|
||||||
"state": state,
|
|
||||||
"note": note,
|
|
||||||
"disk_gb": disk_gb(vm["disk"]) + extra_disk_gb,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def plan_totals(rows):
|
|
||||||
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
|
||||||
neuf) : (nb, vcpus, ram_mo, disque_go)."""
|
|
||||||
fresh = [r for r in rows if r["state"] != "exists"]
|
|
||||||
return (
|
|
||||||
len(fresh),
|
|
||||||
sum(r["vm"]["vcpus"] for r in fresh),
|
|
||||||
sum(r["vm"]["ram"] for r in fresh),
|
|
||||||
sum(r["disk_gb"] for r in fresh),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_spec(vms, domains, form):
|
|
||||||
"""Assemble la spec finale, dans la forme exacte que produit la CLI."""
|
|
||||||
known = set(domains)
|
|
||||||
return {
|
|
||||||
"res_label": form["res_label"],
|
|
||||||
"vms": [vm for vm in vms if vm["name"] not in known],
|
|
||||||
"existing": [vm["name"] for vm in vms if vm["name"] in known],
|
|
||||||
"ssh_key": form["ssh_key"],
|
|
||||||
"install": form["install"],
|
|
||||||
"add_ssh_config": form["add_ssh_config"],
|
|
||||||
"parallelism": form["parallelism"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_dur(secs) -> str:
|
|
||||||
mm, ss = divmod(int(secs), 60)
|
|
||||||
if mm >= 60:
|
|
||||||
return f"{mm // 60}h{mm % 60:02d}"
|
|
||||||
return f"{mm}m{ss:02d}" if mm else f"{ss}s"
|
|
||||||
|
|
||||||
|
|
||||||
# Au-delà, un OSC 52 est tronqué par certains terminaux (xterm notamment).
|
|
||||||
# On copie alors la FIN du log — la partie qui porte l'erreur.
|
|
||||||
CLIP_LIMIT = 100_000
|
|
||||||
|
|
||||||
|
|
||||||
def clip_payload(text, limit=CLIP_LIMIT):
|
|
||||||
"""(texte_à_copier, tronqué?) — on garde la fin, pas le début."""
|
|
||||||
if len(text) <= limit:
|
|
||||||
return text, False
|
|
||||||
return text[-limit:], True
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Formulaire Textual
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def run_deploy_form(ctx, run_app: bool = True):
|
|
||||||
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
|
|
||||||
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
DataTable,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
RadioButton,
|
|
||||||
RadioSet,
|
|
||||||
Select,
|
|
||||||
SelectionList,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
catalog = ctx["catalog"]
|
|
||||||
arches = ctx["arches"]
|
|
||||||
domains = set(ctx.get("domains") or [])
|
|
||||||
profiles = ctx.get("install_profiles") or []
|
|
||||||
branches = ctx.get("branches") or ["master"]
|
|
||||||
host_cpu = ctx.get("host_cpu") or 2
|
|
||||||
free_ram = ctx.get("free_ram") or 0
|
|
||||||
base_vcpus = ctx.get("base_vcpus") or 2
|
|
||||||
extra_disk = ctx.get("extra_disk_gb") or 0
|
|
||||||
defaults = ctx.get("defaults") or {}
|
|
||||||
result = {"spec": None}
|
|
||||||
|
|
||||||
AUTO = "__auto__"
|
|
||||||
|
|
||||||
def entry_label(e):
|
|
||||||
star = " *" if e.get("default") else ""
|
|
||||||
return (
|
|
||||||
f"{e['distro']} {e['version']}{star} [{e['arch']}] "
|
|
||||||
f"RAM≥{e['ram']}Mo {e['disk']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
class EditVMScreen(ModalScreen):
|
|
||||||
"""Réglages d'UNE VM. Un champ vide garde la valeur courante."""
|
|
||||||
|
|
||||||
BINDINGS = [("escape", "cancel", t("Cancel"))]
|
|
||||||
|
|
||||||
def __init__(self, vm):
|
|
||||||
super().__init__()
|
|
||||||
self._vm = vm
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="editbox"):
|
|
||||||
yield Static(f" {self._vm['name']}", id="edittitle")
|
|
||||||
yield Label(t("Name"))
|
|
||||||
yield Input(value=self._vm["name"], id="e_name")
|
|
||||||
yield Label(t("vCPU"))
|
|
||||||
yield Input(value=str(self._vm["vcpus"]), id="e_vcpus")
|
|
||||||
yield Label(t("RAM (MB)"))
|
|
||||||
yield Input(value=str(self._vm["ram"]), id="e_ram")
|
|
||||||
yield Label(t("Disk"))
|
|
||||||
yield Input(value=str(self._vm["disk"]), id="e_disk")
|
|
||||||
with Horizontal(id="editbtns"):
|
|
||||||
yield Button(t("Apply"), variant="primary", id="e_ok")
|
|
||||||
yield Button(t("Cancel"), id="e_cancel")
|
|
||||||
|
|
||||||
def on_button_pressed(self, event) -> None:
|
|
||||||
if event.button.id != "e_ok":
|
|
||||||
self.dismiss(None)
|
|
||||||
return
|
|
||||||
out = {}
|
|
||||||
name = self.query_one("#e_name", Input).value.strip()
|
|
||||||
if name:
|
|
||||||
out["name"] = name
|
|
||||||
for field, wid in (("vcpus", "#e_vcpus"), ("ram", "#e_ram")):
|
|
||||||
raw = self.query_one(wid, Input).value.strip()
|
|
||||||
if raw.isdigit() and int(raw) > 0:
|
|
||||||
out[field] = int(raw)
|
|
||||||
disk = parse_disk(self.query_one("#e_disk", Input).value)
|
|
||||||
if disk:
|
|
||||||
out["disk"] = disk
|
|
||||||
self.dismiss(out)
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
self.dismiss(None)
|
|
||||||
|
|
||||||
class PreviewScreen(ModalScreen):
|
|
||||||
"""Aperçu des commandes qui seraient lancées (aucune exécution)."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
("escape", "close", t("Close")),
|
|
||||||
("q", "close", t("Close")),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, lines):
|
|
||||||
super().__init__()
|
|
||||||
self._lines = lines
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id="prevbox"):
|
|
||||||
yield Static(
|
|
||||||
f" {t('Preview (dry-run):')} ({t('Esc to close')})",
|
|
||||||
id="prevtitle",
|
|
||||||
)
|
|
||||||
yield Static("\n\n".join(self._lines), id="prevbody")
|
|
||||||
|
|
||||||
def action_close(self) -> None:
|
|
||||||
self.dismiss()
|
|
||||||
|
|
||||||
class DeployForm(App):
|
|
||||||
CSS = """
|
|
||||||
#body { height: 1fr; }
|
|
||||||
#fields { width: 62; border: solid $accent; overflow-y: auto; }
|
|
||||||
#right { width: 1fr; }
|
|
||||||
#plan { height: 1fr; border: solid $accent; }
|
|
||||||
#totals { height: auto; color: $text-muted; padding: 0 1; }
|
|
||||||
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
|
|
||||||
SelectionList { height: 10; border: solid $panel; }
|
|
||||||
RadioSet { height: auto; layout: horizontal; }
|
|
||||||
#reslabel { color: $text-muted; }
|
|
||||||
EditVMScreen { align: center middle; }
|
|
||||||
#editbox {
|
|
||||||
width: 56; height: auto; padding: 1 2;
|
|
||||||
border: thick $accent; background: $surface;
|
|
||||||
}
|
|
||||||
#edittitle { color: $accent; text-style: bold; }
|
|
||||||
#editbtns { height: auto; padding-top: 1; }
|
|
||||||
PreviewScreen { align: center middle; }
|
|
||||||
#prevbox {
|
|
||||||
width: 90%; height: 70%; padding: 1 2;
|
|
||||||
border: thick $accent; background: $surface;
|
|
||||||
}
|
|
||||||
#prevtitle { height: 1; color: $accent; text-style: bold; }
|
|
||||||
#prevbody { height: 1fr; overflow-y: auto; }
|
|
||||||
"""
|
|
||||||
# Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la
|
|
||||||
# palette de commandes de Textual, et une lettre seule serait avalée
|
|
||||||
# par le champ de saisie qui a le focus.
|
|
||||||
BINDINGS = [
|
|
||||||
("f5", "deploy", t("Deploy")),
|
|
||||||
("f2", "edit_vm", t("Edit VM")),
|
|
||||||
("f3", "preview", t("Preview")),
|
|
||||||
("f6", "select_all", t("All")),
|
|
||||||
("f7", "select_main", t("Main versions")),
|
|
||||||
("f8", "select_none", t("None")),
|
|
||||||
("escape", "cancel", t("Cancel")),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.arch = ctx.get("native") or arches[0]
|
|
||||||
self.profile = "1"
|
|
||||||
self.custom = {}
|
|
||||||
self.overrides = {}
|
|
||||||
self.vms = []
|
|
||||||
self.rows = []
|
|
||||||
|
|
||||||
# -- construction de l'écran ----------------------------------- #
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header()
|
|
||||||
with Horizontal(id="body"):
|
|
||||||
with VerticalScroll(id="fields"):
|
|
||||||
yield Static(t("Architecture"), classes="grouptitle")
|
|
||||||
with RadioSet(id="f_arch"):
|
|
||||||
for a in arches:
|
|
||||||
label = a if a != "all" else t("all archs")
|
|
||||||
yield RadioButton(label, value=a == self.arch)
|
|
||||||
yield Static(t("Catalog"), classes="grouptitle")
|
|
||||||
yield SelectionList(id="f_catalog")
|
|
||||||
yield Static(t("Resources per VM"), classes="grouptitle")
|
|
||||||
with RadioSet(id="f_profile"):
|
|
||||||
for label in ("x1", "x2", "x3", "x4"):
|
|
||||||
yield RadioButton(label, value=label == "x1")
|
|
||||||
yield RadioButton(t("custom"))
|
|
||||||
yield Select(
|
|
||||||
[(str(c), c) for c in ctx["cpu_presets"]],
|
|
||||||
prompt=t("vCPU"),
|
|
||||||
id="f_vcpus",
|
|
||||||
disabled=True,
|
|
||||||
)
|
|
||||||
yield Select(
|
|
||||||
[
|
|
||||||
(f"{m} ({m // 1024}G)", m)
|
|
||||||
for m in ctx["ram_presets"]
|
|
||||||
],
|
|
||||||
prompt=t("RAM (MB)"),
|
|
||||||
id="f_ram",
|
|
||||||
disabled=True,
|
|
||||||
)
|
|
||||||
yield Select(
|
|
||||||
[(d, d) for d in ctx["disk_presets"]],
|
|
||||||
prompt=t("Disk"),
|
|
||||||
id="f_disk",
|
|
||||||
disabled=True,
|
|
||||||
)
|
|
||||||
yield Static("ERPLibre", classes="grouptitle")
|
|
||||||
yield Checkbox(
|
|
||||||
t("Install ERPLibre"),
|
|
||||||
value=defaults.get("install", True),
|
|
||||||
id="f_install",
|
|
||||||
)
|
|
||||||
yield Select(
|
|
||||||
[(b, b) for b in branches],
|
|
||||||
value=(
|
|
||||||
"develop" if "develop" in branches else branches[0]
|
|
||||||
),
|
|
||||||
allow_blank=False,
|
|
||||||
id="f_branch",
|
|
||||||
)
|
|
||||||
yield Select(
|
|
||||||
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
|
|
||||||
value=0 if profiles else Select.BLANK,
|
|
||||||
allow_blank=not profiles,
|
|
||||||
id="f_profile_install",
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
t("Production (/opt, confined)"),
|
|
||||||
value=defaults.get("prod", False),
|
|
||||||
id="f_prod",
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
t("Monitoring dashboard"),
|
|
||||||
value=defaults.get("monitor", True),
|
|
||||||
id="f_monitor",
|
|
||||||
)
|
|
||||||
yield Static("SSH", classes="grouptitle")
|
|
||||||
yield Input(
|
|
||||||
value=ctx.get("ssh_key") or "",
|
|
||||||
placeholder=t("SSH public key path"),
|
|
||||||
id="f_key",
|
|
||||||
)
|
|
||||||
yield Checkbox(
|
|
||||||
t("Add each VM to ~/.ssh/config"),
|
|
||||||
value=defaults.get("add_ssh_config", True),
|
|
||||||
id="f_sshcfg",
|
|
||||||
)
|
|
||||||
yield Static(t("Parallelism"), classes="grouptitle")
|
|
||||||
yield Select(
|
|
||||||
[(str(n), n) for n in range(1, host_cpu + 1)],
|
|
||||||
value=min(4, host_cpu),
|
|
||||||
allow_blank=False,
|
|
||||||
id="f_par",
|
|
||||||
)
|
|
||||||
with Vertical(id="right"):
|
|
||||||
yield DataTable(id="plan")
|
|
||||||
yield Static("", id="totals")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.title = t("Deploy ERPLibre VM(s)!")
|
|
||||||
table = self.query_one("#plan", DataTable)
|
|
||||||
table.cursor_type = "row"
|
|
||||||
table.add_columns(
|
|
||||||
t("Name"),
|
|
||||||
t("Distro"),
|
|
||||||
t("Version"),
|
|
||||||
t("Arch"),
|
|
||||||
"vCPU",
|
|
||||||
"RAM",
|
|
||||||
t("Disk"),
|
|
||||||
t("Status"),
|
|
||||||
)
|
|
||||||
self._reload_catalog(select_defaults=True)
|
|
||||||
|
|
||||||
# -- catalogue et recalcul ------------------------------------- #
|
|
||||||
def _entries(self):
|
|
||||||
return catalog.get(self.arch, [])
|
|
||||||
|
|
||||||
def _reload_catalog(self, select_defaults=False):
|
|
||||||
"""Recharge la liste à cocher après un changement d'architecture.
|
|
||||||
Les cases cochées sont conservées quand la même entrée existe
|
|
||||||
encore (l'identité est (distro, version, arch), pas le rang)."""
|
|
||||||
widget = self.query_one("#f_catalog", SelectionList)
|
|
||||||
keep = set()
|
|
||||||
if not select_defaults:
|
|
||||||
keep = {
|
|
||||||
entry_key(self._entries_before[i])
|
|
||||||
for i in widget.selected
|
|
||||||
if i < len(self._entries_before)
|
|
||||||
}
|
|
||||||
widget.clear_options()
|
|
||||||
entries = self._entries()
|
|
||||||
for i, e in enumerate(entries):
|
|
||||||
on = (
|
|
||||||
e.get("default", False)
|
|
||||||
if select_defaults
|
|
||||||
else (entry_key(e) in keep)
|
|
||||||
)
|
|
||||||
widget.add_option((entry_label(e), i, on))
|
|
||||||
self._entries_before = entries
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def _selected_entries(self):
|
|
||||||
widget = self.query_one("#f_catalog", SelectionList)
|
|
||||||
entries = self._entries()
|
|
||||||
return [entries[i] for i in sorted(widget.selected)]
|
|
||||||
|
|
||||||
def _recompute(self):
|
|
||||||
entries = self._selected_entries()
|
|
||||||
self.vms = build_vms(
|
|
||||||
entries,
|
|
||||||
self.profile,
|
|
||||||
base_vcpus,
|
|
||||||
host_cpu,
|
|
||||||
self.custom,
|
|
||||||
self.overrides,
|
|
||||||
)
|
|
||||||
self.rows = plan_rows(
|
|
||||||
self.vms,
|
|
||||||
domains,
|
|
||||||
(
|
|
||||||
extra_disk
|
|
||||||
if self.query_one("#f_install", Checkbox).value
|
|
||||||
else 0
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._render_plan()
|
|
||||||
|
|
||||||
def _render_plan(self):
|
|
||||||
table = self.query_one("#plan", DataTable)
|
|
||||||
table.clear()
|
|
||||||
for r in self.rows:
|
|
||||||
vm = r["vm"]
|
|
||||||
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]]
|
|
||||||
table.add_row(
|
|
||||||
vm["name"],
|
|
||||||
vm["distro"],
|
|
||||||
vm["version"],
|
|
||||||
vm["arch"],
|
|
||||||
str(vm["vcpus"]),
|
|
||||||
f"{vm['ram']}Mo",
|
|
||||||
f"{r['disk_gb']}G",
|
|
||||||
f"{icon}{r['note']}",
|
|
||||||
)
|
|
||||||
n, cpus, ram, disk = plan_totals(self.rows)
|
|
||||||
warn = ""
|
|
||||||
if free_ram and ram > free_ram:
|
|
||||||
warn = f" ⚠ {t('> host free RAM')}"
|
|
||||||
elif cpus > host_cpu:
|
|
||||||
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
|
|
||||||
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
|
|
||||||
dup_txt = (
|
|
||||||
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
|
|
||||||
if dupes
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
self.query_one("#totals", Static).update(
|
|
||||||
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
|
|
||||||
f"{warn}{dup_txt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- réactions aux champs -------------------------------------- #
|
|
||||||
def on_radio_set_changed(self, event) -> None:
|
|
||||||
if event.radio_set.id == "f_arch":
|
|
||||||
self.arch = arches[event.radio_set.pressed_index]
|
|
||||||
self._reload_catalog()
|
|
||||||
elif event.radio_set.id == "f_profile":
|
|
||||||
index = event.radio_set.pressed_index
|
|
||||||
self.profile = "custom" if index == 4 else str(index + 1)
|
|
||||||
custom = self.profile == "custom"
|
|
||||||
for wid in ("#f_vcpus", "#f_ram", "#f_disk"):
|
|
||||||
self.query_one(wid, Select).disabled = not custom
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def on_selection_list_selected_changed(self, event) -> None:
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def on_select_changed(self, event) -> None:
|
|
||||||
mapping = {"f_vcpus": "vcpus", "f_ram": "ram", "f_disk": "disk"}
|
|
||||||
field = mapping.get(event.select.id)
|
|
||||||
if field:
|
|
||||||
if event.value is not Select.BLANK:
|
|
||||||
self.custom[field] = event.value
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
def on_checkbox_changed(self, event) -> None:
|
|
||||||
if event.checkbox.id == "f_install":
|
|
||||||
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
|
||||||
|
|
||||||
# -- actions ---------------------------------------------------- #
|
|
||||||
def action_select_all(self) -> None:
|
|
||||||
self.query_one("#f_catalog", SelectionList).select_all()
|
|
||||||
|
|
||||||
def action_select_none(self) -> None:
|
|
||||||
self.query_one("#f_catalog", SelectionList).deselect_all()
|
|
||||||
|
|
||||||
def action_select_main(self) -> None:
|
|
||||||
"""Une VM par distro : la version marquée par défaut."""
|
|
||||||
widget = self.query_one("#f_catalog", SelectionList)
|
|
||||||
widget.deselect_all()
|
|
||||||
for i, e in enumerate(self._entries()):
|
|
||||||
if e.get("default"):
|
|
||||||
widget.select(i)
|
|
||||||
|
|
||||||
def action_edit_vm(self) -> None:
|
|
||||||
table = self.query_one("#plan", DataTable)
|
|
||||||
index = table.cursor_row
|
|
||||||
if not (0 <= index < len(self.vms)):
|
|
||||||
return
|
|
||||||
entries = self._selected_entries()
|
|
||||||
key = entry_key(entries[index])
|
|
||||||
|
|
||||||
def apply(changes):
|
|
||||||
if changes:
|
|
||||||
self.overrides.setdefault(key, {}).update(changes)
|
|
||||||
self._recompute()
|
|
||||||
|
|
||||||
self.push_screen(EditVMScreen(dict(self.vms[index])), apply)
|
|
||||||
|
|
||||||
def _form_values(self):
|
|
||||||
install = None
|
|
||||||
if self.query_one("#f_install", Checkbox).value and profiles:
|
|
||||||
index = self.query_one("#f_profile_install", Select).value
|
|
||||||
label, cmd = profiles[index if isinstance(index, int) else 0]
|
|
||||||
install = {
|
|
||||||
"branch": self.query_one("#f_branch", Select).value,
|
|
||||||
"prod": self.query_one("#f_prod", Checkbox).value,
|
|
||||||
"label": label,
|
|
||||||
"cmd": cmd,
|
|
||||||
"monitor": self.query_one("#f_monitor", Checkbox).value,
|
|
||||||
}
|
|
||||||
key = self.query_one("#f_key", Input).value.strip()
|
|
||||||
return {
|
|
||||||
"res_label": (
|
|
||||||
t("custom")
|
|
||||||
if self.profile == "custom"
|
|
||||||
else f"x{self.profile}"
|
|
||||||
),
|
|
||||||
"ssh_key": os.path.expanduser(key) if key else "",
|
|
||||||
"install": install,
|
|
||||||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
|
||||||
"parallelism": self.query_one("#f_par", Select).value,
|
|
||||||
}
|
|
||||||
|
|
||||||
def action_preview(self) -> None:
|
|
||||||
spec = build_spec(self.vms, domains, self._form_values())
|
|
||||||
build = ctx.get("build_command")
|
|
||||||
if not build:
|
|
||||||
return
|
|
||||||
lines = [build(vm, spec, True) for vm in spec["vms"]]
|
|
||||||
self.push_screen(PreviewScreen(lines or [t("Nothing selected.")]))
|
|
||||||
|
|
||||||
def action_deploy(self) -> None:
|
|
||||||
if not self.vms:
|
|
||||||
self.notify(t("Nothing selected."), severity="warning")
|
|
||||||
return
|
|
||||||
spec = build_spec(self.vms, domains, self._form_values())
|
|
||||||
if not spec["vms"]:
|
|
||||||
self.notify(
|
|
||||||
t("Nothing to create - every VM already exists."),
|
|
||||||
severity="warning",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
orphans = [r for r in self.rows if r["state"] == "orphan"]
|
|
||||||
if orphans and not getattr(self, "_orphan_ack", False):
|
|
||||||
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une
|
|
||||||
# première fois, F5 à nouveau vaut confirmation.
|
|
||||||
self._orphan_ack = True
|
|
||||||
self.notify(
|
|
||||||
t("orphan disk - will FAIL")
|
|
||||||
+ f" ({len(orphans)}) — "
|
|
||||||
+ t("press F5 again to confirm"),
|
|
||||||
severity="error",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
result["spec"] = spec
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
self.exit()
|
|
||||||
|
|
||||||
app = DeployForm()
|
|
||||||
# Exposé pour les tests headless (run_app=False), qui pilotent l'app
|
|
||||||
# eux-mêmes et ont besoin de lire la spec produite.
|
|
||||||
app._result = result
|
|
||||||
if not run_app:
|
|
||||||
return app
|
|
||||||
app.run()
|
|
||||||
return result["spec"]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Vue de progression : un bloc repliable par VM
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
|
||||||
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
|
|
||||||
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
|
|
||||||
|
|
||||||
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
|
||||||
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.containers import Vertical, VerticalScroll
|
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Collapsible,
|
|
||||||
Footer,
|
|
||||||
Header,
|
|
||||||
RichLog,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def slug(name):
|
|
||||||
"""Identifiant de widget : Textual n'accepte ni point ni tiret en
|
|
||||||
tête, et les noms de VM en contiennent."""
|
|
||||||
return "vm_" + "".join(c if c.isalnum() else "_" for c in name)
|
|
||||||
|
|
||||||
class Progress(App):
|
|
||||||
CSS = """
|
|
||||||
#blocks { height: 1fr; }
|
|
||||||
RichLog { height: 14; border: solid $panel; }
|
|
||||||
#summary { height: auto; color: $accent; padding: 0 1; }
|
|
||||||
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
|
||||||
"""
|
|
||||||
BINDINGS = [
|
|
||||||
("c", "copy_current", t("Copy log")),
|
|
||||||
("C", "copy_all", t("Copy all logs")),
|
|
||||||
("q", "quit", t("Quit")),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._out = {name: "" for _jid, name, _p in jobs}
|
|
||||||
self._done = 0
|
|
||||||
self._t0 = time.time()
|
|
||||||
self._slots = threading.Semaphore(max(1, parallelism))
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header()
|
|
||||||
with VerticalScroll(id="blocks"):
|
|
||||||
for jid, name, _parts in jobs:
|
|
||||||
with Collapsible(
|
|
||||||
title=f"⏳ [{jid}] {name}",
|
|
||||||
collapsed=False,
|
|
||||||
id=slug(name),
|
|
||||||
):
|
|
||||||
yield RichLog(
|
|
||||||
id=f"log_{slug(name)}",
|
|
||||||
highlight=False,
|
|
||||||
markup=False,
|
|
||||||
wrap=True,
|
|
||||||
)
|
|
||||||
with Vertical():
|
|
||||||
yield Static("", id="summary")
|
|
||||||
yield Static(
|
|
||||||
f" {t('c copy log · C copy all · q quit')}", id="hint"
|
|
||||||
)
|
|
||||||
yield Button(t("Copy all logs"), id="copyall")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.title = t("Deploying")
|
|
||||||
self._refresh_summary()
|
|
||||||
for jid, name, parts in jobs:
|
|
||||||
self.run_job(jid, name, parts)
|
|
||||||
|
|
||||||
def _refresh_summary(self):
|
|
||||||
self.query_one("#summary", Static).update(
|
|
||||||
f" {self._done}/{len(jobs)} — "
|
|
||||||
f"{fmt_dur(time.time() - self._t0)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# `thread=True` : subprocess.run est bloquant ; le faire dans un
|
|
||||||
# thread garde la boucle d'événements Textual fluide. Le sémaphore
|
|
||||||
# borne les déploiements SIMULTANÉS — sans lui, demander « 4 en
|
|
||||||
# parallèle » en lancerait autant que de VM.
|
|
||||||
def run_job(self, jid, name, parts):
|
|
||||||
def _job() -> None:
|
|
||||||
with self._slots:
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
res = subprocess.run(
|
|
||||||
parts, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
rc = res.returncode
|
|
||||||
out = (res.stdout or "") + (res.stderr or "")
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
rc, out = 1, str(exc)
|
|
||||||
self.call_from_thread(
|
|
||||||
self._finish, jid, name, rc, out, time.time() - t0
|
|
||||||
)
|
|
||||||
|
|
||||||
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
|
||||||
|
|
||||||
def _finish(self, jid, name, rc, out, secs):
|
|
||||||
self._out[name] = out
|
|
||||||
results.append((name, rc, out, secs))
|
|
||||||
self._done += 1
|
|
||||||
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
|
||||||
for line in out.strip().splitlines():
|
|
||||||
log.write(line)
|
|
||||||
block = self.query_one(f"#{slug(name)}", Collapsible)
|
|
||||||
mark = "✅" if rc == 0 else "❌"
|
|
||||||
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
|
||||||
"" if rc == 0 else f" · rc={rc}"
|
|
||||||
)
|
|
||||||
# Un succès se replie (il n'y a plus rien à y lire) ; un échec
|
|
||||||
# reste ouvert.
|
|
||||||
block.collapsed = rc == 0
|
|
||||||
self._refresh_summary()
|
|
||||||
|
|
||||||
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
|
||||||
def _copy(self, text, what):
|
|
||||||
payload, cut = clip_payload(text)
|
|
||||||
if not payload.strip():
|
|
||||||
self.notify(t("Nothing to copy."), severity="warning")
|
|
||||||
return
|
|
||||||
self.copy_to_clipboard(payload)
|
|
||||||
note = f"{what} — {len(payload)} {t('chars')}"
|
|
||||||
if cut:
|
|
||||||
note += f" ({t('tail only, log was truncated')})"
|
|
||||||
self.notify(
|
|
||||||
note + "\n" + t("Needs an OSC 52 capable terminal."),
|
|
||||||
title=t("Clipboard"),
|
|
||||||
timeout=8,
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_copy_current(self) -> None:
|
|
||||||
focused = self.focused
|
|
||||||
for _jid, name, _p in jobs:
|
|
||||||
node = focused
|
|
||||||
while node is not None:
|
|
||||||
if getattr(node, "id", None) == slug(name):
|
|
||||||
self._copy(self._out[name], name)
|
|
||||||
return
|
|
||||||
node = node.parent
|
|
||||||
self.action_copy_all()
|
|
||||||
|
|
||||||
def action_copy_all(self) -> None:
|
|
||||||
blob = "\n".join(
|
|
||||||
f"───── {name} ─────\n{self._out[name]}"
|
|
||||||
for _jid, name, _p in jobs
|
|
||||||
)
|
|
||||||
self._copy(blob, t("all logs"))
|
|
||||||
|
|
||||||
def on_button_pressed(self, event) -> None:
|
|
||||||
if event.button.id == "copyall":
|
|
||||||
self.action_copy_all()
|
|
||||||
|
|
||||||
app = Progress()
|
|
||||||
app._results = results # lecture par les tests headless
|
|
||||||
if not run_app:
|
|
||||||
return app
|
|
||||||
app.run()
|
|
||||||
return results
|
|
||||||
|
|
@ -260,8 +260,7 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
||||||
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
||||||
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
||||||
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
|
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
|
||||||
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM).
|
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM)."""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
text = Path(log_path).read_text(errors="replace")
|
text = Path(log_path).read_text(errors="replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
@ -271,7 +270,9 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
||||||
low = line.lower()
|
low = line.lower()
|
||||||
if EXIT_MARKER in line:
|
if EXIT_MARKER in line:
|
||||||
continue
|
continue
|
||||||
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
|
if "error" in low and not any(
|
||||||
|
ig in line for ig in _LST_IGNORE_ERROR
|
||||||
|
):
|
||||||
nerr += 1
|
nerr += 1
|
||||||
if "warning" in low and not any(
|
if "warning" in low and not any(
|
||||||
ig in line for ig in _LST_IGNORE_WARNING
|
ig in line for ig in _LST_IGNORE_WARNING
|
||||||
|
|
@ -310,8 +311,7 @@ def _read_tail(
|
||||||
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
|
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
|
||||||
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
|
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é =
|
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.
|
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin."""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
size = os.path.getsize(path)
|
size = os.path.getsize(path)
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
|
|
@ -351,14 +351,10 @@ def load_stats() -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def record_duration(distro, version, arch, secs, ok=True) -> None:
|
def record_duration(distro, version, arch, secs) -> None:
|
||||||
"""Enregistre une install (distro + version + archi + durée + horodatage)
|
"""Enregistre une install (distro + version + archi + durée + horodatage)
|
||||||
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
|
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
|
||||||
500 derniers runs.
|
500 derniers runs."""
|
||||||
|
|
||||||
`ok=False` conserve la trace d'un ÉCHEC : sans elle aucun taux de réussite
|
|
||||||
n'est calculable. Les échecs sont exclus des moyennes et de l'ETA (leur
|
|
||||||
durée ne dit rien du temps d'une install qui aboutit)."""
|
|
||||||
data = load_stats()
|
data = load_stats()
|
||||||
runs = data.setdefault("runs", [])
|
runs = data.setdefault("runs", [])
|
||||||
runs.append(
|
runs.append(
|
||||||
|
|
@ -368,7 +364,6 @@ def record_duration(distro, version, arch, secs, ok=True) -> None:
|
||||||
"arch": arch or "?",
|
"arch": arch or "?",
|
||||||
"seconds": int(secs),
|
"seconds": int(secs),
|
||||||
"ts": int(time.time()),
|
"ts": int(time.time()),
|
||||||
"ok": bool(ok),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
data["runs"] = runs[-500:]
|
data["runs"] = runs[-500:]
|
||||||
|
|
@ -378,80 +373,8 @@ def record_duration(distro, version, arch, secs, ok=True) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def reset_stats() -> int:
|
|
||||||
"""Vide l'historique. Renvoie le nombre de runs effacés."""
|
|
||||||
count = len(all_runs())
|
|
||||||
try:
|
|
||||||
_stats_path().write_text(json.dumps({"runs": []}))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def all_runs(stats=None):
|
|
||||||
"""Tous les runs, succès ET échecs."""
|
|
||||||
return (stats or load_stats()).get("runs", []) or []
|
|
||||||
|
|
||||||
|
|
||||||
def _runs(stats=None):
|
def _runs(stats=None):
|
||||||
"""Runs RÉUSSIS seulement : base des moyennes et de l'ETA.
|
return (stats or load_stats()).get("runs", []) or []
|
||||||
|
|
||||||
Les entrées écrites avant l'ajout du champ « ok » n'en ont pas ; elles
|
|
||||||
étaient forcément des succès (seuls ceux-là étaient enregistrés).
|
|
||||||
"""
|
|
||||||
return [r for r in all_runs(stats) if r.get("ok", True)]
|
|
||||||
|
|
||||||
|
|
||||||
def stats_summary(stats=None):
|
|
||||||
"""Chiffres globaux de l'historique d'installation.
|
|
||||||
|
|
||||||
Renvoie un dict vide quand rien n'a encore été enregistré, pour que
|
|
||||||
l'appelant distingue « aucune donnée » de « zéro seconde »."""
|
|
||||||
runs = all_runs(stats)
|
|
||||||
if not runs:
|
|
||||||
return {}
|
|
||||||
ok = [r for r in runs if r.get("ok", True)]
|
|
||||||
secs = sorted(r["seconds"] for r in ok)
|
|
||||||
lst_ts = [r.get("ts", 0) for r in runs if r.get("ts")]
|
|
||||||
return {
|
|
||||||
"total": len(runs),
|
|
||||||
"ok": len(ok),
|
|
||||||
"failed": len(runs) - len(ok),
|
|
||||||
"first_ts": min(lst_ts) if lst_ts else 0,
|
|
||||||
"last_ts": max(lst_ts) if lst_ts else 0,
|
|
||||||
"median": secs[len(secs) // 2] if secs else 0,
|
|
||||||
"min": secs[0] if secs else 0,
|
|
||||||
"max": secs[-1] if secs else 0,
|
|
||||||
"total_secs": sum(secs),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stats_by(field, stats=None):
|
|
||||||
"""Agrège par « distro », « arch » ou « distro version ».
|
|
||||||
|
|
||||||
Renvoie [(clé, nb_réussis, moyenne_secondes, nb_échecs)] trié du plus
|
|
||||||
utilisé au moins utilisé."""
|
|
||||||
dct = {}
|
|
||||||
for run in all_runs(stats):
|
|
||||||
if field == "version":
|
|
||||||
key = f"{run.get('distro', '?')} {run.get('version', '?')}"
|
|
||||||
else:
|
|
||||||
key = run.get(field, "?")
|
|
||||||
entry = dct.setdefault(key, {"secs": [], "failed": 0})
|
|
||||||
if run.get("ok", True):
|
|
||||||
entry["secs"].append(run["seconds"])
|
|
||||||
else:
|
|
||||||
entry["failed"] += 1
|
|
||||||
lst = [
|
|
||||||
(
|
|
||||||
key,
|
|
||||||
len(e["secs"]),
|
|
||||||
int(sum(e["secs"]) / len(e["secs"])) if e["secs"] else 0,
|
|
||||||
e["failed"],
|
|
||||||
)
|
|
||||||
for key, e in dct.items()
|
|
||||||
]
|
|
||||||
return sorted(lst, key=lambda row: (-(row[1] + row[3]), row[0]))
|
|
||||||
|
|
||||||
|
|
||||||
def eta_reference(stats, arch):
|
def eta_reference(stats, arch):
|
||||||
|
|
@ -727,9 +650,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
self._cells = {}
|
self._cells = {}
|
||||||
# Historique de durées (ETA) + dossier disque à surveiller.
|
# Historique de durées (ETA) + dossier disque à surveiller.
|
||||||
self._stats = load_stats()
|
self._stats = load_stats()
|
||||||
self._disk_dir = (
|
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
|
||||||
os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
|
|
||||||
)
|
|
||||||
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
||||||
self._domstate = {}
|
self._domstate = {}
|
||||||
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
||||||
|
|
@ -777,13 +698,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
table.add_column("Disque", key="disk", width=8)
|
table.add_column("Disque", key="disk", width=8)
|
||||||
for i, vm in enumerate(vms, 1):
|
for i, vm in enumerate(vms, 1):
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
str(i), vm["name"], "", "⏳", "—", "--:--", "-",
|
||||||
vm["name"],
|
|
||||||
"",
|
|
||||||
"⏳",
|
|
||||||
"—",
|
|
||||||
"--:--",
|
|
||||||
"-",
|
|
||||||
key=vm["name"],
|
key=vm["name"],
|
||||||
)
|
)
|
||||||
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
||||||
|
|
@ -804,9 +719,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
|
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
|
||||||
# progression (terminées / total + durée globale).
|
# progression (terminées / total + durée globale).
|
||||||
self.title = (
|
self.title = f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
|
||||||
f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
|
|
||||||
)
|
|
||||||
self.sub_title = f"0/{len(vms)} {t('completed')}"
|
self.sub_title = f"0/{len(vms)} {t('completed')}"
|
||||||
self._refresh_ssh()
|
self._refresh_ssh()
|
||||||
self._load_selected_log(reset=True)
|
self._load_selected_log(reset=True)
|
||||||
|
|
@ -895,10 +808,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
errors[name] = scan_log_errors(vm["log"])
|
errors[name] = scan_log_errors(vm["log"])
|
||||||
# Odoo up ? On ne teste que celles pas encore confirmées up
|
# Odoo up ? On ne teste que celles pas encore confirmées up
|
||||||
# et non effacées (test TCP court sur :8069).
|
# et non effacées (test TCP court sur :8069).
|
||||||
if (
|
if name not in self._odoo_up and self._domstate.get(
|
||||||
name not in self._odoo_up
|
name
|
||||||
and self._domstate.get(name) != "gone"
|
) != "gone":
|
||||||
):
|
|
||||||
if _port_open(vm.get("ip"), 8069):
|
if _port_open(vm.get("ip"), 8069):
|
||||||
odoo[name] = True
|
odoo[name] = True
|
||||||
return disks, status, self._collect_tele(), errors, odoo
|
return disks, status, self._collect_tele(), errors, odoo
|
||||||
|
|
@ -924,9 +836,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
self._set_cell(table, name, "disk", disks.get(name, "-"))
|
self._set_cell(table, name, "disk", disks.get(name, "-"))
|
||||||
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table,
|
table, name, "odoo",
|
||||||
name,
|
|
||||||
"odoo",
|
|
||||||
"🟢" if name in self._odoo_up else "—",
|
"🟢" if name in self._odoo_up else "—",
|
||||||
)
|
)
|
||||||
if name in self._final:
|
if name in self._final:
|
||||||
|
|
@ -945,14 +855,12 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
if state in ("done", "failed"):
|
if state in ("done", "failed"):
|
||||||
elapsed = now - started
|
elapsed = now - started
|
||||||
self._final[name] = (state, code, elapsed)
|
self._final[name] = (state, code, elapsed)
|
||||||
# Les échecs sont enregistrés eux aussi (ok=False) :
|
if state == "done":
|
||||||
# sans eux, aucun taux de réussite n'est calculable.
|
|
||||||
record_duration(
|
record_duration(
|
||||||
vm.get("distro"),
|
vm.get("distro"),
|
||||||
vm.get("version"),
|
vm.get("version"),
|
||||||
vm.get("arch"),
|
vm.get("arch"),
|
||||||
elapsed,
|
elapsed,
|
||||||
ok=state == "done",
|
|
||||||
)
|
)
|
||||||
self._stats = load_stats()
|
self._stats = load_stats()
|
||||||
lbl = "✅" if state == "done" else f"❌ ({code})"
|
lbl = "✅" if state == "done" else f"❌ ({code})"
|
||||||
|
|
@ -963,9 +871,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
|
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
|
||||||
# le log, y compris pour un « succès ».
|
# le log, y compris pour un « succès ».
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table,
|
table, name, "err",
|
||||||
name,
|
|
||||||
"err",
|
|
||||||
self._err_label(self._errcount.get(name)),
|
self._err_label(self._errcount.get(name)),
|
||||||
)
|
)
|
||||||
elif ds == "paused":
|
elif ds == "paused":
|
||||||
|
|
@ -1034,14 +940,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
else:
|
else:
|
||||||
running += 1
|
running += 1
|
||||||
return {
|
return {
|
||||||
"total": len(vms),
|
"total": len(vms), "done": done, "fail": fail,
|
||||||
"done": done,
|
"deleted": deleted, "running": running, "paused": paused,
|
||||||
"fail": fail,
|
"err_vms": err_vms, "warn_vms": warn_vms,
|
||||||
"deleted": deleted,
|
|
||||||
"running": running,
|
|
||||||
"paused": paused,
|
|
||||||
"err_vms": err_vms,
|
|
||||||
"warn_vms": warn_vms,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _update_stats(self):
|
def _update_stats(self):
|
||||||
|
|
@ -1181,11 +1082,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
for i, b in enumerate(available, 1):
|
for i, b in enumerate(available, 1):
|
||||||
print(f" [{i}] {b}{' *' if i == 1 else ''}")
|
print(f" [{i}] {b}{' *' if i == 1 else ''}")
|
||||||
print(" [i] Installer un autre navigateur")
|
print(" [i] Installer un autre navigateur")
|
||||||
sel = (
|
sel = input(
|
||||||
input(f"Choix (numéro, vide = {available[0]}) : ")
|
f"Choix (numéro, vide = {available[0]}) : "
|
||||||
.strip()
|
).strip().lower()
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
if sel == "i":
|
if sel == "i":
|
||||||
return self._install_cli_browser()
|
return self._install_cli_browser()
|
||||||
if not sel:
|
if not sel:
|
||||||
|
|
@ -1224,7 +1123,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
return None
|
return None
|
||||||
printable = " ".join(cmd)
|
printable = " ".join(cmd)
|
||||||
print(f"Commande : {printable}")
|
print(f"Commande : {printable}")
|
||||||
ans = input("Installer maintenant ? (o/N) : ").strip().lower()
|
ans = input(
|
||||||
|
"Installer maintenant ? (o/N) : "
|
||||||
|
).strip().lower()
|
||||||
if ans not in ("o", "oui", "y", "yes"):
|
if ans not in ("o", "oui", "y", "yes"):
|
||||||
return None
|
return None
|
||||||
rc = os.system(printable)
|
rc = os.system(printable)
|
||||||
|
|
|
||||||
1124
script/todo/todo.py
1124
script/todo/todo.py
File diff suppressed because it is too large
Load diff
|
|
@ -262,9 +262,9 @@ TRANSLATIONS = {
|
||||||
"fr": "Ajouter cette VM à ~/.ssh/config ? (o/N, défaut : non) : ",
|
"fr": "Ajouter cette VM à ~/.ssh/config ? (o/N, défaut : non) : ",
|
||||||
"en": "Add this VM to ~/.ssh/config? (y/N, default: no): ",
|
"en": "Add this VM to ~/.ssh/config? (y/N, default: no): ",
|
||||||
},
|
},
|
||||||
"Add each VM to ~/.ssh/config? (Y/n): ": {
|
"Add each VM to ~/.ssh/config? (y/N): ": {
|
||||||
"fr": "Ajouter chaque VM à ~/.ssh/config ? (O/n, défaut : oui) : ",
|
"fr": "Ajouter chaque VM à ~/.ssh/config ? (o/N, défaut : non) : ",
|
||||||
"en": "Add each VM to ~/.ssh/config? (Y/n, default: yes): ",
|
"en": "Add each VM to ~/.ssh/config? (y/N, default: no): ",
|
||||||
},
|
},
|
||||||
"Waiting for the VM IP (DHCP lease)...": {
|
"Waiting for the VM IP (DHCP lease)...": {
|
||||||
"fr": "Attente de l'IP de la VM (bail DHCP)...",
|
"fr": "Attente de l'IP de la VM (bail DHCP)...",
|
||||||
|
|
@ -1249,46 +1249,6 @@ TRANSLATIONS = {
|
||||||
"fr": "Choix (1-4, défaut 1) :",
|
"fr": "Choix (1-4, défaut 1) :",
|
||||||
"en": "Choice (1-4, default 1):",
|
"en": "Choice (1-4, default 1):",
|
||||||
},
|
},
|
||||||
"Choice (1-5, default 1):": {
|
|
||||||
"fr": "Choix (1-5, défaut 1) :",
|
|
||||||
"en": "Choice (1-5, default 1):",
|
|
||||||
},
|
|
||||||
"Custom - set vCPU, RAM and disk": {
|
|
||||||
"fr": "Personnalisé — choisir vCPU, RAM et disque",
|
|
||||||
"en": "Custom — set vCPU, RAM and disk",
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"fr": "personnalisé",
|
|
||||||
"en": "custom",
|
|
||||||
},
|
|
||||||
"varies": {
|
|
||||||
"fr": "varié",
|
|
||||||
"en": "varies",
|
|
||||||
},
|
|
||||||
"vCPU per VM, blank = keep": {
|
|
||||||
"fr": "vCPU par VM, vide = garder",
|
|
||||||
"en": "vCPU per VM, blank = keep",
|
|
||||||
},
|
|
||||||
"New vCPU count, blank = keep": {
|
|
||||||
"fr": "Nouveau nombre de vCPU, vide = garder",
|
|
||||||
"en": "New vCPU count, blank = keep",
|
|
||||||
},
|
|
||||||
"Invalid vCPU count.": {
|
|
||||||
"fr": "Nombre de vCPU invalide.",
|
|
||||||
"en": "Invalid vCPU count.",
|
|
||||||
},
|
|
||||||
"More vCPU than host cores": {
|
|
||||||
"fr": "Plus de vCPU que de cœurs sur l'hôte",
|
|
||||||
"en": "More vCPU than host cores",
|
|
||||||
},
|
|
||||||
"> host cores": {
|
|
||||||
"fr": "> cœurs de l'hôte",
|
|
||||||
"en": "> host cores",
|
|
||||||
},
|
|
||||||
"Total vCPU (all running):": {
|
|
||||||
"fr": "vCPU total (toutes démarrées) :",
|
|
||||||
"en": "Total vCPU (all running):",
|
|
||||||
},
|
|
||||||
"Deploy VM(s) (one or many)": {
|
"Deploy VM(s) (one or many)": {
|
||||||
"fr": "🚀 Déployer une ou plusieurs VM",
|
"fr": "🚀 Déployer une ou plusieurs VM",
|
||||||
"en": "🚀 Deploy VM(s) (one or many)",
|
"en": "🚀 Deploy VM(s) (one or many)",
|
||||||
|
|
@ -1351,214 +1311,6 @@ TRANSLATIONS = {
|
||||||
"fr": "📊 Télémétrie de navigation (TUI)",
|
"fr": "📊 Télémétrie de navigation (TUI)",
|
||||||
"en": "📊 Navigation telemetry (TUI)",
|
"en": "📊 Navigation telemetry (TUI)",
|
||||||
},
|
},
|
||||||
"Configuration": {
|
|
||||||
"fr": "⚙ Configuration",
|
|
||||||
"en": "⚙ Configuration",
|
|
||||||
},
|
|
||||||
"Interface": {
|
|
||||||
"fr": "Interface",
|
|
||||||
"en": "Interface",
|
|
||||||
},
|
|
||||||
"Maintenance": {
|
|
||||||
"fr": "Maintenance",
|
|
||||||
"en": "Maintenance",
|
|
||||||
},
|
|
||||||
"Language / Langue": {
|
|
||||||
"fr": "🌐 Langue / Language",
|
|
||||||
"en": "🌐 Language / Langue",
|
|
||||||
},
|
|
||||||
"QEMU deployment interface": {
|
|
||||||
"fr": "🖥 Interface de déploiement QEMU",
|
|
||||||
"en": "🖥 QEMU deployment interface",
|
|
||||||
},
|
|
||||||
"Display while deploying": {
|
|
||||||
"fr": "📜 Affichage pendant le déploiement",
|
|
||||||
"en": "📜 Display while deploying",
|
|
||||||
},
|
|
||||||
"Ask every time": {
|
|
||||||
"fr": "Demander à chaque fois",
|
|
||||||
"en": "Ask every time",
|
|
||||||
},
|
|
||||||
"TUI form": {
|
|
||||||
"fr": "Formulaire TUI",
|
|
||||||
"en": "TUI form",
|
|
||||||
},
|
|
||||||
"Classic questions (line by line)": {
|
|
||||||
"fr": "Questions classiques (ligne par ligne)",
|
|
||||||
"en": "Classic questions (line by line)",
|
|
||||||
},
|
|
||||||
"CLI output (easy to copy)": {
|
|
||||||
"fr": "Sortie CLI (facile à copier)",
|
|
||||||
"en": "CLI output (easy to copy)",
|
|
||||||
},
|
|
||||||
"TUI, collapsible blocks per VM": {
|
|
||||||
"fr": "TUI, blocs repliables par VM",
|
|
||||||
"en": "TUI, collapsible blocks per VM",
|
|
||||||
},
|
|
||||||
"Choice (number, blank = keep):": {
|
|
||||||
"fr": "Choix (numéro, vide = garder) :",
|
|
||||||
"en": "Choice (number, blank = keep):",
|
|
||||||
},
|
|
||||||
"Reset all preferences": {
|
|
||||||
"fr": "🧹 Réinitialiser toutes les préférences",
|
|
||||||
"en": "🧹 Reset all preferences",
|
|
||||||
},
|
|
||||||
"Preferences reset": {
|
|
||||||
"fr": "Préférences réinitialisées",
|
|
||||||
"en": "Preferences reset",
|
|
||||||
},
|
|
||||||
"Interface:": {
|
|
||||||
"fr": "Interface :",
|
|
||||||
"en": "Interface:",
|
|
||||||
},
|
|
||||||
"(change the default in TODO > Configuration)": {
|
|
||||||
"fr": "(changer le défaut dans TODO > Configuration)",
|
|
||||||
"en": "(change the default in TODO > Configuration)",
|
|
||||||
},
|
|
||||||
"Loading (VM list, branches)...": {
|
|
||||||
"fr": "Chargement (liste des VM, branches)...",
|
|
||||||
"en": "Loading (VM list, branches)...",
|
|
||||||
},
|
|
||||||
"Architecture": {
|
|
||||||
"fr": "Architecture",
|
|
||||||
"en": "Architecture",
|
|
||||||
},
|
|
||||||
"Resources per VM": {
|
|
||||||
"fr": "Ressources par VM",
|
|
||||||
"en": "Resources per VM",
|
|
||||||
},
|
|
||||||
"Install ERPLibre": {
|
|
||||||
"fr": "Installer ERPLibre",
|
|
||||||
"en": "Install ERPLibre",
|
|
||||||
},
|
|
||||||
"Production (/opt, confined)": {
|
|
||||||
"fr": "Production (/opt, confiné)",
|
|
||||||
"en": "Production (/opt, confined)",
|
|
||||||
},
|
|
||||||
"Monitoring dashboard": {
|
|
||||||
"fr": "Tableau de bord de suivi",
|
|
||||||
"en": "Monitoring dashboard",
|
|
||||||
},
|
|
||||||
"Add each VM to ~/.ssh/config": {
|
|
||||||
"fr": "Ajouter chaque VM à ~/.ssh/config",
|
|
||||||
"en": "Add each VM to ~/.ssh/config",
|
|
||||||
},
|
|
||||||
"Parallelism": {
|
|
||||||
"fr": "Parallélisme",
|
|
||||||
"en": "Parallelism",
|
|
||||||
},
|
|
||||||
"Name": {
|
|
||||||
"fr": "Nom",
|
|
||||||
"en": "Name",
|
|
||||||
},
|
|
||||||
"Distro": {
|
|
||||||
"fr": "Distro",
|
|
||||||
"en": "Distro",
|
|
||||||
},
|
|
||||||
"Version": {
|
|
||||||
"fr": "Version",
|
|
||||||
"en": "Version",
|
|
||||||
},
|
|
||||||
"Arch": {
|
|
||||||
"fr": "Archi",
|
|
||||||
"en": "Arch",
|
|
||||||
},
|
|
||||||
"vCPU": {
|
|
||||||
"fr": "vCPU",
|
|
||||||
"en": "vCPU",
|
|
||||||
},
|
|
||||||
"RAM (MB)": {
|
|
||||||
"fr": "RAM (Mo)",
|
|
||||||
"en": "RAM (MB)",
|
|
||||||
},
|
|
||||||
"all archs": {
|
|
||||||
"fr": "toutes",
|
|
||||||
"en": "all",
|
|
||||||
},
|
|
||||||
"exists - skipped": {
|
|
||||||
"fr": "existe — ignorée",
|
|
||||||
"en": "exists - skipped",
|
|
||||||
},
|
|
||||||
"orphan disk - will FAIL": {
|
|
||||||
"fr": "disque orphelin — ÉCHOUERA",
|
|
||||||
"en": "orphan disk - will FAIL",
|
|
||||||
},
|
|
||||||
"press F5 again to confirm": {
|
|
||||||
"fr": "F5 à nouveau pour confirmer",
|
|
||||||
"en": "press F5 again to confirm",
|
|
||||||
},
|
|
||||||
"Deploy": {
|
|
||||||
"fr": "Déployer",
|
|
||||||
"en": "Deploy",
|
|
||||||
},
|
|
||||||
"Edit VM": {
|
|
||||||
"fr": "Éditer la VM",
|
|
||||||
"en": "Edit VM",
|
|
||||||
},
|
|
||||||
"Preview": {
|
|
||||||
"fr": "Aperçu",
|
|
||||||
"en": "Preview",
|
|
||||||
},
|
|
||||||
"All": {
|
|
||||||
"fr": "Tout",
|
|
||||||
"en": "All",
|
|
||||||
},
|
|
||||||
"Main versions": {
|
|
||||||
"fr": "Versions principales",
|
|
||||||
"en": "Main versions",
|
|
||||||
},
|
|
||||||
"None": {
|
|
||||||
"fr": "Rien",
|
|
||||||
"en": "None",
|
|
||||||
},
|
|
||||||
"Cancel": {
|
|
||||||
"fr": "Annuler",
|
|
||||||
"en": "Cancel",
|
|
||||||
},
|
|
||||||
"Apply": {
|
|
||||||
"fr": "Appliquer",
|
|
||||||
"en": "Apply",
|
|
||||||
},
|
|
||||||
"Close": {
|
|
||||||
"fr": "Fermer",
|
|
||||||
"en": "Close",
|
|
||||||
},
|
|
||||||
"Copy log": {
|
|
||||||
"fr": "Copier le log",
|
|
||||||
"en": "Copy log",
|
|
||||||
},
|
|
||||||
"Copy all logs": {
|
|
||||||
"fr": "Copier tous les logs",
|
|
||||||
"en": "Copy all logs",
|
|
||||||
},
|
|
||||||
"all logs": {
|
|
||||||
"fr": "tous les logs",
|
|
||||||
"en": "all logs",
|
|
||||||
},
|
|
||||||
"c copy log · C copy all · q quit": {
|
|
||||||
"fr": "c copier le log · C copier tout · q quitter",
|
|
||||||
"en": "c copy log · C copy all · q quit",
|
|
||||||
},
|
|
||||||
"Clipboard": {
|
|
||||||
"fr": "Presse-papiers",
|
|
||||||
"en": "Clipboard",
|
|
||||||
},
|
|
||||||
"chars": {
|
|
||||||
"fr": "car.",
|
|
||||||
"en": "chars",
|
|
||||||
},
|
|
||||||
"tail only, log was truncated": {
|
|
||||||
"fr": "fin seulement, log tronqué",
|
|
||||||
"en": "tail only, log was truncated",
|
|
||||||
},
|
|
||||||
"Needs an OSC 52 capable terminal.": {
|
|
||||||
"fr": "Nécessite un terminal compatible OSC 52.",
|
|
||||||
"en": "Needs an OSC 52 capable terminal.",
|
|
||||||
},
|
|
||||||
"Nothing to copy.": {
|
|
||||||
"fr": "Rien à copier.",
|
|
||||||
"en": "Nothing to copy.",
|
|
||||||
},
|
|
||||||
"TODO navigation telemetry": {
|
"TODO navigation telemetry": {
|
||||||
"fr": "TODO — télémétrie de navigation",
|
"fr": "TODO — télémétrie de navigation",
|
||||||
"en": "TODO navigation telemetry",
|
"en": "TODO navigation telemetry",
|
||||||
|
|
@ -2453,116 +2205,21 @@ TRANSLATIONS = {
|
||||||
"tourneront pas toutes en même temps.",
|
"tourneront pas toutes en même temps.",
|
||||||
"en": "Total RAM exceeds host free RAM: not all VMs will run at once.",
|
"en": "Total RAM exceeds host free RAM: not all VMs will run at once.",
|
||||||
},
|
},
|
||||||
"Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ": {
|
"Install ERPLibre into ~/git/erplibre on each VM? (y/N): ": {
|
||||||
"fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? "
|
"fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? (o/N, défaut : non) : ",
|
||||||
"(O/n, défaut : oui) : ",
|
"en": "Install ERPLibre into ~/git/erplibre on each VM? (y/N, default: no): ",
|
||||||
"en": "Install ERPLibre into ~/git/erplibre on each VM? "
|
|
||||||
"(Y/n, default: yes): ",
|
|
||||||
},
|
},
|
||||||
"Deploy these VMs now? (Y/n): ": {
|
"Deploy these VMs now? (y/N): ": {
|
||||||
"fr": "Déployer ces VM maintenant ? (O/n, défaut : oui) : ",
|
"fr": "Déployer ces VM maintenant ? (o/N, défaut : non) : ",
|
||||||
"en": "Deploy these VMs now? (Y/n, default: yes): ",
|
"en": "Deploy these VMs now? (y/N, default: no): ",
|
||||||
},
|
|
||||||
"Discard everything and start over? (y/N): ": {
|
|
||||||
"fr": "Tout abandonner et recommencer ? (o/N, défaut : non) : ",
|
|
||||||
"en": "Discard everything and start over? (y/N, default: no): ",
|
|
||||||
},
|
|
||||||
"Final review before deployment": {
|
|
||||||
"fr": "Récapitulatif final avant déploiement",
|
|
||||||
"en": "Final review before deployment",
|
|
||||||
},
|
|
||||||
"VMs to create:": {
|
|
||||||
"fr": "VM à créer :",
|
|
||||||
"en": "VMs to create:",
|
|
||||||
},
|
|
||||||
"Existing, left untouched:": {
|
|
||||||
"fr": "Existantes, laissées intactes :",
|
|
||||||
"en": "Existing, left untouched:",
|
|
||||||
},
|
|
||||||
"ERPLibre install:": {
|
|
||||||
"fr": "Installation ERPLibre :",
|
|
||||||
"en": "ERPLibre install:",
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"fr": "profil",
|
|
||||||
"en": "profile",
|
|
||||||
},
|
|
||||||
"branch": {
|
|
||||||
"fr": "branche",
|
|
||||||
"en": "branch",
|
|
||||||
},
|
|
||||||
"production (/opt, confined)": {
|
|
||||||
"fr": "production (/opt, confiné)",
|
|
||||||
"en": "production (/opt, confined)",
|
|
||||||
},
|
|
||||||
"development (~/git)": {
|
|
||||||
"fr": "développement (~/git)",
|
|
||||||
"en": "development (~/git)",
|
|
||||||
},
|
|
||||||
"no": {
|
|
||||||
"fr": "non",
|
|
||||||
"en": "no",
|
|
||||||
},
|
|
||||||
"SSH key:": {
|
|
||||||
"fr": "Clé SSH :",
|
|
||||||
"en": "SSH key:",
|
|
||||||
},
|
|
||||||
"~/.ssh/config:": {
|
|
||||||
"fr": "~/.ssh/config :",
|
|
||||||
"en": "~/.ssh/config:",
|
|
||||||
},
|
|
||||||
"one entry per VM": {
|
|
||||||
"fr": "une entrée par VM",
|
|
||||||
"en": "one entry per VM",
|
|
||||||
},
|
|
||||||
"untouched": {
|
|
||||||
"fr": "inchangé",
|
|
||||||
"en": "untouched",
|
|
||||||
},
|
|
||||||
"Parallelism:": {
|
|
||||||
"fr": "Parallélisme :",
|
|
||||||
"en": "Parallelism:",
|
|
||||||
},
|
|
||||||
"at a time": {
|
|
||||||
"fr": "à la fois",
|
|
||||||
"en": "at a time",
|
|
||||||
},
|
|
||||||
"Nothing to create - every VM already exists.": {
|
|
||||||
"fr": "Rien à créer — toutes les VM existent déjà.",
|
|
||||||
"en": "Nothing to create — every VM already exists.",
|
|
||||||
},
|
|
||||||
"Name collisions detected": {
|
|
||||||
"fr": "Collisions de noms détectées",
|
|
||||||
"en": "Name collisions detected",
|
|
||||||
},
|
|
||||||
"VM already defined - SKIPPED, nothing overwritten": {
|
|
||||||
"fr": "VM déjà définie — IGNORÉE, rien ne sera écrasé",
|
|
||||||
"en": "VM already defined — SKIPPED, nothing overwritten",
|
|
||||||
},
|
|
||||||
"disk present without VM - deployment will FAIL": {
|
|
||||||
"fr": "disque présent sans VM — le déploiement ÉCHOUERA",
|
|
||||||
"en": "disk present without VM - deployment will FAIL",
|
|
||||||
},
|
|
||||||
"Remove it by hand, or rename the VM.": {
|
|
||||||
"fr": "L'effacer à la main, ou renommer la VM.",
|
|
||||||
"en": "Remove it by hand, or rename the VM.",
|
|
||||||
},
|
|
||||||
"Continue despite these collisions? (y/N): ": {
|
|
||||||
"fr": "Continuer malgré ces collisions ? (o/N, défaut : non) : ",
|
|
||||||
"en": "Continue despite these collisions? (y/N, default: no): ",
|
|
||||||
},
|
},
|
||||||
"Cancelled.": {
|
"Cancelled.": {
|
||||||
"fr": "Annulé.",
|
"fr": "Annulé.",
|
||||||
"en": "Cancelled.",
|
"en": "Cancelled.",
|
||||||
},
|
},
|
||||||
"Resolving VM IPs (parallel, emulated boot is slow)...": {
|
"already exists, skipped.": {
|
||||||
"fr": "Résolution des IP des VM (en parallèle, le démarrage émulé "
|
"fr": "existe déjà, ignorée.",
|
||||||
"est lent)...",
|
"en": "already exists, skipped.",
|
||||||
"en": "Resolving VM IPs (parallel, emulated boot is slow)...",
|
|
||||||
},
|
|
||||||
"no IP": {
|
|
||||||
"fr": "pas d'IP",
|
|
||||||
"en": "no IP",
|
|
||||||
},
|
},
|
||||||
"Cloning ERPLibre on each VM": {
|
"Cloning ERPLibre on each VM": {
|
||||||
"fr": "Clonage d'ERPLibre sur chaque VM",
|
"fr": "Clonage d'ERPLibre sur chaque VM",
|
||||||
|
|
@ -2800,48 +2457,6 @@ TRANSLATIONS = {
|
||||||
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
||||||
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
|
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
|
||||||
},
|
},
|
||||||
# QEMU - statistics screen
|
|
||||||
"Statistics (installs, durations, VMs)": {
|
|
||||||
"fr": "📊 Statistiques (installations, durées, VM)",
|
|
||||||
"en": "📊 Statistics (installs, durations, VMs)",
|
|
||||||
},
|
|
||||||
"QEMU statistics": {"fr": "Statistiques QEMU", "en": "QEMU statistics"},
|
|
||||||
"No installation recorded yet.": {
|
|
||||||
"fr": "Aucune installation enregistrée pour l'instant.",
|
|
||||||
"en": "No installation recorded yet.",
|
|
||||||
},
|
|
||||||
"Installations": {"fr": "Installations", "en": "Installations"},
|
|
||||||
"Total": {"fr": "Total", "en": "Total"},
|
|
||||||
"succeeded": {"fr": "réussies", "en": "succeeded"},
|
|
||||||
"failed": {"fr": "échouées", "en": "failed"},
|
|
||||||
"Period": {"fr": "Période", "en": "Period"},
|
|
||||||
"days": {"fr": "jours", "en": "days"},
|
|
||||||
"Median duration": {"fr": "Durée médiane", "en": "Median duration"},
|
|
||||||
"min": {"fr": "min", "en": "min"},
|
|
||||||
"max": {"fr": "max", "en": "max"},
|
|
||||||
"Cumulated time": {"fr": "Temps cumulé", "en": "Cumulated time"},
|
|
||||||
"By distribution": {"fr": "Par distribution", "en": "By distribution"},
|
|
||||||
"By version": {"fr": "Par version", "en": "By version"},
|
|
||||||
"By architecture": {"fr": "Par architecture", "en": "By architecture"},
|
|
||||||
"Virtual machines": {
|
|
||||||
"fr": "Machines virtuelles",
|
|
||||||
"en": "Virtual machines",
|
|
||||||
},
|
|
||||||
"Defined": {"fr": "Définies", "en": "Defined"},
|
|
||||||
"running": {"fr": "en cours", "en": "running"},
|
|
||||||
"stopped": {"fr": "arrêtées", "en": "stopped"},
|
|
||||||
"Disk used": {"fr": "Disque utilisé", "en": "Disk used"},
|
|
||||||
"image": {"fr": "image", "en": "image"},
|
|
||||||
"failure": {"fr": "échec", "en": "failure"},
|
|
||||||
"Reset the statistics": {
|
|
||||||
"fr": "Réinitialiser les statistiques",
|
|
||||||
"en": "Reset the statistics",
|
|
||||||
},
|
|
||||||
"Nothing to reset.": {"fr": "Rien à effacer.", "en": "Nothing to reset."},
|
|
||||||
"Erase": {"fr": "Effacer", "en": "Erase"},
|
|
||||||
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
|
|
||||||
"runs erased": {"fr": "runs effacés", "en": "runs erased"},
|
|
||||||
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
|
|
||||||
# Database migration - resume menu
|
# Database migration - resume menu
|
||||||
"Migration in progress": {
|
"Migration in progress": {
|
||||||
"fr": "Migration en cours",
|
"fr": "Migration en cours",
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
||||||
"""Préférences persistantes du CLI TODO.
|
|
||||||
|
|
||||||
Réglages qui survivent d'une session à l'autre et qui appartiennent à
|
|
||||||
l'UTILISATEUR, pas au dépôt : ils vivent donc dans ~/.erplibre (comme la
|
|
||||||
télémétrie de navigation) et non dans un fichier versionné.
|
|
||||||
|
|
||||||
- get(key, default) / set(key, value) : accès unitaire.
|
|
||||||
- reset() : efface tout et revient aux défauts.
|
|
||||||
|
|
||||||
Tout est best-effort : une préférence illisible ou un disque plein ne doivent
|
|
||||||
JAMAIS empêcher le CLI de démarrer.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Clés connues et leur valeur par défaut. Une clé absente de ce dictionnaire
|
|
||||||
# reste lisible/écrivable, mais n'apparaît pas dans l'écran de configuration.
|
|
||||||
DEFAULTS = {
|
|
||||||
# Interface du déploiement QEMU : "ask" pose la question à chaque fois,
|
|
||||||
# "tui" ouvre le formulaire directement, "cli" garde les invites en ligne.
|
|
||||||
"qemu_deploy_ui": "ask",
|
|
||||||
# Affichage pendant le déploiement : "cli" (sortie texte, facile à copier
|
|
||||||
# depuis le terminal) ou "tui" (blocs repliables + copie OSC 52).
|
|
||||||
"qemu_deploy_progress": "cli",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _path() -> Path:
|
|
||||||
base = Path(os.path.expanduser("~/.erplibre"))
|
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
|
||||||
return base / "todo_prefs.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load() -> dict:
|
|
||||||
try:
|
|
||||||
data = json.loads(_path().read_text())
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return {}
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save(data: dict) -> None:
|
|
||||||
try:
|
|
||||||
_path().write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def get(key: str, default=None):
|
|
||||||
"""Valeur d'une préférence : fichier, puis DEFAULTS, puis `default`."""
|
|
||||||
if default is None:
|
|
||||||
default = DEFAULTS.get(key)
|
|
||||||
return load().get(key, default)
|
|
||||||
|
|
||||||
|
|
||||||
def set(key: str, value) -> None: # noqa: A001 - API voulue : prefs.set(...)
|
|
||||||
data = load()
|
|
||||||
data[key] = value
|
|
||||||
_save(data)
|
|
||||||
|
|
||||||
|
|
||||||
def reset() -> int:
|
|
||||||
"""Efface toutes les préférences. Renvoie le nombre de clés effacées."""
|
|
||||||
count = len(load())
|
|
||||||
_save({})
|
|
||||||
return count
|
|
||||||
Loading…
Reference in a new issue