426 lines
20 KiB
Python
426 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import curses
|
|
import textwrap
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
from .assets import banner_text
|
|
from .models import Scenario, TaskDefinition, TaskResult
|
|
from .status import StatusItem
|
|
from .storage import ScenarioStore
|
|
from .summary_utils import SCORE_PREFIXES, clean_text, collect_details
|
|
from .system_info import SystemInfo
|
|
|
|
|
|
@dataclass
|
|
class AppSelection:
|
|
task_keys: list[str]
|
|
scenario_name: str | None = None
|
|
|
|
|
|
class Palette:
|
|
TITLE = 1
|
|
HEADER = 2
|
|
PANEL = 3
|
|
SELECTED = 4
|
|
SUCCESS = 5
|
|
ERROR = 6
|
|
MUTED = 7
|
|
HIGHLIGHT = 8
|
|
CATEGORY = 9
|
|
|
|
|
|
def _setup_colors() -> None:
|
|
if not curses.has_colors():
|
|
return
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
curses.init_pair(Palette.TITLE, curses.COLOR_CYAN, -1)
|
|
curses.init_pair(Palette.HEADER, curses.COLOR_YELLOW, -1)
|
|
curses.init_pair(Palette.PANEL, curses.COLOR_WHITE, curses.COLOR_BLUE)
|
|
curses.init_pair(Palette.SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
curses.init_pair(Palette.SUCCESS, curses.COLOR_GREEN, -1)
|
|
curses.init_pair(Palette.ERROR, curses.COLOR_RED, -1)
|
|
curses.init_pair(Palette.MUTED, curses.COLOR_BLUE, -1)
|
|
curses.init_pair(Palette.HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
|
curses.init_pair(Palette.CATEGORY, curses.COLOR_MAGENTA, -1)
|
|
|
|
|
|
class SecureCheckTUI:
|
|
def __init__(
|
|
self,
|
|
system: SystemInfo,
|
|
tasks: list[TaskDefinition],
|
|
store: ScenarioStore,
|
|
*,
|
|
status_provider: Callable[[], list[StatusItem]],
|
|
initial_selected: set[str] | None = None,
|
|
initial_scenario_name: str | None = None,
|
|
initial_message: str | None = None,
|
|
) -> None:
|
|
self.system = system
|
|
self.tasks = tasks
|
|
self.store = store
|
|
self.status_provider = status_provider
|
|
self.index = 0
|
|
self.message = initial_message or "Sélectionnez les tâches puis lancez avec 'r'."
|
|
self.selected = initial_selected if initial_selected is not None else {task.key for task in tasks if task.default_selected}
|
|
self.scenario_name = initial_scenario_name
|
|
self.banner_lines = banner_text().splitlines()
|
|
self.status_items = self.status_provider()
|
|
|
|
def run(self) -> AppSelection | None:
|
|
return curses.wrapper(self._main)
|
|
|
|
def _main(self, stdscr: curses.window) -> AppSelection | None:
|
|
curses.curs_set(0)
|
|
stdscr.keypad(True)
|
|
_setup_colors()
|
|
|
|
while True:
|
|
self._draw(stdscr)
|
|
key = stdscr.getch()
|
|
|
|
if key in (ord("q"), 27):
|
|
return None
|
|
if key in (curses.KEY_UP, ord("k")):
|
|
self.index = max(0, self.index - 1)
|
|
elif key in (curses.KEY_DOWN, ord("j")):
|
|
self.index = min(len(self.tasks) - 1, self.index + 1)
|
|
elif key == ord(" "):
|
|
current = self.tasks[self.index].key
|
|
if current in self.selected:
|
|
self.selected.remove(current)
|
|
else:
|
|
self.selected.add(current)
|
|
elif key == ord("a"):
|
|
baseline = self.store.get("baseline_workstation")
|
|
if baseline:
|
|
self.selected = set(baseline.task_keys)
|
|
self.scenario_name = baseline.name
|
|
self.message = "Scénario baseline_workstation chargé."
|
|
elif key == ord("s"):
|
|
self._save_current(stdscr)
|
|
elif key == ord("l"):
|
|
self._load_scenario(stdscr)
|
|
elif key == ord("d"):
|
|
self._show_dashboard(stdscr)
|
|
elif key == ord("x"):
|
|
self._delete_scenario(stdscr)
|
|
elif key == ord("r"):
|
|
if not self.selected:
|
|
self.message = "Aucune tâche sélectionnée."
|
|
continue
|
|
return AppSelection(
|
|
task_keys=[task.key for task in self.tasks if task.key in self.selected],
|
|
scenario_name=self.scenario_name,
|
|
)
|
|
elif key == ord("?"):
|
|
self._show_help(stdscr)
|
|
|
|
def _draw(self, stdscr: curses.window) -> None:
|
|
stdscr.erase()
|
|
height, width = stdscr.getmaxyx()
|
|
header_row = 1
|
|
for line in self.banner_lines[: min(6, max(0, height - 8))]:
|
|
stdscr.addnstr(header_row, 2, line, width - 4, curses.color_pair(Palette.TITLE) | curses.A_BOLD)
|
|
header_row += 1
|
|
|
|
scenario_hint = f" | scénario={self.scenario_name}" if self.scenario_name else ""
|
|
header = f"SecureCheck | {self.system.pretty_name} | user={self.system.target_user} | pkg={self.system.package_manager}{scenario_hint}"
|
|
stdscr.addnstr(header_row, 2, header, width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|
|
stdscr.addnstr(
|
|
header_row + 1,
|
|
2,
|
|
"↑↓ naviguer Espace cocher s sauver l charger d état x supprimer r exécuter q quitter",
|
|
width - 4,
|
|
curses.color_pair(Palette.MUTED),
|
|
)
|
|
stdscr.hline(header_row + 2, 1, curses.ACS_HLINE, width - 2)
|
|
|
|
list_top = header_row + 4
|
|
desc_height = 5
|
|
status_width = 40 if width >= 120 else 0
|
|
task_width = width - 4 - status_width - (2 if status_width else 0)
|
|
left_x = 1
|
|
right_x = left_x + task_width + 2 if status_width else 0
|
|
content_height = max(8, height - list_top - desc_height - 2)
|
|
self._draw_box(stdscr, list_top, left_x, content_height, task_width, "Tâches")
|
|
self._draw_task_list(stdscr, list_top + 1, left_x + 1, content_height - 2, task_width - 2)
|
|
if status_width:
|
|
self._draw_box(stdscr, list_top, right_x, content_height, status_width, "Etat")
|
|
self._draw_status_panel(stdscr, list_top + 1, right_x + 1, content_height - 2, status_width - 2)
|
|
|
|
desc_top = list_top + content_height + 1
|
|
self._draw_box(stdscr, desc_top, 1, desc_height, width - 2, "Détail")
|
|
current = self.tasks[self.index]
|
|
count_selected = len(self.selected)
|
|
total_ok = sum(1 for item in self.status_items if item.ok)
|
|
total_ko = len(self.status_items) - total_ok
|
|
summary = f"Sélection: {count_selected}/{len(self.tasks)} | Etat: {total_ok} OK / {total_ko} KO"
|
|
stdscr.addnstr(desc_top + 1, 3, summary, width - 8, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|
|
for offset, line in enumerate(textwrap.wrap(current.description, width - 8)[:2], start=desc_top + 2):
|
|
stdscr.addnstr(offset, 3, line, width - 8)
|
|
stdscr.addnstr(height - 1, 2, self.message, width - 4, curses.color_pair(Palette.MUTED) | curses.A_BOLD)
|
|
stdscr.refresh()
|
|
|
|
def _draw_box(self, stdscr: curses.window, top: int, left: int, height: int, width: int, title: str) -> None:
|
|
stdscr.attron(curses.color_pair(Palette.PANEL))
|
|
stdscr.addch(top, left, curses.ACS_ULCORNER)
|
|
stdscr.hline(top, left + 1, curses.ACS_HLINE, width - 2)
|
|
stdscr.addch(top, left + width - 1, curses.ACS_URCORNER)
|
|
for row in range(top + 1, top + height - 1):
|
|
stdscr.addch(row, left, curses.ACS_VLINE)
|
|
stdscr.addch(row, left + width - 1, curses.ACS_VLINE)
|
|
stdscr.addch(top + height - 1, left, curses.ACS_LLCORNER)
|
|
stdscr.hline(top + height - 1, left + 1, curses.ACS_HLINE, width - 2)
|
|
stdscr.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER)
|
|
stdscr.attroff(curses.color_pair(Palette.PANEL))
|
|
stdscr.addnstr(top, left + 2, f" {title} ", width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|
|
|
|
def _draw_task_list(self, stdscr: curses.window, top: int, left: int, height: int, width: int) -> None:
|
|
window_start = max(0, min(self.index - (height // 2), max(0, len(self.tasks) - height)))
|
|
visible_tasks = self.tasks[window_start : window_start + height]
|
|
for offset in range(height):
|
|
stdscr.addnstr(top + offset, left, " " * width, width)
|
|
for row, task in enumerate(visible_tasks):
|
|
y = top + row
|
|
selected = task.key in self.selected
|
|
current = self.tasks[self.index].key == task.key
|
|
marker = "✓" if selected else " "
|
|
category = f"[{task.category}]"
|
|
base_attr = curses.color_pair(Palette.SELECTED) | curses.A_BOLD if current else curses.A_NORMAL
|
|
stdscr.addnstr(y, left, " " * width, width, base_attr)
|
|
led_attr = curses.color_pair(Palette.SUCCESS if selected else Palette.MUTED) | curses.A_BOLD
|
|
stdscr.addnstr(y, left, "●", 1, led_attr | (curses.A_REVERSE if current else 0))
|
|
stdscr.addnstr(y, left + 2, f"[{marker}]", 3, base_attr | (curses.color_pair(Palette.SUCCESS) if selected else 0))
|
|
stdscr.addnstr(y, left + 6, task.label, max(1, width - 22), base_attr)
|
|
stdscr.addnstr(y, left + width - min(18, len(category) + 1), category, min(18, width - 1), curses.color_pair(Palette.CATEGORY) | (curses.A_BOLD if current else 0))
|
|
|
|
def _draw_status_panel(self, stdscr: curses.window, top: int, left: int, height: int, width: int) -> None:
|
|
self.status_items = self.status_provider()
|
|
items = self.status_items[:height]
|
|
for offset in range(height):
|
|
stdscr.addnstr(top + offset, left, " " * width, width)
|
|
for row, item in enumerate(items):
|
|
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR) | curses.A_BOLD
|
|
stdscr.addnstr(top + row, left, "●", 1, color)
|
|
line = f" {item.label:<16} {item.detail}"
|
|
stdscr.addnstr(top + row, left + 2, line, width - 2)
|
|
|
|
def _prompt(self, stdscr: curses.window, prompt: str) -> str | None:
|
|
height, width = stdscr.getmaxyx()
|
|
curses.echo()
|
|
curses.curs_set(1)
|
|
stdscr.move(height - 1, 0)
|
|
stdscr.clrtoeol()
|
|
stdscr.addnstr(height - 1, 2, prompt, width - 4, curses.color_pair(Palette.HIGHLIGHT) | curses.A_BOLD)
|
|
value = stdscr.getstr(height - 1, min(len(prompt) + 2, width - 2), 60).decode("utf-8").strip()
|
|
curses.noecho()
|
|
curses.curs_set(0)
|
|
return value or None
|
|
|
|
def _pick_scenario(self, stdscr: curses.window, *, deletable_only: bool = False) -> Scenario | None:
|
|
scenarios = self.store.list_all()
|
|
if deletable_only:
|
|
scenarios = [scenario for scenario in scenarios if not scenario.builtin]
|
|
if not scenarios:
|
|
self.message = "Aucun scénario disponible."
|
|
return None
|
|
|
|
index = 0
|
|
while True:
|
|
stdscr.erase()
|
|
_setup_colors()
|
|
height, width = stdscr.getmaxyx()
|
|
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Scénarios")
|
|
stdscr.addnstr(1, 3, "Entrée valider | q retour", width - 6, curses.color_pair(Palette.MUTED))
|
|
for line_no, scenario in enumerate(scenarios[: height - 4], start=2):
|
|
prefix = ">" if line_no - 2 == index else " "
|
|
kind = "builtin" if scenario.builtin else "user"
|
|
attr = curses.color_pair(Palette.SELECTED) | curses.A_BOLD if line_no - 2 == index else curses.A_NORMAL
|
|
stdscr.addnstr(line_no + 1, 3, f"{prefix} {scenario.name} [{kind}] - {scenario.description}", width - 6, attr)
|
|
stdscr.refresh()
|
|
key = stdscr.getch()
|
|
if key in (ord("q"), 27):
|
|
return None
|
|
if key in (curses.KEY_UP, ord("k")):
|
|
index = max(0, index - 1)
|
|
elif key in (curses.KEY_DOWN, ord("j")):
|
|
index = min(len(scenarios) - 1, index + 1)
|
|
elif key in (10, 13, curses.KEY_ENTER):
|
|
return scenarios[index]
|
|
|
|
def _save_current(self, stdscr: curses.window) -> None:
|
|
name = self._prompt(stdscr, "Nom du scénario: ")
|
|
if not name:
|
|
self.message = "Sauvegarde annulée."
|
|
return
|
|
description = self._prompt(stdscr, "Description courte: ") or ""
|
|
self.store.save(Scenario(name=name, description=description, task_keys=sorted(self.selected)))
|
|
self.scenario_name = name
|
|
self.message = f"Scénario '{name}' enregistré."
|
|
|
|
def _load_scenario(self, stdscr: curses.window) -> None:
|
|
scenario = self._pick_scenario(stdscr)
|
|
if not scenario:
|
|
self.message = "Chargement annulé."
|
|
return
|
|
self.selected = set(scenario.task_keys)
|
|
self.scenario_name = scenario.name
|
|
self.message = f"Scénario '{scenario.name}' chargé."
|
|
|
|
def _delete_scenario(self, stdscr: curses.window) -> None:
|
|
scenario = self._pick_scenario(stdscr, deletable_only=True)
|
|
if not scenario:
|
|
self.message = "Suppression annulée."
|
|
return
|
|
if self.store.delete(scenario.name):
|
|
self.message = f"Scénario '{scenario.name}' supprimé."
|
|
else:
|
|
self.message = "Impossible de supprimer le scénario."
|
|
|
|
def _show_help(self, stdscr: curses.window) -> None:
|
|
lines = [
|
|
"SecureCheck - Aide rapide",
|
|
"",
|
|
"Chaque ligne correspond à une action automatisable.",
|
|
"Les scénarios permettent d'enregistrer un lot réutilisable.",
|
|
"Vous pouvez précharger un scénario puis ajuster les cases avant exécution.",
|
|
"La touche d affiche le tableau d'état du système avec indicateurs rouge/vert.",
|
|
"Après exécution, l'application affiche un résumé puis revient au menu.",
|
|
"",
|
|
"Appuyez sur une touche pour revenir.",
|
|
]
|
|
stdscr.erase()
|
|
_setup_colors()
|
|
height, width = stdscr.getmaxyx()
|
|
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Aide")
|
|
for index, line in enumerate(lines[: height - 1]):
|
|
stdscr.addnstr(index + 1, 3, line, width - 6, curses.color_pair(Palette.HEADER) if index == 0 else curses.A_NORMAL)
|
|
stdscr.refresh()
|
|
stdscr.getch()
|
|
|
|
def _show_dashboard(self, stdscr: curses.window) -> None:
|
|
self.status_items = self.status_provider()
|
|
groups: dict[str, list[StatusItem]] = defaultdict(list)
|
|
for item in self.status_items:
|
|
groups[item.category].append(item)
|
|
|
|
_setup_colors()
|
|
|
|
stdscr.erase()
|
|
height, width = stdscr.getmaxyx()
|
|
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Tableau d'état")
|
|
stdscr.addnstr(1, 3, "Vert = OK | Rouge = manquant/inactif | Appuyez sur une touche", width - 6, curses.color_pair(Palette.MUTED))
|
|
row = 3
|
|
for category, items in groups.items():
|
|
if row >= height - 1:
|
|
break
|
|
stdscr.addnstr(row, 3, f"[{category}]", width - 6, curses.color_pair(Palette.CATEGORY) | curses.A_BOLD)
|
|
row += 1
|
|
for item in items:
|
|
if row >= height - 1:
|
|
break
|
|
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR)
|
|
stdscr.addstr(row, 3, "●", color | curses.A_BOLD)
|
|
stdscr.addnstr(row, 5, f"{item.label:<18} {item.detail}", width - 8)
|
|
row += 1
|
|
row += 1
|
|
stdscr.refresh()
|
|
stdscr.getch()
|
|
|
|
|
|
class RunSummaryTUI:
|
|
def __init__(self, results: list[TaskResult], status_items: list[StatusItem], run_log_path: str) -> None:
|
|
self.results = results
|
|
self.status_items = status_items
|
|
self.run_log_path = run_log_path
|
|
self.scroll_offset = 0
|
|
|
|
def run(self) -> None:
|
|
curses.wrapper(self._main)
|
|
|
|
def _main(self, stdscr: curses.window) -> None:
|
|
curses.curs_set(0)
|
|
stdscr.keypad(True)
|
|
_setup_colors()
|
|
while True:
|
|
self._draw(stdscr)
|
|
key = stdscr.getch()
|
|
if key in (ord("q"), 27, 10, 13, ord("m"), ord(" ")):
|
|
return
|
|
if key == curses.KEY_UP and self.scroll_offset > 0:
|
|
self.scroll_offset -= 1
|
|
elif key == curses.KEY_DOWN:
|
|
self.scroll_offset += 1
|
|
|
|
def _draw(self, stdscr: curses.window) -> None:
|
|
stdscr.erase()
|
|
height, width = stdscr.getmaxyx()
|
|
ok_count = sum(1 for result in self.results if result.success)
|
|
ko_count = len(self.results) - ok_count
|
|
score_lines, notif_lines = collect_details(self.results)
|
|
entries: list[tuple[str, int]] = []
|
|
entries.append((f"OK: {ok_count} | ECHEC: {ko_count} | Appuie sur une touche pour revenir", curses.color_pair(Palette.HEADER) | curses.A_BOLD))
|
|
for result in self.results:
|
|
status = "OK" if result.success else "ECHEC"
|
|
color = curses.color_pair(Palette.SUCCESS if result.success else Palette.ERROR)
|
|
entries.append((f"{status:<4} {result.label} ({result.duration_seconds:.1f}s)", color | curses.A_BOLD))
|
|
for detail in result.details:
|
|
clean = clean_text(detail).strip()
|
|
if any(clean.startswith(prefix) for prefix in SCORE_PREFIXES):
|
|
continue
|
|
if clean.startswith("Modifications") or clean.startswith("•"):
|
|
continue
|
|
wrapped = textwrap.wrap(clean, width - 9) or [""]
|
|
for line in wrapped:
|
|
entries.append((f" - {line}", 0))
|
|
if result.error:
|
|
entries.append((f" - {result.error}", curses.color_pair(Palette.ERROR)))
|
|
if score_lines:
|
|
entries.insert(1, ("Lynis", curses.color_pair(Palette.CATEGORY) | curses.A_BOLD))
|
|
for idx, line in enumerate(score_lines, start=2):
|
|
entries.insert(idx, (f" {line}", curses.color_pair(Palette.SUCCESS)))
|
|
entries.append((("", 0)))
|
|
entries.append(("Etat synthétique:", curses.color_pair(Palette.CATEGORY) | curses.A_BOLD))
|
|
for item in self.status_items:
|
|
attr = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR) | curses.A_BOLD
|
|
entries.append((f"● [{item.category}] {item.label}: {item.detail}", attr))
|
|
if notif_lines:
|
|
entries.append((("", 0)))
|
|
entries.append(("Modifications recommandées:", curses.color_pair(Palette.ERROR) | curses.A_BOLD))
|
|
for line in notif_lines:
|
|
clean = self._clean(line)
|
|
bullet = "•" if clean.strip().startswith("•") else "-"
|
|
entries.append((f" {bullet} {clean.lstrip('• ').strip()}", curses.color_pair(Palette.MUTED)))
|
|
entries.append(("", 0))
|
|
entries.append((f"Log: {self.run_log_path}", curses.color_pair(Palette.MUTED)))
|
|
|
|
available = height - 4
|
|
max_offset = max(0, len(entries) - available)
|
|
self.scroll_offset = min(max(self.scroll_offset, 0), max_offset)
|
|
visible = entries[self.scroll_offset : self.scroll_offset + available]
|
|
|
|
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Résumé d'exécution")
|
|
for idx, (line, attr) in enumerate(visible):
|
|
stdscr.addnstr(2 + idx, 3, line, width - 6, attr)
|
|
if max_offset:
|
|
bar_pos = int((self.scroll_offset / max_offset) * (available - 1)) if max_offset else 0
|
|
stdscr.addch(2 + min(bar_pos, available - 1), width - 3, curses.ACS_CKBOARD)
|
|
def _draw_box(self, stdscr: curses.window, top: int, left: int, height: int, width: int, title: str) -> None:
|
|
stdscr.attron(curses.color_pair(Palette.PANEL))
|
|
stdscr.addch(top, left, curses.ACS_ULCORNER)
|
|
stdscr.hline(top, left + 1, curses.ACS_HLINE, width - 2)
|
|
stdscr.addch(top, left + width - 1, curses.ACS_URCORNER)
|
|
for row in range(top + 1, top + height - 1):
|
|
stdscr.addch(row, left, curses.ACS_VLINE)
|
|
stdscr.addch(row, left + width - 1, curses.ACS_VLINE)
|
|
stdscr.addch(top + height - 1, left, curses.ACS_LLCORNER)
|
|
stdscr.hline(top + height - 1, left + 1, curses.ACS_HLINE, width - 2)
|
|
stdscr.addch(top + height - 1, left + width - 1, curses.ACS_LRCORNER)
|
|
stdscr.attroff(curses.color_pair(Palette.PANEL))
|
|
stdscr.addnstr(top, left + 2, f" {title} ", width - 4, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|