192 lines
6.3 KiB
Python
192 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()
|