Initial commit
This commit is contained in:
401
securecheck/app.py
Normal file
401
securecheck/app.py
Normal file
@@ -0,0 +1,401 @@
|
||||
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 .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
|
||||
|
||||
def run(self) -> None:
|
||||
curses.wrapper(self._main)
|
||||
|
||||
def _main(self, stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
stdscr.timeout(5000)
|
||||
_setup_colors()
|
||||
while True:
|
||||
self._draw(stdscr)
|
||||
key = stdscr.getch()
|
||||
if key == -1 or key in (ord("q"), 27, 10, 13, ord("m"), ord(" ")):
|
||||
return
|
||||
|
||||
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
|
||||
self._draw_box(stdscr, 0, 1, height - 1, width - 2, "Résumé d'exécution")
|
||||
stdscr.addnstr(1, 3, f"OK: {ok_count} | ECHEC: {ko_count} | Retour menu auto dans 5s", width - 6, curses.color_pair(Palette.HEADER) | curses.A_BOLD)
|
||||
row = 3
|
||||
for result in self.results:
|
||||
if row >= height - 6:
|
||||
break
|
||||
color = curses.color_pair(Palette.SUCCESS if result.success else Palette.ERROR)
|
||||
status = "OK" if result.success else "ECHEC"
|
||||
line = f"{status:<5} {result.label} ({result.duration_seconds:.1f}s)"
|
||||
stdscr.addnstr(row, 3, line, width - 6, color | curses.A_BOLD)
|
||||
row += 1
|
||||
for detail in result.details[:2]:
|
||||
if row >= height - 6:
|
||||
break
|
||||
stdscr.addnstr(row, 6, f"- {detail}", width - 9)
|
||||
row += 1
|
||||
if result.error and row < height - 6:
|
||||
stdscr.addnstr(row, 6, f"- {result.error}", width - 9, curses.color_pair(Palette.ERROR))
|
||||
row += 1
|
||||
row += 1
|
||||
stdscr.addnstr(row, 3, "Etat synthétique:", width - 6, curses.color_pair(Palette.CATEGORY) | curses.A_BOLD)
|
||||
row += 1
|
||||
for item in self.status_items[: max(0, height - row - 2)]:
|
||||
color = curses.color_pair(Palette.SUCCESS if item.ok else Palette.ERROR)
|
||||
stdscr.addnstr(row, 3, "●", 1, color | curses.A_BOLD)
|
||||
stdscr.addnstr(row, 5, f"[{item.category}] {item.label}: {item.detail}", width - 8)
|
||||
row += 1
|
||||
stdscr.addnstr(height - 2, 3, f"Log: {self.run_log_path}", width - 6, curses.color_pair(Palette.MUTED))
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user