erplibre/script/analyse/monitoring_tui.py

192 lines
6.3 KiB
Python
Raw Permalink Normal View History

[ADD] analyse: ausculter une base qui n'est pas ici 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
2026-08-24 07:21:37 -04:00
# © 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,
[ADD] analyse: anonymiser une copie, sans IA et sans rien casser Des mots pris dans une liste, des nombres tirés entre 0 et 1000, écrits en SQL. Aucun modèle, aucun réseau — un test le vérifie sur les imports. Le difficile n'est pas de remplacer, c'est de savoir ce qu'on n'a PAS le droit de toucher. Mesuré sur une base 18 réelle : 505 champs `selection` sont stockés en varchar, 2693 many2one sont des entiers, 194 textes sont des jsonb par langue, 301 contraintes d'unicité attendent une collision. « Tous les champs string » n'existe pas ; on croise ir_model_fields, pg_attribute et pg_constraint, et aucune des trois ne suffit seule. Trois pièges ont été trouvés en LANÇANT l'outil, pas en le relisant : PostgreSQL refuse d'indexer un ARRAY[...] sans parenthèses, res_partner.credit_limit est un jsonb qu'Odoo appelle float, et crm_lead.probability porte un CHECK qui interdit 1000. Chaque fois l'écriture a échoué et la base est restée intacte : une seule transaction, tout ou rien. Preuve sur copie jetable : empreinte du schéma identique, 848 tables, 6495 contraintes, arch_db et xmlid intacts, lang et many2one inchangés — seules les colonnes visées ont changé. --- EN --- Words from a list, numbers drawn between 0 and 1000, written in SQL. No model, no network — a test checks that on the imports. The hard part is not replacing, it is knowing what must NOT be touched. Measured on a real 18 database: 505 `selection` fields are stored as varchar, 2693 many2one are integers, 194 texts are per-language jsonb, 301 unique constraints await a collision. "All string fields" does not exist; we cross ir_model_fields, pg_attribute and pg_constraint, and none of the three is enough alone. Three traps were found by RUNNING it, not by rereading it: PostgreSQL refuses to subscript a bare ARRAY[...], res_partner.credit_limit is a jsonb Odoo calls float, and crm_lead.probability has a CHECK forbidding 1000. Each time the write failed and the database stayed intact: one transaction, all or nothing. Proof on a throwaway copy: identical schema fingerprint, 848 tables, 6495 constraints, arch_db and xmlids intact, lang and many2one unchanged — only the targeted columns changed. Assisted-by: Claude Opus 5
2026-08-25 00:43:50 -04:00
"" if utilisable else t(analyse["why_not"]),
[ADD] analyse: ausculter une base qui n'est pas ici 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
2026-08-24 07:21:37 -04:00
)
)
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.')}")
[ADD] analyse: anonymiser une copie, sans IA et sans rien casser Des mots pris dans une liste, des nombres tirés entre 0 et 1000, écrits en SQL. Aucun modèle, aucun réseau — un test le vérifie sur les imports. Le difficile n'est pas de remplacer, c'est de savoir ce qu'on n'a PAS le droit de toucher. Mesuré sur une base 18 réelle : 505 champs `selection` sont stockés en varchar, 2693 many2one sont des entiers, 194 textes sont des jsonb par langue, 301 contraintes d'unicité attendent une collision. « Tous les champs string » n'existe pas ; on croise ir_model_fields, pg_attribute et pg_constraint, et aucune des trois ne suffit seule. Trois pièges ont été trouvés en LANÇANT l'outil, pas en le relisant : PostgreSQL refuse d'indexer un ARRAY[...] sans parenthèses, res_partner.credit_limit est un jsonb qu'Odoo appelle float, et crm_lead.probability porte un CHECK qui interdit 1000. Chaque fois l'écriture a échoué et la base est restée intacte : une seule transaction, tout ou rien. Preuve sur copie jetable : empreinte du schéma identique, 848 tables, 6495 contraintes, arch_db et xmlid intacts, lang et many2one inchangés — seules les colonnes visées ont changé. --- EN --- Words from a list, numbers drawn between 0 and 1000, written in SQL. No model, no network — a test checks that on the imports. The hard part is not replacing, it is knowing what must NOT be touched. Measured on a real 18 database: 505 `selection` fields are stored as varchar, 2693 many2one are integers, 194 texts are per-language jsonb, 301 unique constraints await a collision. "All string fields" does not exist; we cross ir_model_fields, pg_attribute and pg_constraint, and none of the three is enough alone. Three traps were found by RUNNING it, not by rereading it: PostgreSQL refuses to subscript a bare ARRAY[...], res_partner.credit_limit is a jsonb Odoo calls float, and crm_lead.probability has a CHECK forbidding 1000. Each time the write failed and the database stayed intact: one transaction, all or nothing. Proof on a throwaway copy: identical schema fingerprint, 848 tables, 6495 constraints, arch_db and xmlids intact, lang and many2one unchanged — only the targeted columns changed. Assisted-by: Claude Opus 5
2026-08-25 00:43:50 -04:00
morceaux.append(f" {t(analyse['why_not'])}")
[ADD] analyse: ausculter une base qui n'est pas ici 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
2026-08-24 07:21:37 -04:00
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()