[ADD] tui qemu: freeze one VM's resources, row in green
A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
This commit is contained in:
parent
e60f18806e
commit
d35950ab97
1 changed files with 285 additions and 6 deletions
|
|
@ -388,8 +388,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
/* 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. */
|
||||
|
||||
|
||||
.vmbranch { width: 18; }
|
||||
.vmcopy { width: 5; min-width: 5; }
|
||||
.vmhead { height: 1; }
|
||||
.vmrow { height: 3; align-vertical: middle; }
|
||||
|
|
@ -694,7 +693,9 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# vit sur la ligne d'affichage, jamais sur la VM : celle-ci part
|
||||
# telle quelle dans la spec, que la CLI produit à l'identique.
|
||||
for row, entry in zip(self.rows, entries):
|
||||
row["custom"] = bool(self.overrides.get(entry_key(entry)))
|
||||
key = entry_key(entry)
|
||||
row["custom"] = bool(self.overrides.get(key))
|
||||
row["locked"] = key in self.locked
|
||||
self._render_plan()
|
||||
self._render_mise()
|
||||
self._render_store()
|
||||
|
|
@ -803,7 +804,19 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
widgets = []
|
||||
for i, r in enumerate(self.rows):
|
||||
vm = r["vm"]
|
||||
key = entry_key(self._selected_entries()[i])
|
||||
row = Horizontal(
|
||||
# « + » ajoute un exemplaire de CETTE entrée ; « - » ne
|
||||
# s'affiche que sur une copie, pour qu'on ne puisse pas
|
||||
# retirer l'original par mégarde.
|
||||
Button("+", id=f"p{i}", classes="vmcopy"),
|
||||
Button("✎", id=f"r{i}", classes="vmcopy"),
|
||||
Button(
|
||||
"🔒" if key in self.locked else "🔓",
|
||||
id=f"l{i}",
|
||||
variant="success" if key in self.locked else "default",
|
||||
classes="vmlock",
|
||||
),
|
||||
Select(
|
||||
[(str(c), c) for c in ctx["cpu_presets"]]
|
||||
+ [(t("free value…"), FREE)],
|
||||
|
|
@ -867,6 +880,22 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
id=f"c{i}_disk",
|
||||
classes="freeval",
|
||||
),
|
||||
(
|
||||
Button("−", id=f"m{i}", classes="vmcopy")
|
||||
if item.get("instance")
|
||||
else Static("", classes="vmcopy")
|
||||
),
|
||||
Select(
|
||||
[(b, b) for b in branches],
|
||||
classes="vmbranch",
|
||||
value=(
|
||||
item.get("branch")
|
||||
or self.rows[i]["vm"].get("branch")
|
||||
or branches[0]
|
||||
),
|
||||
allow_blank=False,
|
||||
id=f"v{i}_branch",
|
||||
),
|
||||
Select(
|
||||
self._type_options(),
|
||||
value=vm.get("desktop") or SERVER,
|
||||
|
|
@ -879,7 +908,15 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
Vertical(
|
||||
Static(self._row_head(i, r), id=f"h{i}"),
|
||||
row,
|
||||
classes="vmcard",
|
||||
# Pas d'id sur la carte : « remove_children() » est
|
||||
# ASYNCHRONE, les anciennes sont encore là au montage
|
||||
# et Textual refuse deux frères de même id. Les ids
|
||||
# des champs vivent un niveau plus bas, dans un parent
|
||||
# neuf — la collision ne les touche pas. On atteint
|
||||
# donc la carte par son RANG.
|
||||
classes=(
|
||||
"vmcard locked" if key in self.locked else "vmcard"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -910,6 +947,50 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._sync_free_inputs()
|
||||
self._syncing = False
|
||||
|
||||
def _refresh_row_widgets(self) -> None:
|
||||
"""Remet les listes de chaque rangée sur ce que la VM vaut MAINTENANT.
|
||||
|
||||
Sans cela, changer le profil x1..x4 mettait les totaux à jour mais
|
||||
laissait les listes sur leurs anciennes valeurs : l'écran affichait
|
||||
8192 pendant que la VM valait 4096. Les rangées ne sont remontées
|
||||
que si le JEU de VM change — pour ne pas voler le focus — donc ce
|
||||
rafraîchissement doit se faire à la main.
|
||||
|
||||
Aucun risque de boucle : on_select_changed ignore une valeur déjà
|
||||
égale à celle du modèle, et le modèle vient précisément d'être
|
||||
recalculé."""
|
||||
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:
|
||||
sel = self.query_one(f"#v{i}_{field}", Select)
|
||||
except Exception:
|
||||
continue
|
||||
# Une liste posée sur « libre… » ne doit PAS être remise
|
||||
# sur une valeur : l'utilisateur vient de la choisir, et
|
||||
# tant qu'il n'a rien tapé la VM vaut encore celle du
|
||||
# profil — on la lui reprendrait sous les doigts.
|
||||
if sel.value is FREE:
|
||||
continue
|
||||
# Une valeur libre déjà saisie n'est dans aucune liste :
|
||||
# liste vide, la saisie à côté porte le nombre.
|
||||
sel.value = (
|
||||
vm[field] if vm[field] in presets else SELECT_NULL
|
||||
)
|
||||
for wid, value in (
|
||||
(f"#v{i}_type", vm.get("desktop") or SERVER),
|
||||
(f"#v{i}_branch", vm.get("branch") or self._branch()),
|
||||
):
|
||||
try:
|
||||
self.query_one(wid, Select).value = value
|
||||
except Exception:
|
||||
pass
|
||||
self._sync_free_inputs()
|
||||
|
||||
def _sync_free_inputs(self) -> None:
|
||||
for i, r in enumerate(self.rows):
|
||||
vm = r["vm"]
|
||||
|
|
@ -922,7 +1003,16 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
widget = self.query_one(f"#c{i}_{field}", Input)
|
||||
except Exception:
|
||||
continue
|
||||
free = vm[field] not in presets
|
||||
# Visible si la valeur EST libre, ou si la liste est
|
||||
# posée sur « libre… » en attente d'une saisie.
|
||||
try:
|
||||
chosen_free = (
|
||||
self.query_one(f"#v{i}_{field}", Select).value
|
||||
is FREE
|
||||
)
|
||||
except Exception:
|
||||
chosen_free = False
|
||||
free = chosen_free or vm[field] not in presets
|
||||
widget.display = free
|
||||
widget.disabled = not free
|
||||
|
||||
|
|
@ -933,7 +1023,12 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
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 ""
|
||||
if row.get("locked"):
|
||||
mark = " 🔒 figée"
|
||||
elif row.get("custom"):
|
||||
mark = " ✎"
|
||||
else:
|
||||
mark = ""
|
||||
return (
|
||||
f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} "
|
||||
f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}"
|
||||
|
|
@ -953,6 +1048,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._refresh_row_widgets()
|
||||
if not self.rows:
|
||||
# Rien de coché : un total à zéro n'apprend rien, on dit
|
||||
# plutôt comment remplir la liste.
|
||||
|
|
@ -995,12 +1091,23 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self.arch = arches[event.radio_set.pressed_index]
|
||||
self._reload_catalog()
|
||||
elif event.radio_set.id == "f_type":
|
||||
self._clear_overrides(("desktop",))
|
||||
# Recalcul : le disque annonce inclut le bureau, et la
|
||||
# colonne Statut affiche le type de VM.
|
||||
self._recompute()
|
||||
elif event.radio_set.id == "f_profile":
|
||||
index = event.radio_set.pressed_index
|
||||
self.profile = "custom" if index == 4 else str(index + 1)
|
||||
# Un multiplicateur x1..x4 ne touche QUE les vCPU et la RAM —
|
||||
# apply_profile y laisse le disque du catalogue. Y effacer une
|
||||
# taille de disque réglée à la main la faisait disparaître sans
|
||||
# rien mettre à la place : on revenait à 20G sans l'avoir
|
||||
# demandé. Le disque n'est rendu au commun que par le profil
|
||||
# « personnalisé », qui en porte un.
|
||||
fields = ("vcpus", "ram")
|
||||
if self.profile == "custom":
|
||||
fields += ("disk",)
|
||||
self._clear_overrides(fields)
|
||||
custom = self.profile == "custom"
|
||||
for field, (sel, _inp) in RES_FIELDS.items():
|
||||
self.query_one(sel, Select).disabled = not custom
|
||||
|
|
@ -1027,6 +1134,36 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
entries = self._selected_entries()
|
||||
return entry_key(entries[index]) if index < len(entries) else None
|
||||
|
||||
def _clear_overrides(self, fields) -> None:
|
||||
"""Rend au choix commun les VM NON figées, pour ces champs-là.
|
||||
|
||||
Le cadenas est la seule chose qui résiste. Une valeur réglée à la
|
||||
main sur une rangée cède donc au choix global suivant : c'est ce
|
||||
qu'on attend d'un réglage « général », et le verrou existe
|
||||
précisément pour dire « pas celle-ci ».
|
||||
|
||||
Par champ, pas en bloc : changer la RAM générale n'a aucune raison
|
||||
d'effacer le disque qu'on a réglé sur une VM.
|
||||
|
||||
« name » n'y figure jamais : un renommage est explicite et ne
|
||||
découle d'aucune valeur générale."""
|
||||
changed = False
|
||||
for key in list(self.overrides):
|
||||
if key in self.locked:
|
||||
continue
|
||||
for field in fields:
|
||||
changed |= self.overrides[key].pop(field, None) is not None
|
||||
if not self.overrides[key]:
|
||||
self.overrides.pop(key, None)
|
||||
if changed:
|
||||
# Forcer le remontage : une rangée peut porter une saisie
|
||||
# LIBRE, que le simple rafraîchissement laisse en place — on
|
||||
# verrait « 12 » à l'écran pendant que la VM vaut 2. Le
|
||||
# remontage rebâtit tout depuis le modèle. Sans risque de vol
|
||||
# de focus : ce chemin part d'un widget GLOBAL, jamais d'une
|
||||
# rangée.
|
||||
self._shown_ids = ()
|
||||
|
||||
def _set_override(self, index, field, value) -> None:
|
||||
"""Écrit — ou retire — la surcharge d'UNE VM."""
|
||||
key = self._row_key(index)
|
||||
|
|
@ -1121,6 +1258,19 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._set_override(index, field, event.value)
|
||||
self._recompute()
|
||||
return
|
||||
# Les choix GLOBAUX de branche et de profil ne portent aucune
|
||||
# valeur de ressource : ils tombaient donc dans le « return »
|
||||
# ci-dessous sans rien recalculer, et les rangées restaient sur
|
||||
# l'ancienne version. Elles n'en gardent pas de copie — « » y
|
||||
# veut dire « celle du formulaire » — il suffit de redessiner.
|
||||
if event.select.id in ("f_branch", "f_profile_install"):
|
||||
self._clear_overrides(
|
||||
("branch",)
|
||||
if event.select.id == "f_branch"
|
||||
else ("install_cmd",)
|
||||
)
|
||||
self._recompute()
|
||||
return
|
||||
field = SELECT_TO_FIELD.get(event.select.id)
|
||||
if not field:
|
||||
return
|
||||
|
|
@ -1134,6 +1284,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._free[field] = False
|
||||
self._show_free(field, False)
|
||||
self.custom[field] = event.value
|
||||
self._clear_overrides((field,))
|
||||
self._recompute()
|
||||
|
||||
def _apply_free(self, field) -> None:
|
||||
|
|
@ -1146,6 +1297,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self.custom[field] = parse_ram(raw)
|
||||
else:
|
||||
self.custom[field] = positive_int(raw, 0)
|
||||
self._clear_overrides((field,))
|
||||
|
||||
def on_input_changed(self, event) -> None:
|
||||
if self._syncing:
|
||||
|
|
@ -1166,6 +1318,133 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._apply_free(field)
|
||||
self._recompute()
|
||||
|
||||
def _set_lock(self, index, on) -> None:
|
||||
"""Fige — ou libère — les ressources d'une VM.
|
||||
|
||||
Figer, c'est recopier les valeurs EFFECTIVES du moment dans les
|
||||
surcharges : le profil commun ne les atteint plus. Libérer les
|
||||
retire, et la VM retombe sous le profil. Le mécanisme est celui
|
||||
des surcharges, déjà éprouvé ; le verrou n'en est que la commande
|
||||
explicite, et il couvre les quatre champs d'un coup."""
|
||||
key = self._row_key(index)
|
||||
if key is None or index >= len(self.rows):
|
||||
return
|
||||
if on:
|
||||
vm = self.rows[index]["vm"]
|
||||
self.locked.add(key)
|
||||
# TOUT ce que la VM tient d'un choix commun est recopié, pas
|
||||
# seulement les ressources : la branche et le profil Odoo en
|
||||
# font partie. Les oublier laissait une VM « figée » changer
|
||||
# de version d'ERPLibre dès qu'on touchait au choix générique,
|
||||
# ce qui vide le mot de son sens.
|
||||
#
|
||||
# Les deux se résolvent AVANT d'être figés : « » y signifie
|
||||
# « celle du formulaire », et geler une chaîne vide ne
|
||||
# gèlerait rien du tout.
|
||||
self.overrides[key] = {
|
||||
"vcpus": vm["vcpus"],
|
||||
"ram": vm["ram"],
|
||||
"disk": vm["disk"],
|
||||
"desktop": vm.get("desktop") or "",
|
||||
"branch": vm.get("branch") or self._branch(),
|
||||
"install_cmd": (
|
||||
vm.get("install_cmd") or self._profile_cmd()
|
||||
),
|
||||
}
|
||||
else:
|
||||
self.locked.discard(key)
|
||||
self.overrides.pop(key, None)
|
||||
self._recompute()
|
||||
# La couleur de la ligne suit le verrou sans tout remonter : un
|
||||
# remontage volerait le focus à la case qu'on vient de cocher.
|
||||
cards = self.query_one("#plan", VerticalScroll).children
|
||||
if index < len(cards):
|
||||
cards[index].set_class(on, "locked")
|
||||
btn = self.query_one(f"#l{index}", Button)
|
||||
btn.label = "🔒" if on else "🔓"
|
||||
btn.variant = "success" if on else "default"
|
||||
|
||||
def _add_copy(self, index, delta) -> None:
|
||||
"""Ajoute ou retire un exemplaire de l'entrée visée.
|
||||
|
||||
Retirer enlève le DERNIER exemplaire, et avec lui ses réglages :
|
||||
les garder ferait resurgir d'anciennes valeurs à la copie
|
||||
suivante, sans que rien ne l'explique."""
|
||||
entries = self._plan_entries()
|
||||
if index >= len(entries):
|
||||
return
|
||||
item = entries[index]
|
||||
base = (item["distro"], item["version"], item["arch"])
|
||||
count = self.copies.get(base, 0)
|
||||
if delta > 0:
|
||||
self.copies[base] = count + 1
|
||||
else:
|
||||
if count <= 0:
|
||||
return
|
||||
gone = (*base, count)
|
||||
self.overrides.pop(gone, None)
|
||||
self.locked.discard(gone)
|
||||
self.copies[base] = count - 1
|
||||
if not self.copies[base]:
|
||||
self.copies.pop(base, None)
|
||||
self._recompute()
|
||||
# Le JEU de VM a changé : les rangées doivent être rebâties.
|
||||
self._mount_rows()
|
||||
|
||||
def on_button_pressed(self, event) -> None:
|
||||
match = re.match(r"([pm])(\d+)$", event.button.id or "")
|
||||
if match:
|
||||
self._add_copy(
|
||||
int(match.group(2)), 1 if match.group(1) == "p" else -1
|
||||
)
|
||||
return
|
||||
match = re.match(r"r(\d+)$", event.button.id or "")
|
||||
if match:
|
||||
self._rename(int(match.group(1)))
|
||||
return
|
||||
match = re.match(r"l(\d+)$", event.button.id or "")
|
||||
if match:
|
||||
index = int(match.group(1))
|
||||
key = self._row_key(index)
|
||||
if key is not None:
|
||||
self._set_lock(index, key not in self.locked)
|
||||
|
||||
def _rename(self, index) -> None:
|
||||
"""Renomme une VM. Le nom saisi devient une surcharge comme les
|
||||
autres : il survit au recalcul, et F4 le retire avec le reste."""
|
||||
key = self._row_key(index)
|
||||
if key is None or index >= len(self.rows):
|
||||
return
|
||||
entries = self._plan_entries()
|
||||
auto = vm_name(
|
||||
entries[index]["name"],
|
||||
self.rows[index]["vm"].get("desktop"),
|
||||
desktop_suffixes,
|
||||
)
|
||||
|
||||
def done(value):
|
||||
if value is None:
|
||||
return
|
||||
if not str(value).strip():
|
||||
self.overrides.get(key, {}).pop("name", None)
|
||||
if not self.overrides.get(key):
|
||||
self.overrides.pop(key, None)
|
||||
else:
|
||||
clean = clean_hostname(value)
|
||||
if not clean:
|
||||
self.notify(
|
||||
t("Invalid name: letters, digits, hyphens."),
|
||||
severity="error",
|
||||
)
|
||||
return
|
||||
self.overrides.setdefault(key, {})["name"] = clean
|
||||
self._recompute()
|
||||
self._mount_rows()
|
||||
|
||||
self.push_screen(
|
||||
RenameScreen(self.rows[index]["vm"]["name"], auto), done
|
||||
)
|
||||
|
||||
def on_checkbox_changed(self, event) -> None:
|
||||
if event.checkbox.id == "f_install":
|
||||
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
||||
|
|
|
|||
Loading…
Reference in a new issue