[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()
|