fix(ui,backend): parallel scan, fix DataTable init, force refresh
All checks were successful
Build and Release .deb / build-deb (push) Successful in 21s

- Use ThreadPoolExecutor for parallel scanning of all targets
- Fix PackageTable and CVETable crash by initializing columns in compose()
- Force widget refresh after scan completion to update counters
This commit is contained in:
enzo 2026-05-13 01:18:08 +02:00
parent e2e504ceff
commit 8f7cb33a9c
4 changed files with 60 additions and 104 deletions

View file

@ -10,7 +10,8 @@ from textual import work
from full_updater.backend.cache import ensure_cache_dir, read_cache, get_cache_timestamp from full_updater.backend.cache import ensure_cache_dir, read_cache, get_cache_timestamp
from full_updater.backend.scanner import ( from full_updater.backend.scanner import (
Target, ScanResult, get_lxc_list, lxc_is_running, Target, ScanResult, get_lxc_list, lxc_is_running,
ensure_debsecan_installed, scan_apt, scan_cve, write_cache ensure_debsecan_installed, scan_apt, scan_cve, write_cache,
run_full_scan
) )
from full_updater.backend.executor import UpgradeExecutor from full_updater.backend.executor import UpgradeExecutor
from full_updater.ui.loader import LoaderScreen from full_updater.ui.loader import LoaderScreen
@ -67,61 +68,19 @@ class FullUpdaterApp(App):
pct = min(100.0, (completed / (total * 4)) * 100) pct = min(100.0, (completed / (total * 4)) * 100)
self.call_from_thread(self._update_loader, pct) self.call_from_thread(self._update_loader, pct)
for idx, target in enumerate(self.targets): def on_result(result: ScanResult):
self.call_from_thread(self._update_loader_status, idx, "running") self.results[result.target.target_id] = result
idx = 0
if not target.is_host and not lxc_is_running(target.target_id): for i, t in enumerate(self.targets):
self.call_from_thread(self._update_loader_status, idx, "skipped") if t.target_id == result.target.target_id:
result = ScanResult(target=target) idx = i
result.status = "skipped" break
self.results[target.target_id] = result
for _ in range(4):
progress_cb()
continue
debsecan_ok, debsecan_err = ensure_debsecan_installed(target.is_host, target.target_id)
progress_cb()
apt_ok, apt_packages, apt_err = scan_apt(target)
progress_cb()
cve_ok = False
cve_list = []
cve_err = ""
if debsecan_ok:
cve_ok, cve_list, cve_err = scan_cve(target)
else:
cve_err = f"debsecan: {debsecan_err}"
progress_cb()
result = ScanResult(
target=target,
apt_ok=apt_ok,
cve_ok=cve_ok,
apt_count=len(apt_packages),
cve_count=len(cve_list),
apt_packages=apt_packages,
cve_list=cve_list,
error=apt_err or cve_err,
status="done" if (apt_ok and (cve_ok or not debsecan_ok)) else "error"
)
if result.error:
result.status = "error"
self.results[target.target_id] = result
cache_id = "host" if target.is_host else target.target_id
write_cache(cache_id, {
"timestamp": datetime.now().isoformat(),
"apt_count": result.apt_count,
"apt_packages": result.apt_packages,
"cve_count": result.cve_count,
"cve_list": result.cve_list,
"error": result.error
})
self.call_from_thread(self._update_loader_status, idx, result.status) self.call_from_thread(self._update_loader_status, idx, result.status)
self.call_from_thread(self._update_sidebar, target.target_id, result) self.call_from_thread(self._update_sidebar, result.target.target_id, result)
results = run_full_scan(progress_cb)
for r in results:
on_result(r)
self.call_from_thread(self._finish_scan) self.call_from_thread(self._finish_scan)
def _update_loader(self, pct: float): def _update_loader(self, pct: float):
@ -157,8 +116,12 @@ class FullUpdaterApp(App):
res.error, res.error,
res.status == "skipped" res.status == "skipped"
) )
# Forcer le rafraichissement de l'interface
sidebar.refresh()
if self.targets: if self.targets:
self._select_target(self.targets[0].target_id) self._select_target(self.targets[0].target_id)
summary = self.query_one(SummaryPanel)
summary.refresh()
def _select_target(self, target_id: str): def _select_target(self, target_id: str):
self.selected_target = target_id self.selected_target = target_id
@ -179,6 +142,7 @@ class FullUpdaterApp(App):
skipped=not data and any(t.target_id == target_id and not t.is_host and not lxc_is_running(t.target_id) for t in self.targets), skipped=not data and any(t.target_id == target_id and not t.is_host and not lxc_is_running(t.target_id) for t in self.targets),
cache_time=get_cache_timestamp(cache_id) cache_time=get_cache_timestamp(cache_id)
) )
summary.refresh()
def on_sidebar_target_selected(self, event: Sidebar.TargetSelected): def on_sidebar_target_selected(self, event: Sidebar.TargetSelected):
self._select_target(event.target_id) self._select_target(event.target_id)

View file

@ -1,7 +1,5 @@
import asyncio import concurrent.futures
import json
import re import re
import shutil
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -65,7 +63,6 @@ def lxc_is_running(vmid: str) -> bool:
def ensure_debsecan_installed(is_host: bool, vmid: str = "") -> tuple[bool, str]: def ensure_debsecan_installed(is_host: bool, vmid: str = "") -> tuple[bool, str]:
"""Retourne (ok, error_msg)""" """Retourne (ok, error_msg)"""
if is_host: if is_host:
# Vérifier si debsecan est installé sur l'hôte
ok, _, _ = run_cmd(["which", "debsecan"]) ok, _, _ = run_cmd(["which", "debsecan"])
if ok: if ok:
return True, "" return True, ""
@ -77,7 +74,6 @@ def ensure_debsecan_installed(is_host: bool, vmid: str = "") -> tuple[bool, str]
return False, f"apt-get install debsecan failed: {err}" return False, f"apt-get install debsecan failed: {err}"
return True, "" return True, ""
else: else:
# Vérifier dans le LXC
ok, _, _ = run_cmd(["pct", "exec", vmid, "--", "which", "debsecan"]) ok, _, _ = run_cmd(["pct", "exec", vmid, "--", "which", "debsecan"])
if ok: if ok:
return True, "" return True, ""
@ -108,12 +104,10 @@ def scan_apt(target: Target) -> tuple[bool, list[dict[str, str]], str]:
if len(parts) < 2: if len(parts) < 2:
continue continue
name = parts[0].strip() name = parts[0].strip()
rest = parts[1] # Essayer d'extraire la nouvelle version et l'ancienne
# Format: apt list --upgradable -> name/arch version suite [upgradable from: oldver]
m = re.search(r"\[upgradable from:\s+([^\]]+)\]", line) m = re.search(r"\[upgradable from:\s+([^\]]+)\]", line)
if m: if m:
old_ver = m.group(1).strip() old_ver = m.group(1).strip()
# Essayer d'extraire la nouvelle version (avant le [upgradable from:])
prefix = line.split("[upgradable from:")[0].strip() prefix = line.split("[upgradable from:")[0].strip()
ver_m = re.search(r"/\S+\s+(\S+)\s+\S+", prefix) ver_m = re.search(r"/\S+\s+(\S+)\s+\S+", prefix)
new_ver = ver_m.group(1) if ver_m else "?" new_ver = ver_m.group(1) if ver_m else "?"
@ -124,7 +118,6 @@ def scan_apt(target: Target) -> tuple[bool, list[dict[str, str]], str]:
"size": "-" "size": "-"
}) })
else: else:
# fallback simple
packages.append({ packages.append({
"name": name, "name": name,
"current": "?", "current": "?",
@ -146,7 +139,6 @@ def scan_cve(target: Target) -> tuple[bool, list[dict[str, str]], str]:
cves = [] cves = []
for line in stdout.splitlines(): for line in stdout.splitlines():
# Format debsecan: CVE-XXXX-XXXX package [remote] [low] - description
m = re.match(r"(CVE-\d{4}-\d+)\s+(\S+)", line) m = re.match(r"(CVE-\d{4}-\d+)\s+(\S+)", line)
if m: if m:
cve_id = m.group(1) cve_id = m.group(1)
@ -159,38 +151,25 @@ def scan_cve(target: Target) -> tuple[bool, list[dict[str, str]], str]:
return True, cves, "" return True, cves, ""
async def scan_target(target: Target, result: ScanResult, progress_cb: Any) -> None: def scan_single_target(target: Target, progress_cb: Any) -> ScanResult:
"""Scanne une cible (APT puis CVE) et écrit le cache.""" """Scanne une cible (APT + CVE) et retourne le résultat."""
result = ScanResult(target=target)
result.status = "running" result.status = "running"
await progress_cb() progress_cb()
# Vérifier si LXC est allumé
if not target.is_host and not lxc_is_running(target.target_id): if not target.is_host and not lxc_is_running(target.target_id):
result.status = "skipped" result.status = "skipped"
result.apt_ok = False return result
result.cve_ok = False
write_cache(target.target_id, {
"timestamp": "",
"apt_count": 0,
"apt_packages": [],
"cve_count": 0,
"cve_list": [],
"error": "LXC éteint"
})
await progress_cb()
return
# Auto-install debsecan
debsecan_ok, debsecan_err = ensure_debsecan_installed(target.is_host, target.target_id) debsecan_ok, debsecan_err = ensure_debsecan_installed(target.is_host, target.target_id)
progress_cb()
# Scan APT
apt_ok, apt_packages, apt_err = scan_apt(target) apt_ok, apt_packages, apt_err = scan_apt(target)
result.apt_ok = apt_ok result.apt_ok = apt_ok
result.apt_count = len(apt_packages) result.apt_count = len(apt_packages)
result.apt_packages = apt_packages result.apt_packages = apt_packages
await progress_cb() progress_cb()
# Scan CVE
if debsecan_ok: if debsecan_ok:
cve_ok, cve_list, cve_err = scan_cve(target) cve_ok, cve_list, cve_err = scan_cve(target)
result.cve_ok = cve_ok result.cve_ok = cve_ok
@ -200,29 +179,48 @@ async def scan_target(target: Target, result: ScanResult, progress_cb: Any) -> N
result.error = cve_err result.error = cve_err
else: else:
result.cve_ok = False result.cve_ok = False
result.cve_count = 0
result.cve_list = []
result.error = f"debsecan: {debsecan_err}" result.error = f"debsecan: {debsecan_err}"
await progress_cb() progress_cb()
result.status = "done" if (result.apt_ok and result.cve_ok) else "error" result.status = "done" if (result.apt_ok and result.cve_ok) else "error"
if result.error: if result.error:
result.status = "error" result.status = "error"
write_cache(target.target_id if not target.is_host else "host", { cache_id = "host" if target.is_host else target.target_id
"timestamp": "", # sera rempli par l'appelant from datetime import datetime
write_cache(cache_id, {
"timestamp": datetime.now().isoformat(),
"apt_count": result.apt_count, "apt_count": result.apt_count,
"apt_packages": result.apt_packages, "apt_packages": result.apt_packages,
"cve_count": result.cve_count, "cve_count": result.cve_count,
"cve_list": result.cve_list, "cve_list": result.cve_list,
"error": result.error "error": result.error
}) })
await progress_cb() progress_cb()
return result
async def run_full_scan(progress_cb: Any) -> list[ScanResult]: def run_full_scan(progress_cb: Any) -> list[ScanResult]:
targets = [Target(target_id="host", name="hote", is_host=True)] + get_lxc_list() targets = [Target(target_id="host", name="hote", is_host=True)] + get_lxc_list()
results = [ScanResult(target=t) for t in targets] results = []
tasks = [scan_target(r.target, r, progress_cb) for r in results] completed = 0
await asyncio.gather(*tasks) total = len(targets)
def _progress():
nonlocal completed
completed += 1
pct = min(100.0, (completed / (total * 4)) * 100)
progress_cb(pct)
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(scan_single_target, t, _progress): t for t in targets}
for future in concurrent.futures.as_completed(futures):
try:
result = future.result()
results.append(result)
except Exception as e:
target = futures[future]
result = ScanResult(target=target, status="error", error=str(e))
results.append(result)
return results return results

View file

@ -28,18 +28,15 @@ class CVETable(Vertical):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.table = None self.table = DataTable(id="cve-table")
self.urls = {} self.urls = {}
def compose(self): def compose(self):
with Horizontal(id="cve-toolbar"): with Horizontal(id="cve-toolbar"):
yield Button("⬅ Retour", id="cve-back") yield Button("⬅ Retour", id="cve-back")
self.table = DataTable(id="cve-table")
yield self.table
def on_mount(self):
self.table.add_columns("CVE-ID", "Paquet", "Lien") self.table.add_columns("CVE-ID", "Paquet", "Lien")
self.table.cursor_type = "row" self.table.cursor_type = "row"
yield self.table
def load_data(self, cves: list[dict]): def load_data(self, cves: list[dict]):
self.table.clear() self.table.clear()

View file

@ -22,16 +22,13 @@ class PackageTable(Vertical):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.table = None self.table = DataTable(id="pkg-table")
def compose(self): def compose(self):
with Horizontal(id="pkg-toolbar"): with Horizontal(id="pkg-toolbar"):
yield Button("⬅ Retour", id="pkg-back") yield Button("⬅ Retour", id="pkg-back")
self.table = DataTable(id="pkg-table")
yield self.table
def on_mount(self):
self.table.add_columns("Nom", "Version actuelle", "Nouvelle version", "Taille") self.table.add_columns("Nom", "Version actuelle", "Nouvelle version", "Taille")
yield self.table
def load_data(self, packages: list[dict]): def load_data(self, packages: list[dict]):
self.table.clear() self.table.clear()