erplibre/script/todo/migration_status_tui.py
Mathieu Benoit c0c5144680 [ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.

The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.

The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.

--- FR ---

[ADD] état de migration : l, p, r et les deux touches de taille

Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.

Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

302 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'état d'une migration, en plein écran.
Le rapport texte dit tout, mais il dit tout D'UN COUP : sur une migration
de six paliers il fait plusieurs centaines de lignes, et ce qu'on cherche —
l'étape où ça a cassé, ce que le test de fumée a conclu — se trouve
quelque part au milieu. Un écran qui se parcourt règle exactement cela.
Les données viennent de `migration_status`, comme le rapport texte. Deux
assemblages séparés dériveraient l'un de l'autre sans que rien ne le dise,
et l'on finirait par lire deux états contradictoires de la même migration.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.todo import migration_status as status # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 4; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 46; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
"""
def head_text(dct):
"""Ce qui identifie la migration. Toujours visible, jamais à chercher."""
info = status.overview(dct)
lst_test = status.tests_summary(dct)
casse = [x for x in lst_test if x.get("status")]
return (
f"{info['database']} · {info['file']}\n"
f"{t('current step')} : {info['step']}\n"
f"{t('last written')} : {info['updated']}"
f" · {len(status.failures(dct))} {t('failed')}"
f" · {len(casse)}/{len(lst_test)} {t('Test results')}"
)
def rows(dct):
"""Les lignes du panneau de gauche : tests d'abord, puis les étapes.
Les tests en tête parce que c'est la question qu'on se pose en ouvrant
cet écran ; les étapes ensuite parce que c'est là qu'on cherche le
détail une fois qu'on sait QUOI chercher.
"""
lst = []
for item in status.tests_summary(dct):
icone, _phrase = status.verdict(item.get("status"))
# Le NUMÉRO de l'étape en tête : une migration lance le même outil
# à chaque palier, et sans lui la liste montrait six lignes
# identiques sans dire laquelle appartenait à quel palier.
numero = (item.get("step") or "").split(" - ")[0]
tete = f"{numero} " if numero else ""
lst.append(
{
"kind": "test",
"label": f"{icone} {tete}{item['name']}",
"detail": str(item.get("runs", 1)),
"data": item,
}
)
for section in status.journal_by_step(dct):
lst.append(
{
"kind": "step",
"label": section["step"],
"detail": str(len(section["lst_cmd"])),
"data": section,
}
)
return lst
def pane_text(dct, row, colour=False, show_log=True):
"""Le détail de la ligne choisie.
Le coloriage passe par de l'ANSI, que Rich sait décoder — et qui rend
au passage le texte LITTÉRAL : une commande contenant « [1] » était
jusqu'ici prise pour du balisage Rich et avalée sans un mot.
"""
if row is None:
return t("Nothing to show yet.")
if row["kind"] == "test":
item = row["data"]
icone, phrase = status.verdict(item.get("status"))
teinte = status.VERDICT_COLOUR.get(item.get("status"), "dim")
lignes = [
f"{icone} {status.paint(item['name'], teinte, colour)}",
f" {status.paint(item.get('step') or '?', 'step', colour)}",
f" {phrase} ({t('exit code')} {item.get('status')})",
f" {t('runs')} : {item.get('runs')}",
f" {item.get('at') or ''}",
]
return "\n".join(lignes)
section = row["data"]
lignes = [status.paint(section["step"], "step", colour), ""]
lst_failure = [
item
for item in status.failures(dct)
if (item.get("step") or "") == section["step"]
]
if lst_failure:
lignes.append(f"❌ {t('Commands that failed')} :")
for item in lst_failure:
lignes.append(
f" {status.paint(item.get('name') or '', 'fail', colour)}"
)
lignes.append("")
for cmd in section["lst_cmd"]:
lignes.append(f"· {status.paint(cmd, 'cmd', colour)}")
# La SORTIE des commandes, relue sur disque. C'est ce qui manquait :
# la liste des commandes dit ce qui a été lancé, jamais ce que cela a
# répondu. Mais les deux mélangés dans un même panneau se confondent —
# d'où « l », qui les sépare.
tail, total = status.step_log_tail(dct, section["step"])
if not show_log:
if total:
lignes.append("")
lignes.append(
f"── {t('server log')} : {total} {t('lines')}"
f" ({t('press l to show')}) ──"
)
return "\n".join(lignes)
if tail:
lignes.append("")
cache = (
f" — {t('last')} {len(tail)} {t('of')} {total}"
if total > len(tail)
else ""
)
lignes.append(f"── {t('server log')}{cache} ──")
lignes.extend(tail)
elif not section["lst_cmd"]:
lignes.append(t("No tool has run yet."))
return "\n".join(lignes)
def build_app(dct, path=None):
"""Textual est importé ICI, pas au chargement du module.
Le module reste importable — donc testable — sur une machine sans
Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le
rapport texte plutôt que d'échouer.
"""
from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
class StatusApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q,escape", "quit", t("Quit")),
("r", "refresh", t("Refresh")),
("l", "toggle_log", t("Logs")),
("p", "toggle_head", t("Panel")),
("plus,equal", "wider", t("Wider")),
("minus,underscore", "narrower", t("Narrower")),
]
LEFT_MIN = 16
LEFT_MAX = 110
LEFT_STEP = 6
def __init__(self, dct, path=None):
super().__init__()
self.dct = dct
self.path = path
self.lst_row = rows(dct)
self.index = 0
self.show_log = True
self.left_width = 46
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Footer()
def on_mount(self):
self.title = t("Migration state")
self._fill_table()
self._show()
def _fill_table(self):
table = self.query_one("#left", DataTable)
table.clear(columns=True)
table.add_columns(t("Test results"), "#")
for row in self.lst_row:
table.add_row(row["label"][:38], row["detail"])
table.styles.width = self.left_width
self.query_one("#head", Static).update(head_text(self.dct))
def _show(self):
row = self.lst_row[self.index] if self.lst_row else None
# `from_ansi` fait DEUX choses : il rend les couleurs, et il
# traite le reste comme du texte LITTÉRAL. Sans lui, une
# commande contenant « [1] » passait pour du balisage Rich et
# disparaissait de l'écran sans que rien ne le signale.
self.query_one("#content", Static).update(
Text.from_ansi(
pane_text(
self.dct, row, colour=True, show_log=self.show_log
)
)
)
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "left" and self.lst_row:
self.index = event.cursor_row
self._show()
def action_refresh(self):
"""Relire le disque. La migration écrit PENDANT qu'on regarde.
L'écran s'ouvre au milieu d'une migration qui continue : sans
cela, il fallait le fermer et le rouvrir pour voir le palier
suivant.
"""
if not self.path:
return
self.dct = status.read(self.path)
self.lst_row = rows(self.dct)
self.index = min(self.index, max(0, len(self.lst_row) - 1))
self._fill_table()
self._show()
def action_toggle_log(self):
self.show_log = not self.show_log
self._show()
def action_toggle_head(self):
head = self.query_one("#head", Static)
head.display = not head.display
def action_wider(self):
self._resize(self.LEFT_STEP)
def action_narrower(self):
self._resize(-self.LEFT_STEP)
def _resize(self, delta):
# Bornée des deux côtés : une colonne de zéro ne se retrouve
# plus, et une qui mange tout l'écran ne laisse rien à lire.
self.left_width = max(
self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta)
)
self.query_one("#left", DataTable).styles.width = self.left_width
return StatusApp(dct, path)
def run_tui(dct, run_app=True, path=None):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi.
Se taire ferait réafficher le rapport texte à la place de l'écran
demandé, sans rien qui distingue les deux.
"""
if not dct:
return False
if not sys.stdout.isatty():
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
return False
try:
from script.todo import textual_setup
except Exception:
textual_setup = None
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(dct, path=path)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
app.run()
return True