Compare commits

...

7 commits

Author SHA1 Message Date
7c1016552b [ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.

The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.

Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.

Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.

Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.

The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.

On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.

Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.

Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
ce5fb38634 [REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.

The function now has three parts around a plain dict, the SPEC:

  _qemu_collect_vms_cli   arch, catalog, resources, names -> the VM list
  _qemu_collect_options_cli   SSH key, install, parallelism -> the spec
  _qemu_run_spec              consumes a spec, asks nothing

_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.

Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.

VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.

No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
b8582f5bc6 [ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.

todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.

The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.

Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.

Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
b664128a05 [IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.

The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.

Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.

Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.

Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.

Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.

Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).

Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
c1afa2f4d2 [ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.

record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.

Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.

Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.

Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
6d3a4c38cb [IMP] qemu: RAM presets up to 256G, shown with their GB equivalent
The presets stopped at 32768 MB; current virtualization hosts go well beyond
that. The series now doubles up to 262144 MB (256G).

The prompt is in MB while people think in GB, so each preset carries its
equivalent: « [g] 65536 (64G) ». With nine of them the line no longer fits, so
suggestions are laid out five per row. Disk presets keep the plain format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:08:01 -04:00
2716127bff [IMP] qemu: offer lettered presets on the disk size and RAM prompts
Both prompts expected a raw number, so common values had to be retyped every
time. They now list suggestions:

    [a] 20G  [b] 40G  [c] 60G  [d] 80G  [e] 120G  [f] 200G
  New disk size in G, blank = keep (20G):

Letters start at « a » so they can never collide with a value typed directly:
anything starting with a digit is read as the value itself, and blank still
keeps the current one. A letter outside the range is rejected instead of being
silently taken for a size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 04:59:02 -04:00
5 changed files with 2388 additions and 241 deletions

View file

@ -0,0 +1,843 @@
#!/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

View file

@ -260,7 +260,8 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM)."""
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM).
"""
try:
text = Path(log_path).read_text(errors="replace")
except OSError:
@ -270,9 +271,7 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
low = line.lower()
if EXIT_MARKER in line:
continue
if "error" in low and not any(
ig in line for ig in _LST_IGNORE_ERROR
):
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
nerr += 1
if "warning" in low and not any(
ig in line for ig in _LST_IGNORE_WARNING
@ -311,7 +310,8 @@ def _read_tail(
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
l'UI sur les gros logs (250 Ko / milliers de lignes). L'offset renvoyé =
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin."""
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin.
"""
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
@ -351,10 +351,14 @@ def load_stats() -> dict:
return {}
def record_duration(distro, version, arch, secs) -> None:
def record_duration(distro, version, arch, secs, ok=True) -> None:
"""Enregistre une install (distro + version + archi + durée + horodatage)
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
500 derniers runs."""
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()
runs = data.setdefault("runs", [])
runs.append(
@ -364,6 +368,7 @@ def record_duration(distro, version, arch, secs) -> None:
"arch": arch or "?",
"seconds": int(secs),
"ts": int(time.time()),
"ok": bool(ok),
}
)
data["runs"] = runs[-500:]
@ -373,10 +378,82 @@ def record_duration(distro, version, arch, secs) -> None:
pass
def _runs(stats=None):
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):
"""Runs RÉUSSIS seulement : base des moyennes et de l'ETA.
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):
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
archis confondues. None si aucun historique."""
@ -650,7 +727,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._cells = {}
# Historique de durées (ETA) + dossier disque à surveiller.
self._stats = load_stats()
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
self._disk_dir = (
os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
)
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
self._domstate = {}
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
@ -698,7 +777,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table.add_column("Disque", key="disk", width=8)
for i, vm in enumerate(vms, 1):
table.add_row(
str(i), vm["name"], "", "⏳", "—", "--:--", "-",
str(i),
vm["name"],
"",
"⏳",
"—",
"--:--",
"-",
key=vm["name"],
)
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
@ -719,7 +804,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def on_mount(self) -> None:
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
# progression (terminées / total + durée globale).
self.title = f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
self.title = (
f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
)
self.sub_title = f"0/{len(vms)} {t('completed')}"
self._refresh_ssh()
self._load_selected_log(reset=True)
@ -808,9 +895,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
errors[name] = scan_log_errors(vm["log"])
# Odoo up ? On ne teste que celles pas encore confirmées up
# et non effacées (test TCP court sur :8069).
if name not in self._odoo_up and self._domstate.get(
name
) != "gone":
if (
name not in self._odoo_up
and self._domstate.get(name) != "gone"
):
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo
@ -836,7 +924,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._set_cell(table, name, "disk", disks.get(name, "-"))
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
self._set_cell(
table, name, "odoo",
table,
name,
"odoo",
"🟢" if name in self._odoo_up else "—",
)
if name in self._final:
@ -855,14 +945,16 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if state in ("done", "failed"):
elapsed = now - started
self._final[name] = (state, code, elapsed)
if state == "done":
record_duration(
vm.get("distro"),
vm.get("version"),
vm.get("arch"),
elapsed,
)
self._stats = load_stats()
# Les échecs sont enregistrés eux aussi (ok=False) :
# sans eux, aucun taux de réussite n'est calculable.
record_duration(
vm.get("distro"),
vm.get("version"),
vm.get("arch"),
elapsed,
ok=state == "done",
)
self._stats = load_stats()
lbl = "✅" if state == "done" else f"❌ ({code})"
self._set_cell(table, name, "state", lbl)
self._set_cell(
@ -871,7 +963,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
# le log, y compris pour un « succès ».
self._set_cell(
table, name, "err",
table,
name,
"err",
self._err_label(self._errcount.get(name)),
)
elif ds == "paused":
@ -940,9 +1034,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
else:
running += 1
return {
"total": len(vms), "done": done, "fail": fail,
"deleted": deleted, "running": running, "paused": paused,
"err_vms": err_vms, "warn_vms": warn_vms,
"total": len(vms),
"done": done,
"fail": fail,
"deleted": deleted,
"running": running,
"paused": paused,
"err_vms": err_vms,
"warn_vms": warn_vms,
}
def _update_stats(self):
@ -1082,9 +1181,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for i, b in enumerate(available, 1):
print(f" [{i}] {b}{' *' if i == 1 else ''}")
print(" [i] Installer un autre navigateur")
sel = input(
f"Choix (numéro, vide = {available[0]}) : "
).strip().lower()
sel = (
input(f"Choix (numéro, vide = {available[0]}) : ")
.strip()
.lower()
)
if sel == "i":
return self._install_cli_browser()
if not sel:
@ -1123,9 +1224,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return None
printable = " ".join(cmd)
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"):
return None
rc = os.system(printable)

File diff suppressed because it is too large Load diff

View file

@ -262,9 +262,9 @@ TRANSLATIONS = {
"fr": "Ajouter cette VM à ~/.ssh/config ? (o/N, défaut : non) : ",
"en": "Add this VM to ~/.ssh/config? (y/N, default: no): ",
},
"Add each VM to ~/.ssh/config? (y/N): ": {
"fr": "Ajouter chaque VM à ~/.ssh/config ? (o/N, défaut : non) : ",
"en": "Add each VM to ~/.ssh/config? (y/N, default: no): ",
"Add each VM to ~/.ssh/config? (Y/n): ": {
"fr": "Ajouter chaque VM à ~/.ssh/config ? (O/n, défaut : oui) : ",
"en": "Add each VM to ~/.ssh/config? (Y/n, default: yes): ",
},
"Waiting for the VM IP (DHCP lease)...": {
"fr": "Attente de l'IP de la VM (bail DHCP)...",
@ -1249,6 +1249,46 @@ TRANSLATIONS = {
"fr": "Choix (1-4, défaut 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)": {
"fr": "🚀 Déployer une ou plusieurs VM",
"en": "🚀 Deploy VM(s) (one or many)",
@ -1311,6 +1351,214 @@ TRANSLATIONS = {
"fr": "📊 Télémétrie de navigation (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": {
"fr": "TODO — télémétrie de navigation",
"en": "TODO navigation telemetry",
@ -2205,21 +2453,116 @@ TRANSLATIONS = {
"tourneront pas toutes en même temps.",
"en": "Total RAM exceeds host free RAM: not all VMs will run at once.",
},
"Install ERPLibre into ~/git/erplibre on each VM? (y/N): ": {
"fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? (o/N, défaut : non) : ",
"en": "Install ERPLibre into ~/git/erplibre on each VM? (y/N, default: no): ",
"Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ": {
"fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? "
"(O/n, défaut : oui) : ",
"en": "Install ERPLibre into ~/git/erplibre on each VM? "
"(Y/n, default: yes): ",
},
"Deploy these VMs now? (y/N): ": {
"fr": "Déployer ces VM maintenant ? (o/N, défaut : non) : ",
"en": "Deploy these VMs now? (y/N, default: no): ",
"Deploy these VMs now? (Y/n): ": {
"fr": "Déployer ces VM maintenant ? (O/n, défaut : oui) : ",
"en": "Deploy these VMs now? (Y/n, default: yes): ",
},
"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.": {
"fr": "Annulé.",
"en": "Cancelled.",
},
"already exists, skipped.": {
"fr": "existe déjà, ignorée.",
"en": "already exists, skipped.",
"Resolving VM IPs (parallel, emulated boot is slow)...": {
"fr": "Résolution des IP des VM (en parallèle, le démarrage émulé "
"est lent)...",
"en": "Resolving VM IPs (parallel, emulated boot is slow)...",
},
"no IP": {
"fr": "pas d'IP",
"en": "no IP",
},
"Cloning ERPLibre on each VM": {
"fr": "Clonage d'ERPLibre sur chaque VM",
@ -2457,6 +2800,48 @@ TRANSLATIONS = {
"fr": "QEMU - Exemple 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
"Migration in progress": {
"fr": "Migration en cours",

72
script/todo/todo_prefs.py Normal file
View file

@ -0,0 +1,72 @@
#!/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