Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client est dans un zip, derrière une URL, ou vivante sur un serveur. « Restant de migration » a dû être écrit : check_migration_quality compare les bases de PALIER et exige le journal de progression — devant une sauvegarde isolée, ni l'un ni l'autre n'existe. Ses compteurs évidents ont été écartés après mesure. Comparés à la base d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158. Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne restent que les constats faux en eux-mêmes, 0 avant, non nuls après — 9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés. Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ; une session RPC n'a rien d'équivalent, et la liste blanche est donc appliquée dans le passe-plat, pas chez l'appelant. --- EN --- The analyses existed; the path BEFORE them did not. A customer database sits in a zip, behind a URL, or live on a server. « Migration leftovers » had to be written: check_migration_quality compares STEP databases and needs the progression log — facing a lone backup, neither exists. Its obvious counters were dropped after measuring. Against the ORIGINAL database: fields with no column 25 → 72, models with no table 90 → 158. Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in itself remains, 0 before and non-zero after — 9 languages with a NULL flag, 68 missing m2m tables, 414 duplicated indexes. The RPC proxy only reads. psql gets that from the server; an RPC session has no equivalent, so the allowlist lives in the proxy, not the caller. Assisted-by: Claude Opus 5
191 lines
6.3 KiB
Python
191 lines
6.3 KiB
Python
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
||
"""Demander quelle auscultation lancer — et rien d'autre.
|
||
|
||
Cet écran ne CONTIENT aucune analyse. Il en choisit une, se referme, et
|
||
rend la clé choisie ; l'appelant lance ensuite l'outil, qui dispose alors
|
||
du terminal entier et peut ouvrir sa propre TUI.
|
||
|
||
Deux raisons, toutes deux mesurées dans ce dépôt. `run_tui` refuse de
|
||
s'ouvrir quand une boucle asyncio tourne déjà — un écran qui en ouvrirait
|
||
un autre afficherait « open it in its own process instead » et rien
|
||
d'autre. Et une analyse lourde appelée depuis un gestionnaire de touche
|
||
bloque la boucle d'événements : plus de rafraîchissement, plus de frappe,
|
||
un écran qui paraît planté pendant des minutes.
|
||
|
||
Les analyses que la provenance ne permet pas restent AFFICHÉES, grisées,
|
||
avec la raison. Les retirer laisserait croire qu'elles n'existent pas ;
|
||
les proposer les ferait échouer à l'ouverture.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.path.append(
|
||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||
)
|
||
|
||
from script.analyse import monitoring # 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: 3; padding: 0 1; background: $panel; color: $text; }
|
||
#body { height: 1fr; }
|
||
#left { width: 52; border-right: solid $accent; }
|
||
#pane { width: 1fr; padding: 0 1; }
|
||
.off { color: $text-disabled; }
|
||
"""
|
||
|
||
|
||
def rows(kind):
|
||
"""Les lignes de l'écran : (clé, étiquette, utilisable, raison).
|
||
|
||
Fonction pure, et c'est délibéré — c'est elle qui décide ce qu'on peut
|
||
lancer, et une décision qui ne se teste qu'en ouvrant un terminal ne
|
||
se teste pas.
|
||
"""
|
||
lignes = []
|
||
for analyse in monitoring.ANALYSES:
|
||
utilisable = kind in analyse["kinds"]
|
||
lignes.append(
|
||
(
|
||
analyse["key"],
|
||
t(analyse["title"]),
|
||
utilisable,
|
||
"" if utilisable else t(analyse["needs_sql"]),
|
||
)
|
||
)
|
||
return lignes
|
||
|
||
|
||
def detail(key, kind):
|
||
"""Le texte du panneau de droite pour cette analyse."""
|
||
analyse = monitoring.analysis_by_key(key)
|
||
if not analyse:
|
||
return ""
|
||
morceaux = [t(analyse["title"]), "", t(analyse["why"]), ""]
|
||
if kind in analyse["kinds"]:
|
||
morceaux.append(f"▶ {t('Enter to run it.')}")
|
||
else:
|
||
morceaux.append(f"✖ {t('Not available for this source.')}")
|
||
morceaux.append(f" {t(analyse['needs_sql'])}")
|
||
morceaux.append("")
|
||
morceaux.append(f" {analyse['script']}")
|
||
return "\n".join(morceaux)
|
||
|
||
|
||
def build_app(kind, target):
|
||
"""Construire l'écran. ImportError si Textual manque — l'appelant le dit."""
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal
|
||
from textual.widgets import Footer, Header, ListItem, ListView, Static
|
||
|
||
lignes = rows(kind)
|
||
|
||
class MonitoringChooser(App):
|
||
CSS = globals()["CSS"]
|
||
BINDINGS = [
|
||
("enter", "choose", t("Run")),
|
||
("q", "quit", t("Quit")),
|
||
("escape", "quit", t("Quit")),
|
||
]
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
yield Static(monitoring.describe_source(kind, target), id="head")
|
||
with Horizontal(id="body"):
|
||
items = []
|
||
for key, label, utilisable, _ in lignes:
|
||
marque = " " if utilisable else "✖ "
|
||
item = ListItem(Static(f"{marque}{label}"))
|
||
if not utilisable:
|
||
item.add_class("off")
|
||
items.append(item)
|
||
yield ListView(*items, id="left")
|
||
yield Static("", id="pane")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
self.query_one("#left", ListView).focus()
|
||
self._refresh_pane(0)
|
||
|
||
def _refresh_pane(self, index) -> None:
|
||
if 0 <= index < len(lignes):
|
||
self.query_one("#pane", Static).update(
|
||
detail(lignes[index][0], kind)
|
||
)
|
||
|
||
def on_list_view_selected(self, event) -> None:
|
||
# `ListView` consomme Entrée pour émettre `Selected` : la
|
||
# liaison de l'application ne la voit jamais. Sans ceci,
|
||
# l'écran ne répondait pas à Entrée — et aucune fonction pure
|
||
# ne pouvait le montrer.
|
||
self.action_choose()
|
||
|
||
def on_list_view_highlighted(self, event) -> None:
|
||
self._refresh_pane(self.query_one("#left", ListView).index or 0)
|
||
|
||
def action_choose(self) -> None:
|
||
index = self.query_one("#left", ListView).index
|
||
if index is None or not (0 <= index < len(lignes)):
|
||
return
|
||
key, _, utilisable, raison = lignes[index]
|
||
if not utilisable:
|
||
# Refuser en le DISANT : un Entrée sans effet se lit comme
|
||
# un écran figé, et l'on appuie plus fort.
|
||
self.query_one("#pane", Static).update(
|
||
f"✖ {t('Not available for this source.')}\n\n {raison}"
|
||
)
|
||
return
|
||
self.exit(key)
|
||
|
||
return MonitoringChooser()
|
||
|
||
|
||
def in_event_loop():
|
||
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
|
||
import asyncio
|
||
|
||
try:
|
||
asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def run_tui(kind, target, run_app=True):
|
||
"""Ouvrir le choix. Rendre la clé choisie, ou None — en disant pourquoi."""
|
||
try:
|
||
from script.todo import textual_setup
|
||
except Exception: # pragma: no cover - repli si l'aide manque
|
||
textual_setup = None
|
||
if textual_setup and not textual_setup.ensure():
|
||
return None
|
||
try:
|
||
app = build_app(kind, target)
|
||
except ImportError:
|
||
print(
|
||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||
f" {sys.executable}"
|
||
)
|
||
return None
|
||
if not run_app:
|
||
return app
|
||
if in_event_loop():
|
||
print(
|
||
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
|
||
f" {t('process instead.')}"
|
||
)
|
||
return None
|
||
return app.run()
|