[ADD] tui qemu: set every VM in place, type included
VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
This commit is contained in:
parent
834b1061ac
commit
d6221ee48d
4 changed files with 604 additions and 281 deletions
|
|
@ -21,6 +21,7 @@ préchargées par l'appelant et arrivent dans `ctx`.
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
try:
|
||||
|
|
@ -76,7 +77,33 @@ def positive_int(value, fallback):
|
|||
return number if number > 0 else fallback
|
||||
|
||||
|
||||
def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
|
||||
def parse_ram(value):
|
||||
"""« 2048 », « 128G », « 1,5G » -> mébioctets, ou 0 si invalide.
|
||||
|
||||
Les valeurs proposées s'affichent en G — « 2G », « 16G » — alors que la
|
||||
saisie libre comptait en Mo. Taper « 128G », ce que l'affichage invite à
|
||||
faire, rendait 0 : la surcharge était alors RETIRÉE et la VM revenait à la
|
||||
valeur du catalogue, sans un mot. On accepte donc les deux écritures, un
|
||||
nombre nu restant des mébioctets."""
|
||||
txt = str(value or "").strip().upper().replace(",", ".")
|
||||
factor = 1
|
||||
if txt.endswith("GI"):
|
||||
factor, txt = 1024, txt[:-2]
|
||||
elif txt.endswith(("G", "T")):
|
||||
factor = 1024 * (1024 if txt.endswith("T") else 1)
|
||||
txt = txt[:-1]
|
||||
elif txt.endswith(("M", "MI")):
|
||||
txt = txt.rstrip("IM")
|
||||
try:
|
||||
mib = int(float(txt) * factor)
|
||||
except ValueError:
|
||||
return 0
|
||||
return mib if mib > 0 else 0
|
||||
|
||||
|
||||
def apply_profile(
|
||||
entries, profile, base_vcpus, host_cpu, custom=None, desktop=""
|
||||
):
|
||||
"""Applique le profil de ressources aux entrées choisies.
|
||||
|
||||
Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur
|
||||
|
|
@ -104,6 +131,10 @@ def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
|
|||
"ram": ram,
|
||||
"disk": disk,
|
||||
"vcpus": vcpus,
|
||||
# Type de VM (« » = serveur). Il vit sur la VM et non sur la
|
||||
# spec entière depuis qu'il se choisit machine par machine ;
|
||||
# `desktop` n'est plus que le défaut commun.
|
||||
"desktop": desktop,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
|
@ -118,10 +149,12 @@ def apply_overrides(vms, entries, overrides):
|
|||
return vms
|
||||
|
||||
|
||||
def build_vms(entries, profile, base_vcpus, host_cpu, custom, overrides):
|
||||
def build_vms(
|
||||
entries, profile, base_vcpus, host_cpu, custom, overrides, desktop=""
|
||||
):
|
||||
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
|
||||
return apply_overrides(
|
||||
apply_profile(entries, profile, base_vcpus, host_cpu, custom),
|
||||
apply_profile(entries, profile, base_vcpus, host_cpu, custom, desktop),
|
||||
entries,
|
||||
overrides,
|
||||
)
|
||||
|
|
@ -214,13 +247,10 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
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,
|
||||
|
|
@ -255,6 +285,9 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# Dernier choix de chaque liste de ressources : il ne porte pas de valeur,
|
||||
# il révèle la saisie libre placée juste dessous.
|
||||
FREE = "__free__"
|
||||
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
|
||||
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
|
||||
SERVER = "__server__"
|
||||
RES_FIELDS = {
|
||||
"vcpus": ("#f_vcpus", "#c_vcpus"),
|
||||
"ram": ("#f_ram", "#c_ram"),
|
||||
|
|
@ -270,50 +303,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
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)."""
|
||||
|
||||
|
|
@ -347,15 +336,33 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
|
||||
SelectionList { height: 10; border: solid $panel; }
|
||||
RadioSet { height: auto; layout: horizontal; }
|
||||
.freeval { display: none; }
|
||||
#reslabel { color: $text-muted; }
|
||||
EditVMScreen { align: center middle; }
|
||||
#editbox {
|
||||
width: 56; height: auto; padding: 1 2;
|
||||
border: thick $accent; background: $surface;
|
||||
.freeval { display: none; width: 9; }
|
||||
/* « width: auto » sur la CARTE, pas seulement sur la rangée. Un
|
||||
conteneur Textual vaut « width: 1fr » par défaut : la carte se bornait
|
||||
donc au panneau, et c'est ELLE que « #plan » mesure pour sa largeur
|
||||
virtuelle. La rangée avait beau être en « auto », son débordement était
|
||||
coupé dans une carte qui ne grandissait pas, et la barre horizontale
|
||||
n'apparaissait jamais. */
|
||||
.vmcard {
|
||||
height: auto; width: auto;
|
||||
border-bottom: solid $panel; padding: 0 1;
|
||||
}
|
||||
#edittitle { color: $accent; text-style: bold; }
|
||||
#editbtns { height: auto; padding-top: 1; }
|
||||
/* Une VM figée se voit à la LIGNE, pas à une case perdue au bout :
|
||||
c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui
|
||||
échappe au profil. */
|
||||
.vmcard.locked { background: $success 20%; }
|
||||
.vmlock { width: 5; min-width: 5; }
|
||||
/* La branche porte des noms longs (« 1.6.0 », « develop »,
|
||||
« feature/xyz ») : trop étroite, la liste les tronque et on ne sait
|
||||
plus ce qu'on a choisi. */
|
||||
|
||||
|
||||
.vmcopy { width: 5; min-width: 5; }
|
||||
.vmhead { height: 1; }
|
||||
.vmrow { height: 3; align-vertical: middle; }
|
||||
.vmrow Select { width: 15; }
|
||||
.vmrow Input { width: 11; }
|
||||
#reslabel { color: $text-muted; }
|
||||
PreviewScreen { align: center middle; }
|
||||
#prevbox {
|
||||
width: 90%; height: 70%; padding: 1 2;
|
||||
|
|
@ -369,7 +376,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# par le champ de saisie qui a le focus.
|
||||
BINDINGS = [
|
||||
("f5", "deploy", t("Deploy")),
|
||||
("f2", "edit_vm", t("Edit VM")),
|
||||
("f4", "clear_vm", t("Reset VM")),
|
||||
("f3", "preview", t("Preview")),
|
||||
("f6", "select_all", t("All")),
|
||||
|
|
@ -385,15 +391,27 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self.custom = {}
|
||||
self._free = {}
|
||||
self.overrides = {}
|
||||
# « all » : les champs de ressources nourrissent le profil.
|
||||
# « one » : ils écrivent une surcharge pour la ligne du plan.
|
||||
self.scope = "all"
|
||||
# Vrai pendant qu'on repositionne les widgets nous-mêmes : sans ce
|
||||
# verrou, remettre une liste à vide déclencherait on_select_changed,
|
||||
# qui réécrirait une surcharge — une boucle qui se nourrit seule.
|
||||
# Vrai pendant qu'on repositionne les widgets nous-mêmes : sans
|
||||
# ce verrou, poser une valeur déclencherait on_select_changed, qui
|
||||
# réécrirait une surcharge — une boucle qui se nourrit seule.
|
||||
self._syncing = False
|
||||
# Dernier rang connu du curseur du plan.
|
||||
self._last_row = 0
|
||||
# Jeu de VM actuellement monté dans le panneau droit.
|
||||
self._shown_ids = ()
|
||||
# Génération du jeu de rangées monté. Les identifiants de widgets
|
||||
# portent un RANG, et le rang se décale quand on coche ou décoche
|
||||
# une entrée : un événement émis par un widget déjà détruit
|
||||
# s'appliquerait alors à la VM qui a pris sa place. Chaque widget
|
||||
# de rangée retient sa génération ; ceux d'une génération périmée
|
||||
# sont ignorés.
|
||||
self._gen = 0
|
||||
# VM dont les ressources sont FIGÉES, par identité de catalogue.
|
||||
# Distinct des surcharges : une VM peut être modifiée sans être
|
||||
# verrouillée, et le verrou fige TOUT, pas seulement ce qu'on a
|
||||
# touché.
|
||||
self.locked = set()
|
||||
# {clé de base: nombre d'exemplaires EN PLUS du premier}.
|
||||
self.copies = {}
|
||||
self.rows = []
|
||||
self.vms = []
|
||||
self.rows = []
|
||||
|
||||
|
|
@ -409,7 +427,10 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
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")
|
||||
yield Static(
|
||||
t("Resources — applied to ALL VMs"),
|
||||
classes="grouptitle",
|
||||
)
|
||||
with RadioSet(id="f_profile"):
|
||||
for label in ("x1", "x2", "x3", "x4"):
|
||||
yield RadioButton(label, value=label == "x1")
|
||||
|
|
@ -437,12 +458,12 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
for m in ctx["ram_presets"]
|
||||
]
|
||||
+ [(t("free value…"), FREE)],
|
||||
prompt=t("RAM (MB)"),
|
||||
prompt=t("RAM: 2048 or 8G"),
|
||||
id="f_ram",
|
||||
disabled=True,
|
||||
)
|
||||
yield Input(
|
||||
placeholder=t("RAM (MB)"),
|
||||
placeholder=t("RAM: 2048 or 8G"),
|
||||
id="c_ram",
|
||||
classes="freeval",
|
||||
disabled=True,
|
||||
|
|
@ -460,22 +481,15 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
classes="freeval",
|
||||
disabled=True,
|
||||
)
|
||||
# Portée des TROIS champs ci-dessus. Le profil x1..x4 reste
|
||||
# global par nature — il multiplie ce que demande chaque
|
||||
# image. Ces valeurs-ci, elles, peuvent ne viser qu'une VM :
|
||||
# une seule machine a besoin de 16 G, pas les huit autres.
|
||||
#
|
||||
# Le mode « une seule » rend les champs actifs même hors
|
||||
# profil personnalisé : on y saisit une valeur absolue, pas
|
||||
# un multiplicateur.
|
||||
with RadioSet(id="f_scope"):
|
||||
yield RadioButton(t("Apply to all VMs"), value=True)
|
||||
yield RadioButton(t("Apply to the selected VM only"))
|
||||
yield Static("", id="scopetarget")
|
||||
yield Static(
|
||||
f" {t('The profile and these fields change EVERY VM.')}"
|
||||
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
|
||||
id="scopetarget",
|
||||
)
|
||||
# Serveur par défaut : c'est ce que sert une image cloud,
|
||||
# et GNOME ajoute une à deux heures sur une architecture
|
||||
# émulée. Le plan annonce le surcoût disque.
|
||||
yield Static(t("VM type:"), classes="grouptitle")
|
||||
yield Static(t("VM type (default):"), classes="grouptitle")
|
||||
with RadioSet(id="f_type"):
|
||||
yield RadioButton(
|
||||
t("Server (no graphical interface)"),
|
||||
|
|
@ -563,26 +577,16 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
id="f_par",
|
||||
)
|
||||
with Vertical(id="right"):
|
||||
yield DataTable(id="plan")
|
||||
# Une liste de widgets, pas un tableau : chaque VM porte
|
||||
# SES listes déroulantes, modifiables sur place. Un
|
||||
# DataTable ne sait pas héberger de widget.
|
||||
yield VerticalScroll(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(first_load=True)
|
||||
self._sync_res_fields()
|
||||
|
||||
# -- catalogue et recalcul ------------------------------------- #
|
||||
def _entries(self):
|
||||
|
|
@ -629,14 +633,19 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
host_cpu,
|
||||
self.custom,
|
||||
self.overrides,
|
||||
self._default_desktop(),
|
||||
)
|
||||
# ERPLibre et GNOME pèsent chacun sur le disque, et se cumulent.
|
||||
grow = 0
|
||||
if self.query_one("#f_install", Checkbox).value:
|
||||
grow += extra_disk
|
||||
if self._desktop():
|
||||
grow += desktop_disk
|
||||
self.rows = plan_rows(self.vms, domains, grow)
|
||||
# Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle
|
||||
# seule : un supplément commun mentait dès que les types
|
||||
# différaient d'une machine à l'autre.
|
||||
for row in self.rows:
|
||||
if row["vm"].get("desktop"):
|
||||
row["disk_gb"] += desktop_disk
|
||||
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
|
||||
# deux lignes aux ressources différentes n'ont aucune explication à
|
||||
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
|
||||
|
|
@ -646,7 +655,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
row["custom"] = bool(self.overrides.get(entry_key(entry)))
|
||||
self._render_plan()
|
||||
self._render_mise()
|
||||
self.query_one("#scopetarget", Static).update(self._scope_label())
|
||||
|
||||
def _render_mise(self):
|
||||
"""Grise le choix quand aucune VM retenue n'est servie par mise,
|
||||
|
|
@ -685,51 +693,193 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
def _mise_usable(self):
|
||||
return any(vm["arch"] in mise_arches for vm in self.vms)
|
||||
|
||||
def _desktop(self):
|
||||
def _default_desktop(self):
|
||||
"""« » pour un serveur, sinon la clé de la saveur choisie."""
|
||||
"""Type de VM par défaut. Chaque rangée peut s'en écarter."""
|
||||
index = self.query_one("#f_type", RadioSet).pressed_index
|
||||
if index is None or index < 1 or index > len(desktops):
|
||||
return ""
|
||||
return desktops[index - 1][0]
|
||||
|
||||
def _desktop_label(self):
|
||||
"""Libellé court du type de VM, pour le tableau et les totaux."""
|
||||
key = self._desktop()
|
||||
if not key:
|
||||
return t("server")
|
||||
label = dict(desktops).get(key, key)
|
||||
return f"🖥 {label}"
|
||||
# -- panneau droit : une rangée de widgets par VM ---------------- #
|
||||
def _row_ids(self):
|
||||
"""Identité du JEU de VM affiché. Reconstruire les widgets à chaque
|
||||
frappe ferait perdre le focus en pleine saisie : on ne le fait que
|
||||
si la liste elle-même a changé."""
|
||||
return tuple(entry_key(e) for e in self._selected_entries())
|
||||
|
||||
def _type_options(self):
|
||||
return [(t("Server"), SERVER)] + [
|
||||
(label, key) for key, label in desktops
|
||||
]
|
||||
|
||||
def _mount_rows(self) -> None:
|
||||
"""(Re)construit le panneau droit.
|
||||
|
||||
Le verrou couvre TOUT le montage : poser « value= » sur un Select
|
||||
fait émettre un Changed à Textual, que on_select_changed prenait
|
||||
pour une saisie. Résultat mesuré — les trois champs de CHAQUE VM
|
||||
recevaient une surcharge dès l'affichage, le profil x1..x4 ne
|
||||
pouvait plus rien changer, et toutes les lignes portaient la
|
||||
marque ✎. Il est relâché après le rafraîchissement, une fois ces
|
||||
messages consommés."""
|
||||
self._syncing = True
|
||||
self._gen += 1
|
||||
plan = self.query_one("#plan", VerticalScroll)
|
||||
plan.remove_children()
|
||||
widgets = []
|
||||
for i, r in enumerate(self.rows):
|
||||
vm = r["vm"]
|
||||
row = Horizontal(
|
||||
Select(
|
||||
[(str(c), c) for c in ctx["cpu_presets"]]
|
||||
+ [(t("free value…"), FREE)],
|
||||
value=(
|
||||
vm["vcpus"]
|
||||
if vm["vcpus"] in ctx["cpu_presets"]
|
||||
else SELECT_NULL
|
||||
),
|
||||
prompt="vCPU",
|
||||
id=f"v{i}_vcpus",
|
||||
),
|
||||
Input(
|
||||
value=(
|
||||
""
|
||||
if vm["vcpus"] in ctx["cpu_presets"]
|
||||
else str(vm["vcpus"])
|
||||
),
|
||||
placeholder="vCPU",
|
||||
id=f"c{i}_vcpus",
|
||||
classes="freeval",
|
||||
),
|
||||
Select(
|
||||
[(f"{m // 1024}G", m) for m in ctx["ram_presets"]]
|
||||
+ [(t("free value…"), FREE)],
|
||||
value=(
|
||||
vm["ram"]
|
||||
if vm["ram"] in ctx["ram_presets"]
|
||||
else SELECT_NULL
|
||||
),
|
||||
prompt=t("RAM: 2048 or 8G"),
|
||||
id=f"v{i}_ram",
|
||||
),
|
||||
Input(
|
||||
value=(
|
||||
""
|
||||
if vm["ram"] in ctx["ram_presets"]
|
||||
else str(vm["ram"])
|
||||
),
|
||||
placeholder=t("RAM: 2048 or 8G"),
|
||||
id=f"c{i}_ram",
|
||||
classes="freeval",
|
||||
),
|
||||
Select(
|
||||
[(d, d) for d in ctx["disk_presets"]]
|
||||
+ [(t("free value…"), FREE)],
|
||||
value=(
|
||||
vm["disk"]
|
||||
if vm["disk"] in ctx["disk_presets"]
|
||||
else SELECT_NULL
|
||||
),
|
||||
prompt=t("Disk"),
|
||||
id=f"v{i}_disk",
|
||||
),
|
||||
Input(
|
||||
value=(
|
||||
""
|
||||
if vm["disk"] in ctx["disk_presets"]
|
||||
else str(vm["disk"])
|
||||
),
|
||||
placeholder=t("Disk"),
|
||||
id=f"c{i}_disk",
|
||||
classes="freeval",
|
||||
),
|
||||
Select(
|
||||
self._type_options(),
|
||||
value=vm.get("desktop") or SERVER,
|
||||
allow_blank=False,
|
||||
id=f"v{i}_type",
|
||||
),
|
||||
classes="vmrow",
|
||||
)
|
||||
widgets.append(
|
||||
Vertical(
|
||||
Static(self._row_head(i, r), id=f"h{i}"),
|
||||
row,
|
||||
classes="vmcard",
|
||||
)
|
||||
)
|
||||
|
||||
# Marque de génération, posée sur CHAQUE widget de rangée.
|
||||
# « walk_children() » ne voit RIEN avant le montage : les enfants
|
||||
# passés au constructeur attendent dans « _pending_children ».
|
||||
def mark(node):
|
||||
node._el_gen = self._gen
|
||||
for child in getattr(node, "_pending_children", None) or []:
|
||||
mark(child)
|
||||
|
||||
for card in widgets:
|
||||
mark(card)
|
||||
# Le montage vient APRÈS le marquage, jamais avant : « mount_all »
|
||||
# consomme « _pending_children » et le vide. Marquer ensuite était
|
||||
# une COURSE — gagnée sur une machine, perdue sur une autre. Perdue,
|
||||
# les listes des rangées n'avaient plus de génération, TOUS les
|
||||
# changements par VM étaient rejetés en silence, et seuls les
|
||||
# réglages globaux semblaient agir.
|
||||
if widgets:
|
||||
plan.mount_all(widgets)
|
||||
self._shown_ids = self._row_ids()
|
||||
# Les saisies libres ne se révèlent qu'après le montage : leur
|
||||
# style ne peut pas être touché avant qu'elles existent.
|
||||
self.call_after_refresh(self._after_mount_rows)
|
||||
|
||||
def _after_mount_rows(self) -> None:
|
||||
self._sync_free_inputs()
|
||||
self._syncing = False
|
||||
|
||||
def _sync_free_inputs(self) -> None:
|
||||
for i, r in enumerate(self.rows):
|
||||
vm = r["vm"]
|
||||
for field, presets in (
|
||||
("vcpus", ctx["cpu_presets"]),
|
||||
("ram", ctx["ram_presets"]),
|
||||
("disk", ctx["disk_presets"]),
|
||||
):
|
||||
try:
|
||||
widget = self.query_one(f"#c{i}_{field}", Input)
|
||||
except Exception:
|
||||
continue
|
||||
free = vm[field] not in presets
|
||||
widget.display = free
|
||||
widget.disabled = not free
|
||||
|
||||
def _row_head(self, index, row):
|
||||
"""Ligne de titre d'une VM : nom, origine, état, marque de
|
||||
personnalisation. Sans elle, deux rangées aux réglages différents
|
||||
n'ont aucune explication à l'écran."""
|
||||
vm = row["vm"]
|
||||
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[row["state"]]
|
||||
state = "" if row["state"] == "new" else f" {icon}{row['note']}"
|
||||
mark = " ✎" if row.get("custom") else ""
|
||||
return (
|
||||
f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} "
|
||||
f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}"
|
||||
)
|
||||
|
||||
def _render_plan(self):
|
||||
table = self.query_one("#plan", DataTable)
|
||||
# « clear() » ramène le curseur en tête. Le plan étant recalculé à
|
||||
# CHAQUE frappe, la ligne visée serait perdue entre deux saisies :
|
||||
# on choisit debian, on tape 16384, le tableau se redessine, et la
|
||||
# valeur suivante partirait sur ubuntu sans que rien ne le montre.
|
||||
keep = table.cursor_row
|
||||
table.clear()
|
||||
# Le type ne concerne QUE les VM réellement créées : une VM déjà
|
||||
# définie n'est pas retouchée, lui afficher « GNOME » laisserait
|
||||
# croire qu'on va lui poser un bureau.
|
||||
kind = self._desktop_label()
|
||||
for r in self.rows:
|
||||
vm = r["vm"]
|
||||
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]]
|
||||
status = kind if r["state"] == "new" else f"{icon}{r['note']}"
|
||||
table.add_row(
|
||||
f"{vm['name']} ✎" if r.get("custom") else vm["name"],
|
||||
vm["distro"],
|
||||
vm["version"],
|
||||
vm["arch"],
|
||||
str(vm["vcpus"]),
|
||||
f"{vm['ram']}Mo",
|
||||
f"{r['disk_gb']}G",
|
||||
status,
|
||||
)
|
||||
if self.rows:
|
||||
# Borné : décocher une entrée raccourcit la liste, et un rang
|
||||
# devenu hors bornes ferait perdre la ligne visée pour de bon.
|
||||
table.move_cursor(row=min(max(keep, 0), len(self.rows) - 1))
|
||||
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
|
||||
# on se contente des titres : remonter à chaque frappe volerait le
|
||||
# focus au champ en cours de saisie.
|
||||
if self._row_ids() != self._shown_ids:
|
||||
self._mount_rows()
|
||||
else:
|
||||
for i, r in enumerate(self.rows):
|
||||
try:
|
||||
self.query_one(f"#h{i}", Static).update(
|
||||
self._row_head(i, r)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not self.rows:
|
||||
# Rien de coché : un total à zéro n'apprend rien, on dit
|
||||
# plutôt comment remplir la liste.
|
||||
|
|
@ -750,9 +900,20 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
if dupes
|
||||
else ""
|
||||
)
|
||||
# Une VM DÉJÀ DÉFINIE n'est pas recréée : elle ne consomme rien
|
||||
# de neuf, donc plan_totals l'écarte. Mais un total à zéro sans
|
||||
# explication se lit comme un bogue — on a cru que les réglages
|
||||
# par VM n'avaient aucun effet, alors qu'ils portaient sur une
|
||||
# machine qui ne sera pas créée.
|
||||
skipped = sum(1 for r in self.rows if r["state"] == "exists")
|
||||
skip_txt = (
|
||||
f" ({skipped} {t('already defined, not counted')})"
|
||||
if skipped
|
||||
else ""
|
||||
)
|
||||
self.query_one("#totals", Static).update(
|
||||
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
|
||||
f" · {self._desktop_label()}{warn}{dup_txt}"
|
||||
f"{skip_txt}{warn}{dup_txt}"
|
||||
)
|
||||
|
||||
# -- réactions aux champs -------------------------------------- #
|
||||
|
|
@ -767,87 +928,38 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
elif event.radio_set.id == "f_profile":
|
||||
index = event.radio_set.pressed_index
|
||||
self.profile = "custom" if index == 4 else str(index + 1)
|
||||
self._sync_res_fields()
|
||||
self._recompute()
|
||||
elif event.radio_set.id == "f_scope":
|
||||
self.scope = (
|
||||
"one" if event.radio_set.pressed_index == 1 else "all"
|
||||
)
|
||||
# Les champs repartent à vide : ils décrivent désormais une
|
||||
# AUTRE cible, et y laisser la valeur précédente ferait croire
|
||||
# qu'elle s'y applique déjà.
|
||||
self._reset_res_widgets()
|
||||
self._sync_res_fields()
|
||||
if self.scope == "one":
|
||||
# Sans focus, le curseur du tableau reste invisible et on
|
||||
# modifierait une ligne qu'on ne voit pas désignée.
|
||||
self.query_one("#plan", DataTable).focus()
|
||||
custom = self.profile == "custom"
|
||||
for field, (sel, _inp) in RES_FIELDS.items():
|
||||
self.query_one(sel, Select).disabled = not custom
|
||||
self._show_free(field, custom and self._free.get(field))
|
||||
self._recompute()
|
||||
|
||||
# -- portée des ressources -------------------------------------- #
|
||||
def _cursor_index(self):
|
||||
"""Rang de la ligne visée dans le plan, ou None."""
|
||||
index = self.query_one("#plan", DataTable).cursor_row
|
||||
return index if 0 <= index < len(self.vms) else None
|
||||
# -- rangées du panneau droit ------------------------------- #
|
||||
def _is_current(self, widget):
|
||||
"""Le widget appartient-il au jeu de rangées ACTUEL ?"""
|
||||
return getattr(widget, "_el_gen", None) == self._gen
|
||||
|
||||
def _cursor_key(self):
|
||||
"""Identité de catalogue de la ligne visée, ou None. C'est elle qui
|
||||
indexe les surcharges, pas le rang : cocher une autre distro ne doit
|
||||
pas déplacer une personnalisation d'une VM à l'autre."""
|
||||
index = self._cursor_index()
|
||||
if index is None:
|
||||
def _focused_row(self):
|
||||
"""Rang de la VM dont un widget a le focus, ou None. C'est la seule
|
||||
désignation qui ait un sens ici : il n'y a plus de curseur unique,
|
||||
chaque rangée est éditable directement."""
|
||||
wid = getattr(self.focused, "id", "") or ""
|
||||
match = re.match(r"[vch](\d+)(?:_|$)", wid)
|
||||
if not match:
|
||||
return None
|
||||
return entry_key(self._selected_entries()[index])
|
||||
index = int(match.group(1))
|
||||
return index if 0 <= index < len(self.rows) else None
|
||||
|
||||
def _scope_label(self):
|
||||
"""Ce que la saisie va toucher, dit en toutes lettres."""
|
||||
if self.scope != "one":
|
||||
n = len(self.overrides)
|
||||
if not n:
|
||||
return ""
|
||||
return f" ✎ {n} {t('VM(s) customised (F4 resets one)')}"
|
||||
index = self._cursor_index()
|
||||
if index is None:
|
||||
return f" ⚠ {t('Pick a line in the plan (Tab, then arrows)')}"
|
||||
vm = self.vms[index]
|
||||
return (
|
||||
f" ✎ {vm['name']} — {vm['vcpus']} vCPU · "
|
||||
f"{vm['ram']} Mo · {vm['disk']}"
|
||||
)
|
||||
def _row_key(self, index):
|
||||
entries = self._selected_entries()
|
||||
return entry_key(entries[index]) if index < len(entries) else None
|
||||
|
||||
def _sync_res_fields(self) -> None:
|
||||
"""Active les trois champs quand ils ont un sens, et redit la cible.
|
||||
|
||||
Portée « toutes » : ils ne servent que le profil personnalisé, comme
|
||||
avant. Portée « une seule » : ils valent toujours, puisqu'on y saisit
|
||||
la valeur absolue d'une VM et non un multiplicateur."""
|
||||
live = self.scope == "one" or self.profile == "custom"
|
||||
for field, (sel, _inp) in RES_FIELDS.items():
|
||||
self.query_one(sel, Select).disabled = not live
|
||||
self._show_free(field, live and self._free.get(field))
|
||||
self.query_one("#scopetarget", Static).update(self._scope_label())
|
||||
|
||||
def _reset_res_widgets(self) -> None:
|
||||
"""Remet listes et saisies à vide SANS déclencher d'écriture."""
|
||||
self._syncing = True
|
||||
try:
|
||||
for field, (sel, inp) in RES_FIELDS.items():
|
||||
self.query_one(sel, Select).value = SELECT_NULL
|
||||
self.query_one(inp, Input).value = ""
|
||||
self._free[field] = False
|
||||
self._show_free(field, False)
|
||||
finally:
|
||||
self._syncing = False
|
||||
|
||||
def _set_resource(self, field, value) -> None:
|
||||
"""Écrit une ressource là où la portée le demande."""
|
||||
if self.scope != "one":
|
||||
self.custom[field] = value
|
||||
return
|
||||
key = self._cursor_key()
|
||||
def _set_override(self, index, field, value) -> None:
|
||||
"""Écrit — ou retire — la surcharge d'UNE VM."""
|
||||
key = self._row_key(index)
|
||||
if key is None:
|
||||
return
|
||||
if value in ("", 0):
|
||||
if value in ("", 0, None):
|
||||
# Saisie vidée ou invalide : on RETIRE la surcharge au lieu
|
||||
# d'écrire un zéro, qui donnerait une VM à 0 vCPU.
|
||||
self.overrides.get(key, {}).pop(field, None)
|
||||
|
|
@ -856,6 +968,21 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
else:
|
||||
self.overrides.setdefault(key, {})[field] = value
|
||||
|
||||
def _row_free(self, index, field, visible) -> None:
|
||||
widget = self.query_one(f"#c{index}_{field}", Input)
|
||||
widget.display = bool(visible)
|
||||
widget.disabled = not visible
|
||||
if visible:
|
||||
widget.focus()
|
||||
|
||||
def _read_row_free(self, index, field):
|
||||
raw = self.query_one(f"#c{index}_{field}", Input).value.strip()
|
||||
if field == "disk":
|
||||
return parse_disk(raw) or ""
|
||||
if field == "ram":
|
||||
return parse_ram(raw)
|
||||
return positive_int(raw, 0)
|
||||
|
||||
def _show_free(self, field, visible) -> None:
|
||||
"""Montre ou cache la saisie libre d'une ressource."""
|
||||
widget = self.query_one(RES_FIELDS[field][1], Input)
|
||||
|
|
@ -868,6 +995,59 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
def on_select_changed(self, event) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
wid = event.select.id or ""
|
||||
row = re.match(r"v(\d+)_(vcpus|ram|disk|type|branch|prof)$", wid)
|
||||
if row and not self._is_current(event.select):
|
||||
# Widget d'une génération périmée : son rang ne désigne plus
|
||||
# la même VM. L'appliquer écraserait le réglage d'une voisine.
|
||||
return
|
||||
if row:
|
||||
index, field = int(row.group(1)), row.group(2)
|
||||
if index >= len(self.rows):
|
||||
return
|
||||
# Poser « value= » au montage fait émettre un Changed que
|
||||
# Textual délivre APRÈS coup : un verrou temporel ne l'attrape
|
||||
# pas — mesuré, les trois champs de chaque VM se retrouvaient
|
||||
# surchargés dès l'affichage et le profil x1..x4 devenait
|
||||
# inopérant. On compare donc à ce que le modèle dit déjà : une
|
||||
# valeur identique n'est pas une saisie, c'est l'écho.
|
||||
#
|
||||
# Cas limite assumé : choisir explicitement la valeur que le
|
||||
# profil donne déjà n'enregistre pas de surcharge. La VM
|
||||
# suivra donc le profil s'il change — ce qui est aussi le plus
|
||||
# attendu quand on n'a rien changé de visible.
|
||||
vm_now = self.rows[index]["vm"]
|
||||
if field == "type":
|
||||
new_desk = "" if event.value == SERVER else event.value
|
||||
if new_desk == (vm_now.get("desktop") or ""):
|
||||
return
|
||||
elif (
|
||||
event.value is not FREE and event.value is not SELECT_NULL
|
||||
):
|
||||
if event.value == vm_now.get(field):
|
||||
return
|
||||
if field == "type":
|
||||
self._set_override(
|
||||
index,
|
||||
"desktop",
|
||||
"" if event.value == SERVER else event.value,
|
||||
)
|
||||
# « Serveur » est un choix légitime, pas un retrait : on le
|
||||
# note explicitement pour qu'il tienne face au défaut.
|
||||
if event.value == SERVER:
|
||||
key = self._row_key(index)
|
||||
if key is not None:
|
||||
self.overrides.setdefault(key, {})["desktop"] = ""
|
||||
elif event.value is FREE:
|
||||
self._row_free(index, field, True)
|
||||
self._set_override(
|
||||
index, field, self._read_row_free(index, field)
|
||||
)
|
||||
elif event.value is not SELECT_NULL:
|
||||
self._row_free(index, field, False)
|
||||
self._set_override(index, field, event.value)
|
||||
self._recompute()
|
||||
return
|
||||
field = SELECT_TO_FIELD.get(event.select.id)
|
||||
if not field:
|
||||
return
|
||||
|
|
@ -880,7 +1060,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
elif event.value is not SELECT_NULL:
|
||||
self._free[field] = False
|
||||
self._show_free(field, False)
|
||||
self._set_resource(field, event.value)
|
||||
self.custom[field] = event.value
|
||||
self._recompute()
|
||||
|
||||
def _apply_free(self, field) -> None:
|
||||
|
|
@ -888,32 +1068,31 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
profil retombe alors sur celle du catalogue."""
|
||||
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
|
||||
if field == "disk":
|
||||
self._set_resource(field, parse_disk(raw) or "")
|
||||
self.custom[field] = parse_disk(raw) or ""
|
||||
elif field == "ram":
|
||||
self.custom[field] = parse_ram(raw)
|
||||
else:
|
||||
self._set_resource(field, positive_int(raw, 0))
|
||||
self.custom[field] = positive_int(raw, 0)
|
||||
|
||||
def on_input_changed(self, event) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
wid = event.input.id or ""
|
||||
row = re.match(r"c(\d+)_(vcpus|ram|disk)$", wid)
|
||||
if row and not self._is_current(event.input):
|
||||
return
|
||||
if row:
|
||||
index, field = int(row.group(1)), row.group(2)
|
||||
self._set_override(
|
||||
index, field, self._read_row_free(index, field)
|
||||
)
|
||||
self._recompute()
|
||||
return
|
||||
field = INPUT_TO_FIELD.get(event.input.id)
|
||||
if field:
|
||||
self._apply_free(field)
|
||||
self._recompute()
|
||||
|
||||
def on_data_table_row_highlighted(self, event) -> None:
|
||||
"""Changer de ligne change la cible : on repart de champs vides,
|
||||
pour ne pas reporter par mégarde le réglage de la VM précédente.
|
||||
|
||||
Le rang précédent est mémorisé plutôt que testé sur un verrou :
|
||||
replacer le curseur après un redessin poste le même message, et
|
||||
Textual le délivre APRÈS que le verrou soit retombé."""
|
||||
if event.cursor_row == self._last_row:
|
||||
return
|
||||
self._last_row = event.cursor_row
|
||||
if self.scope == "one":
|
||||
self._reset_res_widgets()
|
||||
self._sync_res_fields()
|
||||
|
||||
def on_checkbox_changed(self, event) -> None:
|
||||
if event.checkbox.id == "f_install":
|
||||
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
||||
|
|
@ -935,32 +1114,21 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
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 action_clear_vm(self) -> None:
|
||||
"""Rend la VM visée au profil commun. Sans cette sortie, une
|
||||
personnalisation posée par erreur ne se défaisait qu'en rouvrant le
|
||||
formulaire."""
|
||||
key = self._cursor_key()
|
||||
"""Rend au profil commun la VM dont un widget a le focus. Sans
|
||||
cette sortie, un réglage posé par erreur ne se défaisait qu'en
|
||||
rouvrant le formulaire."""
|
||||
index = self._focused_row()
|
||||
if index is None:
|
||||
return
|
||||
key = self._row_key(index)
|
||||
if key is None or key not in self.overrides:
|
||||
return
|
||||
self.overrides.pop(key)
|
||||
self._reset_res_widgets()
|
||||
self._sync_res_fields()
|
||||
# Les widgets de la rangée portent encore l'ancienne valeur : on
|
||||
# les remonte pour qu'ils disent la vérité.
|
||||
self._recompute()
|
||||
self._mount_rows()
|
||||
|
||||
def _form_values(self):
|
||||
install = None
|
||||
|
|
@ -987,7 +1155,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
"timezone": self.query_one("#f_tz", Input).value.strip()
|
||||
or ctx.get("timezone")
|
||||
or "",
|
||||
"desktop": self._desktop(),
|
||||
"desktop": self._default_desktop(),
|
||||
"python_provider": self._python_provider(),
|
||||
"install": install,
|
||||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||||
|
|
|
|||
|
|
@ -249,7 +249,13 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
|||
# En-tête d'emblée (date/distro/version/arch) : le log n'est jamais
|
||||
# vide, l'utilisateur voit tout de suite QUOI s'installe.
|
||||
Path(log_path).write_text(_log_header(vm, branch, when))
|
||||
_launch_one(vm["ip"], remote_cmd, log_path, vm["name"])
|
||||
# Une VM peut porter SA commande : depuis que le type de VM (serveur
|
||||
# ou bureau) se choisit machine par machine, le script distant n'est
|
||||
# plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse
|
||||
# intacts les appelants qui n'en fournissent qu'une.
|
||||
_launch_one(
|
||||
vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"]
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"name": vm["name"],
|
||||
|
|
|
|||
|
|
@ -4797,8 +4797,17 @@ class TODO:
|
|||
run_monitor,
|
||||
)
|
||||
|
||||
# `desktop` accepte une SAVEUR unique (toutes les VM) ou un dict
|
||||
# {nom: saveur} depuis que le type se choisit machine par machine. La
|
||||
# commande distante en dépend, donc elle se construit par VM ; celle-ci
|
||||
# reste le défaut pour les noms absents du dict.
|
||||
desk_map = desktop if isinstance(desktop, dict) else {}
|
||||
remote = self._qemu_erplibre_remote_cmd(
|
||||
branch, final_cmd, prod, desktop, python_provider
|
||||
branch,
|
||||
final_cmd,
|
||||
prod,
|
||||
"" if desk_map else desktop,
|
||||
python_provider,
|
||||
)
|
||||
try:
|
||||
mod = self._qemu_import_module()
|
||||
|
|
@ -4815,15 +4824,22 @@ class TODO:
|
|||
if mod
|
||||
else (None, None, None)
|
||||
)
|
||||
vms.append(
|
||||
{
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"distro": d,
|
||||
"version": v,
|
||||
"arch": a,
|
||||
}
|
||||
)
|
||||
entry = {
|
||||
"name": name,
|
||||
"ip": ip,
|
||||
"distro": d,
|
||||
"version": v,
|
||||
"arch": a,
|
||||
}
|
||||
if desk_map:
|
||||
entry["remote_cmd"] = self._qemu_erplibre_remote_cmd(
|
||||
branch,
|
||||
final_cmd,
|
||||
prod,
|
||||
desk_map.get(name, ""),
|
||||
python_provider,
|
||||
)
|
||||
vms.append(entry)
|
||||
else:
|
||||
print(f" {name}: {t('no IP, skipped.')}")
|
||||
if not vms:
|
||||
|
|
@ -5361,7 +5377,9 @@ class TODO:
|
|||
dry_run=dry_run,
|
||||
timezone=spec.get("timezone"),
|
||||
locale=spec.get("locale"),
|
||||
desktop=bool(spec.get("desktop")),
|
||||
# Le type suit la VM. Repli sur la valeur de spec pour la CLI,
|
||||
# qui ne pose la question qu'une fois pour tout le parc.
|
||||
desktop=bool(vm.get("desktop", spec.get("desktop"))),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
|
|
@ -6121,7 +6139,18 @@ class TODO:
|
|||
deployed = list(spec.get("existing") or [])
|
||||
install = spec.get("install")
|
||||
install_branch = install["branch"] if install else None
|
||||
desktop = spec.get("desktop") or ""
|
||||
# Le type de VM est choisi machine par machine dans la TUI ; la CLI n'en
|
||||
# pose qu'un pour tout le parc. On ramene les deux a la meme carte, et
|
||||
# `desktop` reste la reponse a « faut-il installer un bureau quelque
|
||||
# part ? », qui declenche la phase d'installation.
|
||||
desktop_default = spec.get("desktop") or ""
|
||||
desktop_map = {
|
||||
vm["name"]: (vm.get("desktop", desktop_default) or "")
|
||||
for vm in pending
|
||||
}
|
||||
for _name in deployed:
|
||||
desktop_map.setdefault(_name, desktop_default)
|
||||
desktop = next((d for d in desktop_map.values() if d), "")
|
||||
python_provider = spec.get("python_provider") or ""
|
||||
ssh_key = spec.get("ssh_key")
|
||||
add_ssh_config = spec["add_ssh_config"]
|
||||
|
|
@ -6196,7 +6225,7 @@ class TODO:
|
|||
ip_map,
|
||||
install["cmd"] if install else None,
|
||||
install["prod"] if install else False,
|
||||
desktop=desktop,
|
||||
desktop=desktop_map,
|
||||
python_provider=python_provider,
|
||||
)
|
||||
else:
|
||||
|
|
@ -6212,7 +6241,7 @@ class TODO:
|
|||
ip_map.get(name),
|
||||
install["cmd"],
|
||||
install["prod"],
|
||||
desktop=desktop,
|
||||
desktop=desktop_map.get(name, ""),
|
||||
python_provider=python_provider,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2139,26 +2139,146 @@ TRANSLATIONS = {
|
|||
"fr": "Éditer la VM",
|
||||
"en": "Edit VM",
|
||||
},
|
||||
"Server": {
|
||||
"fr": "Serveur",
|
||||
"en": "Server",
|
||||
},
|
||||
"VM type (default):": {
|
||||
"fr": "Type de VM (défaut) :",
|
||||
"en": "VM type (default):",
|
||||
},
|
||||
"These values are the default for every VM;": {
|
||||
"fr": "Ces valeurs sont le défaut de chaque VM ;",
|
||||
"en": "These values are the default for every VM;",
|
||||
},
|
||||
"adjust any of them per VM on the right.": {
|
||||
"fr": "ajustez-les VM par VM dans la vue de droite.",
|
||||
"en": "adjust any of them per VM on the right.",
|
||||
},
|
||||
"Application store:": {
|
||||
"fr": "Magasin d'applications :",
|
||||
"en": "Application store:",
|
||||
},
|
||||
"Application store (graphical Ubuntu VMs):": {
|
||||
"fr": "Magasin d'applications (VM Ubuntu graphiques) :",
|
||||
"en": "Application store (graphical Ubuntu VMs):",
|
||||
},
|
||||
"deb only (epiphany-browser)": {
|
||||
"fr": "deb uniquement (epiphany-browser)",
|
||||
"en": "deb only (epiphany-browser)",
|
||||
},
|
||||
"Flatpak tooling, no Flathub": {
|
||||
"fr": "outillage Flatpak, sans Flathub",
|
||||
"en": "Flatpak tooling, no Flathub",
|
||||
},
|
||||
"snap (Ubuntu default, Firefox)": {
|
||||
"fr": "snap (défaut Ubuntu, Firefox)",
|
||||
"en": "snap (Ubuntu default, Firefox)",
|
||||
},
|
||||
"snap needs the store; slow under emulation.": {
|
||||
"fr": "snap exige le store ; lent sous émulation.",
|
||||
"en": "snap needs the store; slow under emulation.",
|
||||
},
|
||||
"No graphical VM on a snap-based distro.": {
|
||||
"fr": "Aucune VM graphique sur une distribution à snap.",
|
||||
"en": "No graphical VM on a snap-based distro.",
|
||||
},
|
||||
"Resources — applied to ALL VMs": {
|
||||
"fr": "Ressources — appliquées à TOUTES les VM",
|
||||
"en": "Resources — applied to ALL VMs",
|
||||
},
|
||||
"The profile and these fields change EVERY VM.": {
|
||||
"fr": "Le profil et ces champs changent TOUTES les VM.",
|
||||
"en": "The profile and these fields change EVERY VM.",
|
||||
},
|
||||
"A VM edited on the right (marked) keeps its own.": {
|
||||
"fr": "Une VM modifiée à droite (marquée ✎) garde les siennes.",
|
||||
"en": "A VM edited on the right (marked) keeps its own.",
|
||||
},
|
||||
"Rename the VM": {
|
||||
"fr": "Renommer la VM",
|
||||
"en": "Rename the VM",
|
||||
},
|
||||
"Rename": {
|
||||
"fr": "Renommer",
|
||||
"en": "Rename",
|
||||
},
|
||||
"Empty = back to the automatic name:": {
|
||||
"fr": "Vide = revenir au nom automatique :",
|
||||
"en": "Empty = back to the automatic name:",
|
||||
},
|
||||
"Invalid name: letters, digits, hyphens.": {
|
||||
"fr": "Nom invalide : lettres, chiffres et traits d'union.",
|
||||
"en": "Invalid name: letters, digits, hyphens.",
|
||||
},
|
||||
"default; a VM may differ, see its line": {
|
||||
"fr": "défaut ; une VM peut s'en écarter, voir sa ligne",
|
||||
"en": "default; a VM may differ, see its line",
|
||||
},
|
||||
"varies, see each line": {
|
||||
"fr": "varie, voir chaque ligne",
|
||||
"en": "varies, see each line",
|
||||
},
|
||||
"From your workstation:": {
|
||||
"fr": "Depuis votre poste :",
|
||||
"en": "From your workstation:",
|
||||
},
|
||||
"then point your client at": {
|
||||
"fr": "puis pointez votre client sur",
|
||||
"en": "then point your client at",
|
||||
},
|
||||
"Remote desktop tunnel (VNC/RDP through SSH)": {
|
||||
"fr": "🖥 Tunnel bureau distant (VNC/RDP par SSH)",
|
||||
"en": "🖥 Remote desktop tunnel (VNC/RDP through SSH)",
|
||||
},
|
||||
"Remote desktop tunnel": {
|
||||
"fr": "Tunnel vers le bureau distant",
|
||||
"en": "Remote desktop tunnel",
|
||||
},
|
||||
"Which VM?": {"fr": "Quelle VM ?", "en": "Which VM?"},
|
||||
"No IP for this VM; is it running?": {
|
||||
"fr": "Pas d'IP pour cette VM ; tourne-t-elle ?",
|
||||
"en": "No IP for this VM; is it running?",
|
||||
},
|
||||
"Run this on YOUR workstation:": {
|
||||
"fr": "À lancer sur VOTRE poste :",
|
||||
"en": "Run this on YOUR workstation:",
|
||||
},
|
||||
"(through the ProxyJump already in ~/.ssh/config)": {
|
||||
"fr": "(par le ProxyJump déjà dans ~/.ssh/config)",
|
||||
"en": "(through the ProxyJump already in ~/.ssh/config)",
|
||||
},
|
||||
"No ~/.ssh/config entry; see SSH configuration.": {
|
||||
"fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.",
|
||||
"en": "No ~/.ssh/config entry; see SSH configuration.",
|
||||
},
|
||||
"Not in an SSH session: check the host address.": {
|
||||
"fr": "Hors session SSH : vérifiez l'adresse de l'hôte.",
|
||||
"en": "Not in an SSH session: check the host address.",
|
||||
},
|
||||
"The tunnel stays open as long as that ssh runs.": {
|
||||
"fr": "Le tunnel reste ouvert tant que ce ssh tourne.",
|
||||
"en": "The tunnel stays open as long as that ssh runs.",
|
||||
},
|
||||
"No VM defined.": {"fr": "Aucune VM définie.", "en": "No VM defined."},
|
||||
"No host in ~/.ssh/config and no local VM.": {
|
||||
"fr": "Aucun hôte dans ~/.ssh/config et aucune VM locale.",
|
||||
"en": "No host in ~/.ssh/config and no local VM.",
|
||||
},
|
||||
"local VM": {"fr": "VM locale", "en": "local VM"},
|
||||
"Remote desktop kind:": {
|
||||
"fr": "Type de bureau distant :",
|
||||
"en": "Remote desktop kind:",
|
||||
},
|
||||
"already defined, not counted": {
|
||||
"fr": "déjà définie(s), non comptée(s)",
|
||||
"en": "already defined, not counted",
|
||||
},
|
||||
"RAM: 2048 or 8G": {"fr": "RAM : 2048 ou 8G", "en": "RAM: 2048 or 8G"},
|
||||
"Reset VM": {
|
||||
"fr": "Réinit. VM",
|
||||
"en": "Reset VM",
|
||||
},
|
||||
"Apply to all VMs": {
|
||||
"fr": "Appliquer à toutes les VM",
|
||||
"en": "Apply to all VMs",
|
||||
},
|
||||
"Apply to the selected VM only": {
|
||||
"fr": "Appliquer à la VM sélectionnée seulement",
|
||||
"en": "Apply to the selected VM only",
|
||||
},
|
||||
"VM(s) customised (F4 resets one)": {
|
||||
"fr": "VM personnalisée(s) (F4 en réinitialise une)",
|
||||
"en": "VM(s) customised (F4 resets one)",
|
||||
},
|
||||
"Pick a line in the plan (Tab, then arrows)": {
|
||||
"fr": "Choisissez une ligne du plan (Tab, puis les flèches)",
|
||||
"en": "Pick a line in the plan (Tab, then arrows)",
|
||||
},
|
||||
"Preview": {
|
||||
"fr": "Aperçu",
|
||||
"en": "Preview",
|
||||
|
|
|
|||
Loading…
Reference in a new issue