[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Le PLAN d'un déploiement : son état, ses gestes, ses deux fenêtres.
|
|
|
|
|
|
|
|
|
|
Un plan de déploiement se manipule pareil qu'on visse sur libvirt ou sur
|
|
|
|
|
Proxmox : on choisit des entrées du catalogue, on en demande plusieurs
|
|
|
|
|
exemplaires, on renomme une machine, on règle une ressource sur une seule
|
|
|
|
|
ligne, on fige celle qui doit échapper au réglage commun. Cette mécanique
|
|
|
|
|
— surcharges, verrous, exemplaires, saisies libres — n'a rien d'un
|
|
|
|
|
hyperviseur ; elle est ici, une seule fois, et les deux formulaires en
|
|
|
|
|
héritent.
|
|
|
|
|
|
|
|
|
|
Ce que le formulaire hôte doit fournir (le contrat, court exprès) :
|
|
|
|
|
|
|
|
|
|
* attributs `rows`, `vms`, `copies`, `overrides`, `locked`, `custom`,
|
|
|
|
|
`profile`, `_gen`, `_shown_ids` ;
|
|
|
|
|
* méthodes `_selected_entries()`, `_recompute()`, `_mount_rows()`,
|
|
|
|
|
`_render_plan()` ;
|
|
|
|
|
* crochets `_presets()` (choix par ressource), `_auto_name(index)` (le nom
|
|
|
|
|
que la VM reprendrait si on effaçait le sien) et `_lock_fields(index)` (ce
|
|
|
|
|
qu'un verrou recopie, ressources comprises).
|
|
|
|
|
|
|
|
|
|
Rien d'autre : le mixin ne connaît ni bureau, ni branche, ni stockage.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from script.todo.deploy_form_lib import (
|
|
|
|
|
FREE,
|
|
|
|
|
RES_FIELDS,
|
|
|
|
|
clean_hostname,
|
|
|
|
|
entry_key,
|
|
|
|
|
expand_copies,
|
|
|
|
|
parse_disk,
|
|
|
|
|
parse_ram,
|
|
|
|
|
positive_int,
|
|
|
|
|
t,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Champs qui ne veulent rien dire l'un sans l'autre : retirer la commande
|
|
|
|
|
# d'installation doit emporter son libellé, sinon le plan afficherait
|
|
|
|
|
# « Odoo 18 » à côté d'une VM qui n'installe plus rien.
|
|
|
|
|
COMPANIONS = {"install_cmd": ("install_label",)}
|
|
|
|
|
|
|
|
|
|
# Les écrans sont construits à l'APPEL, pas à l'import : ce module se lit sans
|
|
|
|
|
# Textual installé (le CLI TODO l'importe pour ses fonctions pures).
|
|
|
|
|
_ECRANS = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_screens():
|
|
|
|
|
from textual.app import ComposeResult
|
[IMP] qemu deploy : pré-configurer la VM, et dire ce qui s'y installe
L'option des outils d'assistance posait trois installateurs amont, puis
laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt,
commandes Claude, activation du venv. Elle les pose, en deux temps parce que
hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend
toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié
manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant
déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un
libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux
écrans montés sans terminal, « bash -n » sur les fragments distants.
--- EN ---
The AI tools box posed three upstream installers, then left every setting to
retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude
commands, activating the venv. It poses them, in two phases because hooks and
templates LIVE in the checkout; the complement follows the clone, always
returns 0 — a comfort must not fail a VM — and with no clone the missing half
is named. core.editor is posed only where there is none, the host already
transmitting its own. The `?` and F1 help says what each option installs,
where a checkbox label carries three of this one's eight poses. Checked: 51
tests, both screens mounted headless, `bash -n` on the remote fragments.
Assisted-by: Claude Opus 5
2026-09-04 02:03:52 -04:00
|
|
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
from textual.screen import ModalScreen
|
|
|
|
|
from textual.widgets import Button, Input, Static
|
|
|
|
|
|
|
|
|
|
class RenameScreen(ModalScreen):
|
|
|
|
|
"""Nom d'une VM. Vide = revenir au nom automatique."""
|
|
|
|
|
|
|
|
|
|
BINDINGS = [("escape", "cancel", t("Cancel"))]
|
|
|
|
|
|
|
|
|
|
def __init__(self, name, auto):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self._name = name
|
|
|
|
|
self._auto = auto
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
with Vertical(id="renbox"):
|
|
|
|
|
yield Static(t("Rename the VM"), id="rentitle")
|
|
|
|
|
yield Input(value=self._name, id="renval")
|
|
|
|
|
yield Static(
|
|
|
|
|
f" {t('Empty = back to the automatic name:')} "
|
|
|
|
|
f"{self._auto}",
|
|
|
|
|
id="renhint",
|
|
|
|
|
)
|
|
|
|
|
with Horizontal(id="renbtns"):
|
|
|
|
|
yield Button(t("Cancel"), id="ren_no")
|
|
|
|
|
yield Button(t("Rename"), variant="primary", id="ren_ok")
|
|
|
|
|
|
|
|
|
|
def on_button_pressed(self, event) -> None:
|
|
|
|
|
if event.button.id != "ren_ok":
|
|
|
|
|
self.dismiss(None)
|
|
|
|
|
return
|
|
|
|
|
self.dismiss(self.query_one("#renval", Input).value)
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
[IMP] qemu deploy : pré-configurer la VM, et dire ce qui s'y installe
L'option des outils d'assistance posait trois installateurs amont, puis
laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt,
commandes Claude, activation du venv. Elle les pose, en deux temps parce que
hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend
toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié
manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant
déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un
libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux
écrans montés sans terminal, « bash -n » sur les fragments distants.
--- EN ---
The AI tools box posed three upstream installers, then left every setting to
retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude
commands, activating the venv. It poses them, in two phases because hooks and
templates LIVE in the checkout; the complement follows the clone, always
returns 0 — a comfort must not fail a VM — and with no clone the missing half
is named. core.editor is posed only where there is none, the host already
transmitting its own. The `?` and F1 help says what each option installs,
where a checkbox label carries three of this one's eight poses. Checked: 51
tests, both screens mounted headless, `bash -n` on the remote fragments.
Assisted-by: Claude Opus 5
2026-09-04 02:03:52 -04:00
|
|
|
class HelpScreen(ModalScreen):
|
|
|
|
|
"""Ce que chaque option d'installation pose VRAIMENT dans la VM.
|
|
|
|
|
|
|
|
|
|
`blocks` : [(titre, [lignes])], dans l'ordre du formulaire. Une case
|
|
|
|
|
cochée engage parfois huit poses, et son libellé n'en nomme que trois :
|
|
|
|
|
cet écran est le seul endroit où la liste complète est lisible AVANT
|
|
|
|
|
de déployer.
|
|
|
|
|
|
|
|
|
|
La fenêtre ne fait que LIRE : elle se ferme sans rien changer, ce qui
|
|
|
|
|
permet de l'ouvrir en plein remplissage du formulaire.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
BINDINGS = [
|
|
|
|
|
("escape", "close", t("Close")),
|
|
|
|
|
("q", "close", t("Close")),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def __init__(self, blocks, title=""):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self._blocks = blocks
|
|
|
|
|
self._title = title or t("What each option installs")
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
with Vertical(id="helpbox"):
|
|
|
|
|
yield Static(
|
|
|
|
|
f" {self._title} ({t('Esc to close')})",
|
|
|
|
|
id="helptitle",
|
|
|
|
|
)
|
|
|
|
|
with VerticalScroll(id="helpbody"):
|
|
|
|
|
for titre, lignes in self._blocks:
|
|
|
|
|
yield Static(f"▸ {titre}", classes="helphead")
|
|
|
|
|
for ligne in lignes:
|
|
|
|
|
yield Static(f" {ligne}")
|
|
|
|
|
yield Static("")
|
|
|
|
|
|
|
|
|
|
def action_close(self) -> None:
|
|
|
|
|
self.dismiss()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"rename": RenameScreen,
|
|
|
|
|
"preview": PreviewScreen,
|
|
|
|
|
"help": HelpScreen,
|
|
|
|
|
}
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def rename_screen():
|
|
|
|
|
"""La fenêtre de renommage, construite au premier appel."""
|
|
|
|
|
if not _ECRANS:
|
|
|
|
|
_ECRANS.update(_build_screens())
|
|
|
|
|
return _ECRANS["rename"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preview_screen():
|
|
|
|
|
"""La fenêtre d'aperçu, construite au premier appel."""
|
|
|
|
|
if not _ECRANS:
|
|
|
|
|
_ECRANS.update(_build_screens())
|
|
|
|
|
return _ECRANS["preview"]
|
|
|
|
|
|
|
|
|
|
|
[IMP] qemu deploy : pré-configurer la VM, et dire ce qui s'y installe
L'option des outils d'assistance posait trois installateurs amont, puis
laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt,
commandes Claude, activation du venv. Elle les pose, en deux temps parce que
hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend
toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié
manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant
déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un
libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux
écrans montés sans terminal, « bash -n » sur les fragments distants.
--- EN ---
The AI tools box posed three upstream installers, then left every setting to
retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude
commands, activating the venv. It poses them, in two phases because hooks and
templates LIVE in the checkout; the complement follows the clone, always
returns 0 — a comfort must not fail a VM — and with no clone the missing half
is named. core.editor is posed only where there is none, the host already
transmitting its own. The `?` and F1 help says what each option installs,
where a checkbox label carries three of this one's eight poses. Checked: 51
tests, both screens mounted headless, `bash -n` on the remote fragments.
Assisted-by: Claude Opus 5
2026-09-04 02:03:52 -04:00
|
|
|
def help_screen():
|
|
|
|
|
"""La fenêtre d'aide, construite au premier appel."""
|
|
|
|
|
if not _ECRANS:
|
|
|
|
|
_ECRANS.update(_build_screens())
|
|
|
|
|
return _ECRANS["help"]
|
|
|
|
|
|
|
|
|
|
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
class PlanMixin:
|
|
|
|
|
"""Les gestes du plan, communs à tous les formulaires de déploiement."""
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
# Le jeu de VM affiché
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
def _plan_entries(self):
|
|
|
|
|
"""Entrées du plan : la sélection, dépliée en exemplaires."""
|
|
|
|
|
return expand_copies(self._selected_entries(), self.copies)
|
|
|
|
|
|
|
|
|
|
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._plan_entries())
|
|
|
|
|
|
|
|
|
|
def _row_key(self, index):
|
|
|
|
|
entries = self._plan_entries()
|
|
|
|
|
return entry_key(entries[index]) if index < len(entries) else None
|
|
|
|
|
|
|
|
|
|
def _is_current(self, widget):
|
|
|
|
|
"""Le widget appartient-il au jeu de rangées ACTUEL ?"""
|
|
|
|
|
return getattr(widget, "_el_gen", None) == self._gen
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
index = int(match.group(1))
|
|
|
|
|
return index if 0 <= index < len(self.rows) else None
|
|
|
|
|
|
|
|
|
|
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']}"
|
|
|
|
|
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}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _row_echo(self, index, field, value) -> bool:
|
|
|
|
|
"""Cette valeur est-elle l'ÉCHO du montage plutôt qu'une saisie ?
|
|
|
|
|
|
|
|
|
|
Poser « value= » sur un Select 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 réglage commun devenait inopérant. On compare donc à ce que le
|
|
|
|
|
modèle dit déjà : une valeur identique n'est pas une saisie.
|
|
|
|
|
|
|
|
|
|
Cas limite assumé : choisir explicitement la valeur que le réglage
|
|
|
|
|
commun donne déjà n'enregistre pas de surcharge. La VM suivra donc ce
|
|
|
|
|
réglage s'il change — ce qui est aussi le plus attendu quand on n'a
|
|
|
|
|
rien changé de visible."""
|
|
|
|
|
if index >= len(self.rows):
|
|
|
|
|
return True
|
|
|
|
|
return value == self.rows[index]["vm"].get(field)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
# Surcharges : ce qu'une VM garde quand le réglage commun change
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
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, 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)
|
|
|
|
|
if not self.overrides.get(key):
|
|
|
|
|
self.overrides.pop(key, None)
|
|
|
|
|
else:
|
|
|
|
|
self.overrides.setdefault(key, {})[field] = value
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
for compagnon in COMPANIONS.get(field, ()):
|
|
|
|
|
self.overrides[key].pop(compagnon, 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_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 tous les champs d'un coup.
|
|
|
|
|
|
|
|
|
|
`_lock_fields` dit CE QUI est recopié : chaque formulaire y ajoute
|
|
|
|
|
ce qui, chez lui, vient d'un choix commun."""
|
|
|
|
|
from textual.containers import VerticalScroll
|
|
|
|
|
from textual.widgets import Button
|
|
|
|
|
|
|
|
|
|
key = self._row_key(index)
|
|
|
|
|
if key is None or index >= len(self.rows):
|
|
|
|
|
return
|
|
|
|
|
if on:
|
|
|
|
|
self.locked.add(key)
|
|
|
|
|
self.overrides[key] = self._lock_fields(index)
|
|
|
|
|
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 _lock_fields(self, index):
|
|
|
|
|
"""Ce qu'un verrou recopie. Les ressources, au minimum."""
|
|
|
|
|
vm = self.rows[index]["vm"]
|
|
|
|
|
return {"vcpus": vm["vcpus"], "ram": vm["ram"], "disk": vm["disk"]}
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
# Exemplaires et renommage
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
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 _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
|
|
|
|
|
auto = self._auto_name(index)
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
rename_screen()(self.rows[index]["vm"]["name"], auto), done
|
|
|
|
|
)
|
|
|
|
|
|
[FIX] proxmox : quatre écrans qui parlaient d'une machine locale
La confirmation de suppression promettait à TOUTE VM « son disque qcow2
EFFACÉ », puis nommait /var/lib/libvirt/images/<nom>.qcow2. Sur une VM
Proxmox ce fichier n'existe pas — au mieux, au pire c'est celui d'une autre
VM du même nom. C'est la peur exacte qui avait fait remonter le nettoyage.
Elle nomme désormais l'hôte, le VMID et « qm destroy ».
« Console de l'hyperviseur » lisait le port par « virsh vncdisplay ». Un
Proxmox n'a pas de libvirt : l'échec se lisait « écran fermé » et on
conseillait « sudo virsh edit » sur une machine sans ce binaire. Ce n'est pas
un écran fermé, c'est la mauvaise question — Proxmox sert le sien par un
ticket. Les deux vrais chemins sont nommés : la console série, l'interface
web par tunnel.
La colonne Odoo était un 🟢 acquis pour toujours : « Odoo ne redescend pas en
cours d'install » est faux — le service redémarre au moins une fois, et il
lui arrive de mourir. Elle est relue, gratuitement sur Proxmox, toutes les
trente secondes ailleurs. Un hôte muet reste distinct d'un Odoo tombé.
Enfin « Versions principales » (F7) manquait à l'écran Proxmox, qui affiche
pourtant le même catalogue. Les trois gestes du catalogue vivent maintenant
dans le socle du plan, où ils ne peuvent plus diverger.
--- EN ---
The delete confirmation promised EVERY VM "its qcow2 disk ERASED", then named
/var/lib/libvirt/images/<name>.qcow2. On a Proxmox VM that file does not
exist — at best; at worst it is another VM's, of the same name. That is the
very fear that surfaced the cleanup report. It now names the host, the VMID
and "qm destroy".
"Hypervisor console" read the port through "virsh vncdisplay". Proxmox has no
libvirt: the failure read as "screen closed" and we advised "sudo virsh edit"
on a machine without that binary. It is not a closed screen, it is the wrong
question — Proxmox serves its own by ticket. Both real paths are named: the
serial console, the web interface through a tunnel.
The Odoo column was a 🟢 acquired forever: "Odoo does not go back down during
the install" is false — the service restarts at least once, and it does die.
It is re-read, free on Proxmox, every thirty seconds elsewhere. A silent host
stays distinct from a dead Odoo.
Finally "Main versions" (F7) was missing from the Proxmox screen, which shows
the same catalog. The catalog's three gestures now live in the plan
foundation, where they can no longer diverge.
Assisted-by: Claude Opus 5
2026-08-24 23:07:55 -04:00
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
# Le catalogue — les trois raccourcis de sélection
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
def _catalog(self):
|
|
|
|
|
from textual.widgets import SelectionList
|
|
|
|
|
|
|
|
|
|
return self.query_one("#f_catalog", SelectionList)
|
|
|
|
|
|
|
|
|
|
def action_select_all(self) -> None:
|
|
|
|
|
self._catalog().select_all()
|
|
|
|
|
|
|
|
|
|
def action_select_none(self) -> None:
|
|
|
|
|
self._catalog().deselect_all()
|
|
|
|
|
|
|
|
|
|
def action_select_main(self) -> None:
|
|
|
|
|
"""Une VM par distribution : la version marquée par défaut.
|
|
|
|
|
|
|
|
|
|
Ici et non dans un formulaire : les trois gestes sont les mêmes des
|
|
|
|
|
deux côtés, et celui-ci manquait à l'écran Proxmox — qui affiche
|
|
|
|
|
pourtant le même catalogue, drapeau « default » compris."""
|
|
|
|
|
widget = self._catalog()
|
|
|
|
|
widget.deselect_all()
|
|
|
|
|
for i, e in enumerate(self._entries()):
|
|
|
|
|
if e.get("default"):
|
|
|
|
|
widget.select(i)
|
|
|
|
|
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
def _auto_name(self, index):
|
|
|
|
|
"""Nom que la VM reprendrait si on effaçait le sien."""
|
|
|
|
|
return self._plan_entries()[index]["name"]
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
# Saisies libres — la valeur qui n'est pas dans la liste
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
def _row_free(self, index, field, visible) -> None:
|
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
|
|
raw = self.query_one(f"#c{index}_{field}", Input).value.strip()
|
|
|
|
|
return self._read_res(field, raw)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _read_res(field, raw):
|
|
|
|
|
"""Lecture d'une ressource saisie à la main. Le disque se lit en
|
|
|
|
|
tailles (« 40G »), la mémoire accepte les deux, le reste est un
|
|
|
|
|
entier — et une valeur invalide vaut « rien », jamais zéro."""
|
|
|
|
|
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."""
|
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
|
|
widget = self.query_one(RES_FIELDS[field][1], Input)
|
|
|
|
|
widget.display = bool(visible)
|
|
|
|
|
widget.disabled = not visible
|
|
|
|
|
|
|
|
|
|
def _apply_free(self, field) -> None:
|
|
|
|
|
"""Relit la saisie libre. Une valeur invalide n'écrase rien : le
|
|
|
|
|
profil retombe alors sur celle du catalogue."""
|
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
|
|
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
|
|
|
|
|
self.custom[field] = self._read_res(field, raw)
|
|
|
|
|
self._clear_overrides((field,))
|
|
|
|
|
|
|
|
|
|
def _sync_free_inputs(self) -> None:
|
|
|
|
|
"""Chaque saisie libre de rangée s'affiche si — et seulement si — sa
|
|
|
|
|
ressource échappe aux préréglages, ou si la liste attend une frappe."""
|
|
|
|
|
from textual.widgets import Input, Select
|
|
|
|
|
|
|
|
|
|
presets = self._presets()
|
|
|
|
|
for i, r in enumerate(self.rows):
|
|
|
|
|
vm = r["vm"]
|
|
|
|
|
for field, choix in presets.items():
|
|
|
|
|
try:
|
|
|
|
|
widget = self.query_one(f"#c{i}_{field}", Input)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
# 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 choix
|
|
|
|
|
widget.display = free
|
|
|
|
|
widget.disabled = not free
|