2443 lines
109 KiB
Python
2443 lines
109 KiB
Python
from __future__ import annotations
|
||
|
||
import ipaddress
|
||
import shutil
|
||
import subprocess
|
||
import time
|
||
from datetime import datetime
|
||
|
||
from PySide6.QtCore import Qt, QThread, QUrl, Signal, QSize, QSettings, QTimer
|
||
from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QButtonGroup,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QFileDialog,
|
||
QFormLayout,
|
||
QFrame,
|
||
QGroupBox,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QProgressBar,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSplitter,
|
||
QStatusBar,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QToolButton,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from . import __version__
|
||
from .actions import send_magic_packet
|
||
from .comparison import compare_hosts
|
||
from .diagnostics import arp_scan_diagnostic
|
||
from .fastscan import naabu_diagnostic
|
||
from .exporters import export_csv, export_json
|
||
from .identity import identity_key, normalize_mac, shared_macs
|
||
from .intelligence import enrich_host
|
||
from .models import Host, PortInfo
|
||
from .online_vendor import (
|
||
PROVIDER_MACLOOKUP,
|
||
PROVIDER_MACVENDORS,
|
||
PROVIDERS,
|
||
OnlineVendorError,
|
||
OnlineVendorResult,
|
||
is_locally_administered,
|
||
lookup_online_vendor,
|
||
provider_label,
|
||
provider_min_interval,
|
||
)
|
||
from .network import (
|
||
NetworkInterface,
|
||
display_target,
|
||
list_ipv4_interfaces,
|
||
scan_identity_scope,
|
||
target_address_count,
|
||
target_is_on_interface,
|
||
validate_target,
|
||
)
|
||
from .privileges import privilege_diagnostic, privileged_command
|
||
from .scanner import HostScanWorker, ScanRequest, ScanWorker, profile_signature
|
||
from .storage import HistoryStore
|
||
from .ui_layout import balanced_column_widths, compact_warning
|
||
from .ui_icons import device_icon, os_icon, themed_icon
|
||
|
||
|
||
ROLE_IP = Qt.UserRole
|
||
ROLE_KIND = Qt.UserRole + 1
|
||
ROLE_PORT = Qt.UserRole + 2
|
||
KIND_HOST = "host"
|
||
KIND_SERVICE = "service"
|
||
|
||
|
||
class IPTreeWidgetItem(QTreeWidgetItem):
|
||
def __lt__(self, other: QTreeWidgetItem) -> bool:
|
||
tree = self.treeWidget()
|
||
if tree is not None and tree.sortColumn() == 1:
|
||
try:
|
||
return ipaddress.ip_address(self.text(1)) < ipaddress.ip_address(other.text(1))
|
||
except ValueError:
|
||
pass
|
||
return super().__lt__(other)
|
||
|
||
|
||
def _display_timestamp(value: str) -> str:
|
||
if not value:
|
||
return ""
|
||
try:
|
||
return datetime.fromisoformat(value).astimezone().strftime("%d/%m/%Y %H:%M:%S")
|
||
except ValueError:
|
||
return value
|
||
|
||
|
||
def _port_url(host: Host, port: PortInfo | None = None) -> str | None:
|
||
candidates = [port] if port else [p for p in host.ports if p.state == "open"]
|
||
if not candidates:
|
||
return None
|
||
# Priorités adaptées aux interfaces d'administration usuelles.
|
||
priorities = (8006, 8007, 5001, 443, 8443, 5000, 80, 8080)
|
||
ordered = sorted(candidates, key=lambda p: priorities.index(p.port) if p.port in priorities else 999)
|
||
for item in ordered:
|
||
if item is None:
|
||
continue
|
||
if item.port in {443, 8443, 5001, 8006, 8007} or "https" in item.service.casefold():
|
||
suffix = "" if item.port == 443 else f":{item.port}"
|
||
return f"https://{host.ip}{suffix}"
|
||
if item.port in {80, 8080, 5000} or "http" in item.service.casefold():
|
||
suffix = "" if item.port == 80 else f":{item.port}"
|
||
return f"http://{host.ip}{suffix}"
|
||
return None
|
||
|
||
|
||
class PrivilegeAuthWorker(QThread):
|
||
result = Signal(bool, str)
|
||
|
||
def run(self) -> None:
|
||
try:
|
||
proc = subprocess.run(
|
||
privileged_command("authorize"),
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
check=False,
|
||
)
|
||
except (OSError, RuntimeError) as exc:
|
||
self.result.emit(False, str(exc))
|
||
return
|
||
if proc.returncode == 0:
|
||
self.result.emit(True, "Mode administrateur activé via Polkit")
|
||
return
|
||
detail = proc.stderr.strip() or proc.stdout.strip()
|
||
if proc.returncode == 126:
|
||
detail = "Authentification annulée ou refusée."
|
||
elif not detail:
|
||
detail = f"pkexec a quitté avec le code {proc.returncode}."
|
||
self.result.emit(False, detail)
|
||
|
||
|
||
class OnlineVendorLookupWorker(QThread):
|
||
result = Signal(object)
|
||
failed = Signal(str, str) # mac, message
|
||
|
||
def __init__(self, macs: list[str], provider: str, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self.macs = list(dict.fromkeys(macs))
|
||
self.provider = provider
|
||
|
||
def run(self) -> None:
|
||
interval = provider_min_interval(self.provider)
|
||
for index, mac in enumerate(self.macs):
|
||
if self.isInterruptionRequested():
|
||
return
|
||
try:
|
||
result = lookup_online_vendor(mac, self.provider)
|
||
except OnlineVendorError as exc:
|
||
self.failed.emit(mac, str(exc))
|
||
else:
|
||
self.result.emit(result)
|
||
if index + 1 < len(self.macs) and interval > 0:
|
||
time.sleep(interval)
|
||
|
||
|
||
class VendorSettingsDialog(QDialog):
|
||
PROVIDER_LABELS = {
|
||
PROVIDER_MACLOOKUP: "MACLookup.app — gratuit, sans clé",
|
||
PROVIDER_MACVENDORS: "MACVendors.com — gratuit, sans clé",
|
||
}
|
||
|
||
def __init__(self, *, enabled: bool, provider: str, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Identification des constructeurs")
|
||
self.setMinimumWidth(520)
|
||
layout = QVBoxLayout(self)
|
||
|
||
title = QLabel("Recherche constructeur en ligne")
|
||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||
layout.addWidget(title)
|
||
|
||
info = QLabel(
|
||
"LibreNet utilise d'abord les bases OUI locales. Si cette option est activée, "
|
||
"les adresses MAC encore inconnues sont envoyées au fournisseur sélectionné "
|
||
"à la fin du scan. Les réponses sont mises en cache localement pendant 30 jours."
|
||
)
|
||
info.setWordWrap(True)
|
||
layout.addWidget(info)
|
||
|
||
self.enabled_box = QCheckBox("Interroger automatiquement une base en ligne pour les MAC inconnues")
|
||
self.enabled_box.setChecked(enabled)
|
||
layout.addWidget(self.enabled_box)
|
||
|
||
form = QFormLayout()
|
||
self.provider_combo = QComboBox()
|
||
for value in PROVIDERS:
|
||
self.provider_combo.addItem(self.PROVIDER_LABELS[value], value)
|
||
idx = self.provider_combo.findData(provider)
|
||
self.provider_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||
form.addRow("Fournisseur", self.provider_combo)
|
||
layout.addLayout(form)
|
||
|
||
privacy = QLabel(
|
||
"Confidentialité : activer cette fonction transmet l'adresse MAC complète à un service Internet tiers. "
|
||
"L'option est désactivée par défaut. Une MAC localement administrée (LAA) peut rester non identifiable, "
|
||
"même avec une base en ligne."
|
||
)
|
||
privacy.setWordWrap(True)
|
||
privacy.setObjectName("mutedLabel")
|
||
layout.addWidget(privacy)
|
||
|
||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
@property
|
||
def online_enabled(self) -> bool:
|
||
return self.enabled_box.isChecked()
|
||
|
||
@property
|
||
def provider(self) -> str:
|
||
value = self.provider_combo.currentData()
|
||
return str(value) if value in PROVIDERS else PROVIDER_MACLOOKUP
|
||
|
||
|
||
class HistoryDialog(QDialog):
|
||
def __init__(self, store: HistoryStore, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Historique des scans")
|
||
self.resize(840, 440)
|
||
layout = QVBoxLayout(self)
|
||
info = QLabel("LibreNet compare un scan au précédent ayant la même cible, le même profil et le même mode de privilèges.")
|
||
info.setWordWrap(True)
|
||
layout.addWidget(info)
|
||
table = QTableWidget(0, 5, self)
|
||
table.setHorizontalHeaderLabels(["Date", "Cible", "Profil", "Hôtes", "ID"])
|
||
rows = store.recent_scans()
|
||
table.setRowCount(len(rows))
|
||
for row_idx, row in enumerate(rows):
|
||
values = [_display_timestamp(row["created_at"]), row["target"], row["profile"], str(row["host_count"]), str(row["id"])]
|
||
for col, value in enumerate(values):
|
||
table.setItem(row_idx, col, QTableWidgetItem(value))
|
||
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
||
table.horizontalHeader().setStretchLastSection(True)
|
||
layout.addWidget(table)
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.setWindowTitle(f"LibreNet Scanner {__version__}")
|
||
self.resize(1540, 860)
|
||
self.setMinimumSize(1050, 650)
|
||
self.hosts: dict[str, Host] = {}
|
||
self.interfaces: list[NetworkInterface] = []
|
||
self.worker: ScanWorker | None = None
|
||
self.host_worker: HostScanWorker | None = None
|
||
self.store = HistoryStore()
|
||
self.baseline_hosts: list[Host] | None = None
|
||
self.baseline_date = ""
|
||
self.scan_had_error = False
|
||
self._routed_scan = False
|
||
self.scan_warnings: list[str] = []
|
||
self.admin_mode = False
|
||
self.auth_worker: PrivilegeAuthWorker | None = None
|
||
self._pending_scan_after_auth = False
|
||
self._pending_profile = "Standard"
|
||
self.current_scan_privileged = False
|
||
self.current_identity_scope = ""
|
||
self.scan_started_at: datetime | None = None
|
||
self._metadata_cache: dict[str, dict[str, object]] = {}
|
||
self.settings = QSettings("LibreNet", "LibreNet Scanner")
|
||
self._restoring_layout = True
|
||
self._columns_user_customized = False
|
||
self.vendor_lookup_worker: OnlineVendorLookupWorker | None = None
|
||
self._vendor_lookup_manual = False
|
||
|
||
self._build_ui()
|
||
self._build_menu()
|
||
self.refresh_interfaces()
|
||
self._update_detail_panel(None)
|
||
QTimer.singleShot(0, self._restore_ui_layout)
|
||
|
||
# ---------- Construction de l'interface ----------
|
||
def _build_ui(self) -> None:
|
||
central = QWidget(self)
|
||
central.setObjectName("appRoot")
|
||
outer = QVBoxLayout(central)
|
||
outer.setContentsMargins(14, 12, 14, 10)
|
||
outer.setSpacing(10)
|
||
|
||
def section_title(text: str) -> QLabel:
|
||
label = QLabel(text.upper())
|
||
label.setObjectName("sectionTitle")
|
||
return label
|
||
|
||
def separator() -> QFrame:
|
||
line = QFrame()
|
||
line.setFrameShape(QFrame.Shape.HLine)
|
||
line.setObjectName("separator")
|
||
return line
|
||
|
||
# Une feuille de style volontairement légère : elle s'appuie sur la palette
|
||
# Qt/KDE afin de rester cohérente avec Breeze clair ou sombre.
|
||
self.setStyleSheet(
|
||
"""
|
||
QWidget#appRoot {
|
||
background: palette(window);
|
||
}
|
||
QFrame#scanCard, QFrame#detailPane {
|
||
background: palette(base);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 10px;
|
||
}
|
||
QLabel#fieldLabel, QLabel#mutedLabel {
|
||
color: palette(mid);
|
||
}
|
||
QLabel#fieldLabel {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel#sectionTitle {
|
||
color: palette(mid);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
QLabel#detailName {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
QLabel#detailType {
|
||
color: palette(mid);
|
||
}
|
||
QLabel#valueLabel {
|
||
font-weight: 500;
|
||
}
|
||
QFrame#separator {
|
||
color: palette(midlight);
|
||
background: palette(midlight);
|
||
max-height: 1px;
|
||
}
|
||
QLineEdit, QPlainTextEdit {
|
||
background: palette(base);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 7px;
|
||
padding: 6px 8px;
|
||
selection-background-color: palette(highlight);
|
||
selection-color: palette(highlighted-text);
|
||
}
|
||
QLineEdit:focus, QPlainTextEdit:focus {
|
||
border: 1px solid palette(highlight);
|
||
}
|
||
QPushButton#primaryScan {
|
||
background: palette(highlight);
|
||
color: palette(highlighted-text);
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 8px 16px;
|
||
font-weight: 700;
|
||
}
|
||
QPushButton#primaryScan:disabled {
|
||
background: palette(midlight);
|
||
color: palette(mid);
|
||
}
|
||
QPushButton#stopButton, QToolButton#adminChip, QToolButton#filterChip,
|
||
QToolButton#scanMenuButton, QPushButton#secondaryAction, QToolButton#moreAction, QPushButton#favoriteButton {
|
||
background: palette(button);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 7px;
|
||
padding: 6px 10px;
|
||
}
|
||
QToolButton#adminChip, QToolButton#filterChip {
|
||
padding: 5px 10px;
|
||
}
|
||
QToolButton#scanMenuButton {
|
||
padding: 0px;
|
||
min-width: 36px;
|
||
max-width: 36px;
|
||
}
|
||
QToolButton#scanMenuButton::menu-indicator {
|
||
image: none;
|
||
width: 0px;
|
||
height: 0px;
|
||
}
|
||
QFrame#viewSwitch {
|
||
background: palette(button);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 7px;
|
||
}
|
||
QToolButton#segmentButton {
|
||
border: none;
|
||
padding: 6px 10px;
|
||
background: transparent;
|
||
}
|
||
QToolButton#segmentButton:checked {
|
||
background: palette(highlight);
|
||
color: palette(highlighted-text);
|
||
border-radius: 5px;
|
||
}
|
||
QPushButton#primaryAction {
|
||
background: palette(highlight);
|
||
color: palette(highlighted-text);
|
||
border: none;
|
||
border-radius: 7px;
|
||
padding: 7px 12px;
|
||
font-weight: 600;
|
||
}
|
||
QPushButton#secondaryAction:hover, QToolButton#moreAction:hover,
|
||
QPushButton#favoriteButton:hover, QToolButton#adminChip:hover,
|
||
QToolButton#filterChip:hover, QToolButton#scanMenuButton:hover, QPushButton#stopButton:hover {
|
||
border-color: palette(highlight);
|
||
}
|
||
QTreeWidget#deviceTree {
|
||
background: palette(base);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 9px;
|
||
outline: 0;
|
||
alternate-background-color: palette(alternate-base);
|
||
}
|
||
QTreeWidget#deviceTree::item {
|
||
min-height: 34px;
|
||
padding: 4px 6px;
|
||
border: 0;
|
||
}
|
||
QTreeWidget#deviceTree::item:selected {
|
||
background: palette(highlight);
|
||
color: palette(highlighted-text);
|
||
}
|
||
QTreeWidget#serviceList {
|
||
border: 0;
|
||
background: transparent;
|
||
outline: 0;
|
||
}
|
||
QTreeWidget#serviceList::item {
|
||
min-height: 26px;
|
||
padding: 2px 3px;
|
||
}
|
||
QHeaderView::section {
|
||
background: palette(window);
|
||
border: none;
|
||
border-bottom: 1px solid palette(midlight);
|
||
padding: 7px 6px;
|
||
font-weight: 600;
|
||
}
|
||
QFrame#noticeFrame {
|
||
background: palette(alternate-base);
|
||
border: 1px solid palette(midlight);
|
||
border-radius: 7px;
|
||
}
|
||
QLabel#noticeLabel {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
QProgressBar {
|
||
border: 1px solid palette(midlight);
|
||
background: palette(base);
|
||
border-radius: 7px;
|
||
min-height: 16px;
|
||
max-height: 16px;
|
||
text-align: center;
|
||
font-size: 10px;
|
||
}
|
||
QProgressBar::chunk {
|
||
background: palette(highlight);
|
||
border-radius: 6px;
|
||
}
|
||
"""
|
||
)
|
||
|
||
# --- Carte de scan -------------------------------------------------
|
||
scan_card = QFrame()
|
||
scan_card.setObjectName("scanCard")
|
||
scan_layout = QHBoxLayout(scan_card)
|
||
scan_layout.setContentsMargins(12, 10, 12, 10)
|
||
scan_layout.setSpacing(10)
|
||
|
||
target_box = QVBoxLayout()
|
||
target_box.setSpacing(3)
|
||
target_label = QLabel("CIBLE")
|
||
target_label.setObjectName("fieldLabel")
|
||
self.target_edit = QLineEdit()
|
||
self.target_edit.setMinimumHeight(34)
|
||
self.target_edit.setPlaceholderText("192.168.1.0/24 ou 192.168.1.1 - 192.168.1.254")
|
||
target_icon = themed_icon("network-server", "network-wired")
|
||
if not target_icon.isNull():
|
||
self.target_edit.addAction(target_icon, QLineEdit.ActionPosition.LeadingPosition)
|
||
target_box.addWidget(target_label)
|
||
target_box.addWidget(self.target_edit)
|
||
|
||
interface_box = QVBoxLayout()
|
||
interface_box.setSpacing(3)
|
||
interface_label = QLabel("INTERFACE RÉSEAU")
|
||
interface_label.setObjectName("fieldLabel")
|
||
self.interface_combo = QComboBox()
|
||
self.interface_combo.setMinimumWidth(320)
|
||
self.interface_combo.setMinimumHeight(34)
|
||
self.interface_combo.currentIndexChanged.connect(self._interface_changed)
|
||
interface_box.addWidget(interface_label)
|
||
interface_box.addWidget(self.interface_combo)
|
||
|
||
self.scan_btn = QPushButton("Scanner")
|
||
self.scan_btn.setObjectName("primaryScan")
|
||
self.scan_btn.setIcon(themed_icon("media-playback-start"))
|
||
self.scan_btn.setIconSize(QSize(18, 18))
|
||
self.scan_btn.setFixedHeight(36)
|
||
self.scan_btn.setMinimumWidth(118)
|
||
self.scan_btn.clicked.connect(lambda: self.start_scan(profile="Standard"))
|
||
|
||
# Bouton de menu volontairement indépendant du QMenu : sous Breeze,
|
||
# associer setArrowType() + setMenu() fait dessiner deux indicateurs.
|
||
# Ici Qt ne dessine qu'un seul chevron et nous ouvrons le menu nous-mêmes.
|
||
self.scan_menu_btn = QToolButton()
|
||
self.scan_menu_btn.setObjectName("scanMenuButton")
|
||
self.scan_menu_btn.setArrowType(Qt.DownArrow)
|
||
self.scan_menu_btn.setToolTip("Choisir un autre type de scan")
|
||
self.scan_menu_btn.setFixedSize(36, 36)
|
||
self.scan_menu = QMenu(self)
|
||
standard = self.scan_menu.addAction(themed_icon("media-playback-start"), "Scan standard")
|
||
standard.triggered.connect(lambda: self.start_scan(profile="Standard"))
|
||
quick = self.scan_menu.addAction(themed_icon("system-run"), "Scan rapide — découverte uniquement")
|
||
quick.triggered.connect(lambda: self.start_scan(profile="Rapide"))
|
||
deep = self.scan_menu.addAction(themed_icon("system-search"), "Scan approfondi — services + OS")
|
||
deep.triggered.connect(lambda: self.start_scan(profile="Approfondi"))
|
||
self.scan_menu_btn.clicked.connect(self._show_scan_menu)
|
||
|
||
self.stop_btn = QPushButton("Arrêter")
|
||
self.stop_btn.setObjectName("stopButton")
|
||
self.stop_btn.setIcon(themed_icon("process-stop"))
|
||
self.stop_btn.setFixedHeight(36)
|
||
self.stop_btn.setEnabled(False)
|
||
self.stop_btn.clicked.connect(self.stop_scan)
|
||
|
||
self.admin_btn = QToolButton()
|
||
self.admin_btn.setObjectName("adminChip")
|
||
self.admin_btn.setText("Standard")
|
||
self.admin_btn.setIcon(themed_icon("object-locked"))
|
||
self.admin_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.admin_btn.setFixedHeight(36)
|
||
self.admin_btn.clicked.connect(self.toggle_admin_mode)
|
||
|
||
action_box = QVBoxLayout()
|
||
action_box.setSpacing(5)
|
||
action_label = QLabel("ACTIONS")
|
||
action_label.setObjectName("fieldLabel")
|
||
action_row = QHBoxLayout()
|
||
action_row.setSpacing(5)
|
||
action_row.addWidget(self.scan_btn)
|
||
action_row.addWidget(self.scan_menu_btn)
|
||
action_row.addWidget(self.stop_btn)
|
||
action_row.addWidget(self.admin_btn)
|
||
action_box.addWidget(action_label)
|
||
action_box.addLayout(action_row)
|
||
|
||
scan_layout.addLayout(target_box, 3)
|
||
scan_layout.addLayout(interface_box, 2)
|
||
scan_layout.addLayout(action_box)
|
||
outer.addWidget(scan_card)
|
||
|
||
# --- Recherche / filtres ------------------------------------------
|
||
filter_bar = QHBoxLayout()
|
||
filter_bar.setSpacing(8)
|
||
self.filter_edit = QLineEdit()
|
||
self.filter_edit.setClearButtonEnabled(True)
|
||
self.filter_edit.setMinimumHeight(34)
|
||
self.filter_edit.setPlaceholderText("Rechercher un nom, une IP, un service, une note…")
|
||
search_icon = themed_icon("edit-find")
|
||
if not search_icon.isNull():
|
||
self.filter_edit.addAction(search_icon, QLineEdit.ActionPosition.LeadingPosition)
|
||
self.filter_edit.textChanged.connect(self._apply_filters)
|
||
|
||
self.view_filter = QToolButton()
|
||
self.view_filter.setObjectName("filterChip")
|
||
self.view_filter.setText("Tous")
|
||
self.view_filter.setIcon(themed_icon("view-filter"))
|
||
self.view_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.view_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||
self.view_filter.setMinimumHeight(34)
|
||
self._view_filter_value = "Tous"
|
||
view_menu = QMenu(self.view_filter)
|
||
self._view_filter_actions = []
|
||
filter_icons = {
|
||
"Tous": "view-list-details",
|
||
"Actifs": "system-run",
|
||
"Favoris": "rating",
|
||
"Changements": "view-refresh",
|
||
"Nouveaux": "list-add",
|
||
"Modifiés": "document-edit",
|
||
"Disparus": "list-remove",
|
||
}
|
||
for label in ("Tous", "Actifs", "Favoris", "Changements", "Nouveaux", "Modifiés", "Disparus"):
|
||
action = view_menu.addAction(themed_icon(filter_icons[label]), label)
|
||
action.setCheckable(True)
|
||
action.setChecked(label == "Tous")
|
||
action.triggered.connect(lambda _checked=False, value=label: self._set_view_filter(value))
|
||
self._view_filter_actions.append(action)
|
||
self.view_filter.setMenu(view_menu)
|
||
|
||
self.group_filter = QToolButton()
|
||
self.group_filter.setObjectName("filterChip")
|
||
self.group_filter.setText("Tous les groupes")
|
||
self.group_filter.setIcon(themed_icon("folder"))
|
||
self.group_filter.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.group_filter.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||
self.group_filter.setMinimumHeight(34)
|
||
self._group_filter_value = "Tous les groupes"
|
||
self.group_filter.setVisible(False)
|
||
|
||
view_switch = QFrame()
|
||
view_switch.setObjectName("viewSwitch")
|
||
view_switch_layout = QHBoxLayout(view_switch)
|
||
view_switch_layout.setContentsMargins(2, 2, 2, 2)
|
||
view_switch_layout.setSpacing(0)
|
||
self.compact_view_btn = QToolButton()
|
||
self.compact_view_btn.setObjectName("segmentButton")
|
||
self.compact_view_btn.setText("Compact")
|
||
self.compact_view_btn.setIcon(themed_icon("view-list-icons", "view-list-details"))
|
||
self.compact_view_btn.setIconSize(QSize(16, 16))
|
||
self.compact_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.compact_view_btn.setCheckable(True)
|
||
self.compact_view_btn.setToolTip("Une ligne par équipement")
|
||
self.detail_view_btn = QToolButton()
|
||
self.detail_view_btn.setObjectName("segmentButton")
|
||
self.detail_view_btn.setText("Détaillé")
|
||
self.detail_view_btn.setIcon(themed_icon("view-list-details", "view-list-icons"))
|
||
self.detail_view_btn.setIconSize(QSize(16, 16))
|
||
self.detail_view_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.detail_view_btn.setCheckable(True)
|
||
self.detail_view_btn.setChecked(True)
|
||
self.detail_view_btn.setToolTip("Afficher les services sous chaque équipement")
|
||
self.view_mode_group = QButtonGroup(self)
|
||
self.view_mode_group.setExclusive(True)
|
||
self.view_mode_group.addButton(self.compact_view_btn)
|
||
self.view_mode_group.addButton(self.detail_view_btn)
|
||
self.compact_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(False))
|
||
self.detail_view_btn.toggled.connect(lambda checked: checked and self._toggle_view_mode(True))
|
||
# Alias conservé pour les chemins existants (double-clic, reconstruction de l'arbre).
|
||
self.view_mode_btn = self.detail_view_btn
|
||
view_switch_layout.addWidget(self.compact_view_btn)
|
||
view_switch_layout.addWidget(self.detail_view_btn)
|
||
|
||
filter_bar.addWidget(self.filter_edit, 1)
|
||
filter_bar.addWidget(self.view_filter)
|
||
filter_bar.addWidget(self.group_filter)
|
||
filter_bar.addWidget(view_switch)
|
||
outer.addLayout(filter_bar)
|
||
|
||
self.notice_frame = QFrame()
|
||
self.notice_frame.setObjectName("noticeFrame")
|
||
notice_layout = QHBoxLayout(self.notice_frame)
|
||
notice_layout.setContentsMargins(10, 6, 8, 6)
|
||
notice_layout.setSpacing(8)
|
||
self.notice_icon = QLabel()
|
||
warning_icon = themed_icon("dialog-warning")
|
||
if not warning_icon.isNull():
|
||
self.notice_icon.setPixmap(warning_icon.pixmap(18, 18))
|
||
self.notice_label = QLabel()
|
||
self.notice_label.setObjectName("noticeLabel")
|
||
self.notice_label.setWordWrap(False)
|
||
self.notice_details_btn = QToolButton()
|
||
self.notice_details_btn.setText("Diagnostic")
|
||
self.notice_details_btn.setIcon(themed_icon("tools-report-bug", "system-search"))
|
||
self.notice_details_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.notice_details_btn.setObjectName("filterChip")
|
||
self.notice_details_btn.clicked.connect(self.show_tools_diagnostic)
|
||
self.notice_close_btn = QToolButton()
|
||
self.notice_close_btn.setIcon(themed_icon("window-close"))
|
||
self.notice_close_btn.setAutoRaise(True)
|
||
self.notice_close_btn.setToolTip("Masquer cet avertissement")
|
||
self.notice_close_btn.clicked.connect(self.notice_frame.hide)
|
||
notice_layout.addWidget(self.notice_icon)
|
||
notice_layout.addWidget(self.notice_label, 1)
|
||
notice_layout.addWidget(self.notice_details_btn)
|
||
notice_layout.addWidget(self.notice_close_btn)
|
||
self.notice_frame.setVisible(False)
|
||
outer.addWidget(self.notice_frame)
|
||
|
||
# --- Zone principale ---------------------------------------------
|
||
splitter = QSplitter(Qt.Horizontal)
|
||
self.main_splitter = splitter
|
||
splitter.setChildrenCollapsible(False)
|
||
splitter.setHandleWidth(5)
|
||
|
||
self.tree = QTreeWidget()
|
||
self.tree.setObjectName("deviceTree")
|
||
self.tree.setColumnCount(4)
|
||
self.tree.setHeaderLabels(["Équipement / service", "IP / port", "Type / version", "Évolution"])
|
||
self.tree.setAlternatingRowColors(True)
|
||
self.tree.setRootIsDecorated(True)
|
||
self.tree.setUniformRowHeights(True)
|
||
self.tree.setIconSize(QSize(26, 26))
|
||
self.tree.setSortingEnabled(True)
|
||
self.tree.sortByColumn(1, Qt.AscendingOrder)
|
||
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.tree.customContextMenuRequested.connect(self._context_menu)
|
||
self.tree.currentItemChanged.connect(lambda current, _previous: self._selection_changed(current))
|
||
self.tree.itemDoubleClicked.connect(self._tree_double_clicked)
|
||
header = self.tree.header()
|
||
# Les deux premières colonnes restent redimensionnables ; Type / version
|
||
# absorbe tout l'espace restant. Cela supprime la grande zone blanche qui
|
||
# apparaissait à droite des colonnes quand Évolution était masquée.
|
||
header.setSectionResizeMode(0, QHeaderView.Interactive)
|
||
header.setSectionResizeMode(1, QHeaderView.Interactive)
|
||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||
header.setSectionResizeMode(3, QHeaderView.Interactive)
|
||
header.setStretchLastSection(False)
|
||
header.setMinimumSectionSize(88)
|
||
header.setSectionsMovable(False)
|
||
header.sectionResized.connect(self._column_resized)
|
||
self.tree.setIndentation(20)
|
||
splitter.addWidget(self.tree)
|
||
|
||
# --- Panneau de détails ------------------------------------------
|
||
detail_pane = QFrame()
|
||
detail_pane.setObjectName("detailPane")
|
||
detail_pane.setMinimumWidth(380)
|
||
detail_pane.setMaximumWidth(540)
|
||
detail_outer = QVBoxLayout(detail_pane)
|
||
detail_outer.setContentsMargins(0, 0, 0, 0)
|
||
|
||
detail_scroll = QScrollArea()
|
||
detail_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
detail_scroll.setWidgetResizable(True)
|
||
detail_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
detail = QWidget()
|
||
detail_layout = QVBoxLayout(detail)
|
||
detail_layout.setContentsMargins(18, 16, 18, 16)
|
||
detail_layout.setSpacing(10)
|
||
|
||
hero = QHBoxLayout()
|
||
hero.setSpacing(12)
|
||
self.detail_icon = QLabel()
|
||
self.detail_icon.setFixedSize(52, 52)
|
||
self.detail_icon.setAlignment(Qt.AlignCenter)
|
||
hero_text = QVBoxLayout()
|
||
hero_text.setSpacing(2)
|
||
self.detail_name = QLabel("Aucun équipement sélectionné")
|
||
self.detail_name.setObjectName("detailName")
|
||
self.detail_name.setWordWrap(True)
|
||
self.detail_type = QLabel("")
|
||
self.detail_type.setObjectName("detailType")
|
||
self.detail_type.setWordWrap(True)
|
||
hero_text.addWidget(self.detail_name)
|
||
hero_text.addWidget(self.detail_type)
|
||
hero.addWidget(self.detail_icon)
|
||
hero.addLayout(hero_text, 1)
|
||
detail_layout.addLayout(hero)
|
||
|
||
# Actions : seules les actions pertinentes seront visibles.
|
||
action_row = QHBoxLayout()
|
||
action_row.setSpacing(6)
|
||
self.web_btn = QPushButton("Ouvrir")
|
||
self.web_btn.setObjectName("primaryAction")
|
||
self.web_btn.setIcon(themed_icon("internet-web-browser"))
|
||
self.web_btn.setIconSize(QSize(17, 17))
|
||
self.ssh_btn = QPushButton("SSH")
|
||
self.ssh_btn.setObjectName("secondaryAction")
|
||
self.ssh_btn.setIcon(themed_icon("utilities-terminal"))
|
||
self.ssh_btn.setIconSize(QSize(17, 17))
|
||
self.smb_btn = QPushButton("Partages")
|
||
self.smb_btn.setObjectName("secondaryAction")
|
||
self.smb_btn.setIcon(themed_icon("folder-network"))
|
||
self.smb_btn.setIconSize(QSize(17, 17))
|
||
self.rdp_btn = QPushButton("RDP")
|
||
self.rdp_btn.setObjectName("secondaryAction")
|
||
self.rdp_btn.setIcon(themed_icon("krdc", "computer"))
|
||
self.rdp_btn.setIconSize(QSize(17, 17))
|
||
self.more_btn = QToolButton()
|
||
self.more_btn.setObjectName("moreAction")
|
||
self.more_btn.setText("Plus")
|
||
self.more_btn.setIcon(themed_icon("overflow-menu", "application-menu"))
|
||
self.more_btn.setIconSize(QSize(17, 17))
|
||
self.more_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||
self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||
for btn in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn):
|
||
action_row.addWidget(btn)
|
||
action_row.addWidget(self.more_btn)
|
||
action_row.addStretch(1)
|
||
self.web_btn.clicked.connect(self._open_selected_web)
|
||
self.ssh_btn.clicked.connect(lambda: self._run_selected_terminal(["ssh"]))
|
||
self.smb_btn.clicked.connect(self._open_selected_smb)
|
||
self.rdp_btn.clicked.connect(self._open_selected_rdp)
|
||
detail_layout.addLayout(action_row)
|
||
|
||
# Widgets gardés comme actions logiques ; leurs commandes sont aussi
|
||
# accessibles depuis le menu "Plus".
|
||
self.hostscan_btn = QPushButton("Scan détaillé")
|
||
self.hostscan_btn.setIcon(themed_icon("system-search"))
|
||
self.hostscan_btn.setVisible(False)
|
||
self.hostscan_btn.clicked.connect(self.scan_selected_host)
|
||
self.wol_btn = QPushButton("Wake-on-LAN")
|
||
self.wol_btn.setIcon(themed_icon("system-run"))
|
||
self.wol_btn.setVisible(False)
|
||
self.wol_btn.clicked.connect(self._wake_selected)
|
||
|
||
detail_layout.addWidget(separator())
|
||
detail_layout.addWidget(section_title("Informations"))
|
||
|
||
info_grid = QGridLayout()
|
||
info_grid.setHorizontalSpacing(12)
|
||
info_grid.setVerticalSpacing(7)
|
||
self.detail_ip = QLabel("—")
|
||
self.detail_mac = QLabel("—")
|
||
self.detail_vendor = QLabel("—")
|
||
self.detail_os = QLabel("—")
|
||
self.detail_os_icon = QLabel()
|
||
self.detail_os_icon.setFixedSize(24, 24)
|
||
self.detail_os_icon.setAlignment(Qt.AlignCenter)
|
||
self.detail_latency = QLabel("—")
|
||
self.detail_seen = QLabel("—")
|
||
info_rows = [
|
||
("Adresse IP", self.detail_ip),
|
||
("Adresse MAC", self.detail_mac),
|
||
("Constructeur", self.detail_vendor),
|
||
("Système", self.detail_os),
|
||
("Latence", self.detail_latency),
|
||
("Dernière vue", self.detail_seen),
|
||
]
|
||
for row, (caption, value) in enumerate(info_rows):
|
||
lab = QLabel(caption)
|
||
lab.setObjectName("mutedLabel")
|
||
value.setObjectName("valueLabel")
|
||
value.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||
value.setWordWrap(True)
|
||
info_grid.addWidget(lab, row, 0, Qt.AlignTop)
|
||
if caption == "Système":
|
||
os_value = QWidget()
|
||
os_value_layout = QHBoxLayout(os_value)
|
||
os_value_layout.setContentsMargins(0, 0, 0, 0)
|
||
os_value_layout.setSpacing(7)
|
||
os_value_layout.addWidget(self.detail_os_icon, 0, Qt.AlignTop)
|
||
os_value_layout.addWidget(value, 1, Qt.AlignTop)
|
||
info_grid.addWidget(os_value, row, 1, Qt.AlignTop)
|
||
else:
|
||
info_grid.addWidget(value, row, 1, Qt.AlignTop)
|
||
info_grid.setColumnStretch(1, 1)
|
||
detail_layout.addLayout(info_grid)
|
||
|
||
vendor_action_row = QHBoxLayout()
|
||
vendor_action_row.addStretch(1)
|
||
self.online_vendor_btn = QPushButton("Rechercher en ligne")
|
||
self.online_vendor_btn.setObjectName("secondaryAction")
|
||
self.online_vendor_btn.setIcon(themed_icon("edit-find"))
|
||
self.online_vendor_btn.setToolTip("Interroger le fournisseur en ligne configuré pour cette adresse MAC")
|
||
self.online_vendor_btn.clicked.connect(self._lookup_selected_vendor_online)
|
||
vendor_action_row.addWidget(self.online_vendor_btn)
|
||
detail_layout.addLayout(vendor_action_row)
|
||
|
||
detail_layout.addWidget(separator())
|
||
detail_layout.addWidget(section_title("Services"))
|
||
self.detail_services_tree = QTreeWidget()
|
||
self.detail_services_tree.setObjectName("serviceList")
|
||
self.detail_services_tree.setColumnCount(2)
|
||
self.detail_services_tree.setHeaderHidden(True)
|
||
self.detail_services_tree.setRootIsDecorated(False)
|
||
self.detail_services_tree.setAlternatingRowColors(False)
|
||
self.detail_services_tree.setIconSize(QSize(18, 18))
|
||
self.detail_services_tree.setMaximumHeight(210)
|
||
self.detail_services_tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||
self.detail_services_tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||
self.detail_services_tree.itemDoubleClicked.connect(self._detail_service_double_clicked)
|
||
self.detail_services = QLabel("Aucun service détecté")
|
||
self.detail_services.setObjectName("mutedLabel")
|
||
self.detail_services.setWordWrap(True)
|
||
detail_layout.addWidget(self.detail_services_tree)
|
||
detail_layout.addWidget(self.detail_services)
|
||
|
||
detail_layout.addWidget(separator())
|
||
meta_header = QHBoxLayout()
|
||
meta_header.addWidget(section_title("Classement local"))
|
||
meta_header.addStretch(1)
|
||
self.favorite_btn = QPushButton("Favori")
|
||
self.favorite_btn.setObjectName("favoriteButton")
|
||
self.favorite_btn.setCheckable(True)
|
||
self.favorite_btn.setIcon(themed_icon("rating"))
|
||
self.favorite_btn.clicked.connect(self._toggle_favorite)
|
||
meta_header.addWidget(self.favorite_btn)
|
||
detail_layout.addLayout(meta_header)
|
||
|
||
group_label = QLabel("Groupe")
|
||
group_label.setObjectName("mutedLabel")
|
||
self.group_edit = QComboBox()
|
||
self.group_edit.setEditable(True)
|
||
self.group_edit.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||
self.group_edit.setPlaceholderText("Infrastructure, Réseau…")
|
||
detail_layout.addWidget(group_label)
|
||
detail_layout.addWidget(self.group_edit)
|
||
|
||
note_label = QLabel("Notes")
|
||
note_label.setObjectName("mutedLabel")
|
||
self.note_edit = QPlainTextEdit()
|
||
self.note_edit.setPlaceholderText("Ajouter une note sur cet équipement…")
|
||
self.note_edit.setMaximumHeight(100)
|
||
self.save_meta_btn = QPushButton("Enregistrer")
|
||
self.save_meta_btn.setObjectName("secondaryAction")
|
||
self.save_meta_btn.setIcon(themed_icon("document-save"))
|
||
self.save_meta_btn.clicked.connect(self._save_selected_metadata)
|
||
detail_layout.addWidget(note_label)
|
||
detail_layout.addWidget(self.note_edit)
|
||
save_row = QHBoxLayout()
|
||
save_row.addStretch(1)
|
||
save_row.addWidget(self.save_meta_btn)
|
||
detail_layout.addLayout(save_row)
|
||
detail_layout.addStretch(1)
|
||
|
||
detail_scroll.setWidget(detail)
|
||
detail_outer.addWidget(detail_scroll)
|
||
splitter.addWidget(detail_pane)
|
||
splitter.setStretchFactor(0, 7)
|
||
splitter.setStretchFactor(1, 3)
|
||
splitter.setSizes([1080, 420])
|
||
outer.addWidget(splitter, 1)
|
||
|
||
# --- Barre d'état synthétique ------------------------------------
|
||
bottom = QHBoxLayout()
|
||
bottom.setSpacing(10)
|
||
self.summary_label = QLabel("0 appareil")
|
||
self.summary_label.setStyleSheet("font-weight: 600;")
|
||
self.activity_label = QLabel("Prêt")
|
||
self.activity_label.setObjectName("mutedLabel")
|
||
self.progress = QProgressBar()
|
||
self.progress.setMinimumWidth(240)
|
||
self.progress.setMaximumWidth(340)
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(0)
|
||
self.progress.setFormat("%p%")
|
||
self.progress.setTextVisible(True)
|
||
self.progress.setVisible(False)
|
||
bottom.addWidget(self.summary_label)
|
||
bottom.addStretch(1)
|
||
bottom.addWidget(self.activity_label)
|
||
bottom.addWidget(self.progress)
|
||
outer.addLayout(bottom)
|
||
|
||
self.setCentralWidget(central)
|
||
self.setStatusBar(QStatusBar())
|
||
self._reload_group_choices()
|
||
|
||
|
||
# ---------- Disposition UI ----------
|
||
def _restore_ui_layout(self) -> None:
|
||
self._restoring_layout = True
|
||
try:
|
||
splitter_sizes = self.settings.value("ui/mainSplitter")
|
||
if isinstance(splitter_sizes, (list, tuple)) and len(splitter_sizes) == 2:
|
||
try:
|
||
values = [int(v) for v in splitter_sizes]
|
||
except (TypeError, ValueError):
|
||
values = []
|
||
if values and all(v > 0 for v in values):
|
||
self.main_splitter.setSizes(values)
|
||
|
||
saved = self.settings.value("ui/columnWidths")
|
||
widths: list[int] = []
|
||
if isinstance(saved, (list, tuple)) and len(saved) == 4:
|
||
try:
|
||
widths = [int(v) for v in saved]
|
||
except (TypeError, ValueError):
|
||
widths = []
|
||
if widths and all(v >= 80 for v in widths):
|
||
# Type / version (colonne 2) est volontairement extensible : on ne
|
||
# restaure pas une ancienne largeur fixe qui recréerait du vide.
|
||
for column, width in enumerate(widths):
|
||
if column == 2:
|
||
continue
|
||
self.tree.header().resizeSection(column, width)
|
||
self._columns_user_customized = True
|
||
else:
|
||
self._columns_user_customized = False
|
||
self._apply_balanced_columns(force=True)
|
||
finally:
|
||
self._restoring_layout = False
|
||
|
||
def _save_ui_layout(self) -> None:
|
||
if not hasattr(self, "tree"):
|
||
return
|
||
header = self.tree.header()
|
||
self.settings.setValue("ui/columnWidths", [header.sectionSize(i) for i in range(self.tree.columnCount())])
|
||
if hasattr(self, "main_splitter"):
|
||
self.settings.setValue("ui/mainSplitter", self.main_splitter.sizes())
|
||
|
||
def _column_resized(self, logical: int, _old_size: int, _new_size: int) -> None:
|
||
if self._restoring_layout or logical == 2:
|
||
return
|
||
self._columns_user_customized = True
|
||
self._save_ui_layout()
|
||
|
||
def _apply_balanced_columns(self, *, force: bool = False) -> None:
|
||
if self._columns_user_customized and not force:
|
||
return
|
||
viewport = max(720, self.tree.viewport().width())
|
||
show_evolution = not self.tree.isColumnHidden(3)
|
||
widths = balanced_column_widths(viewport, show_evolution=show_evolution)
|
||
previous = self._restoring_layout
|
||
self._restoring_layout = True
|
||
try:
|
||
header = self.tree.header()
|
||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||
for column, width in enumerate(widths):
|
||
if column == 2:
|
||
continue
|
||
if column == 3 and not show_evolution:
|
||
continue
|
||
header.resizeSection(column, width)
|
||
finally:
|
||
self._restoring_layout = previous
|
||
|
||
def _update_evolution_visibility(self) -> None:
|
||
has_changes = any(
|
||
host.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"}
|
||
for host in self.hosts.values()
|
||
)
|
||
was_hidden = self.tree.isColumnHidden(3)
|
||
self.tree.setColumnHidden(3, not has_changes)
|
||
# La dernière colonne visible doit toujours occuper l'espace utile : Type /
|
||
# version reste donc en mode Stretch, avec ou sans colonne Évolution.
|
||
self.tree.header().setSectionResizeMode(2, QHeaderView.Stretch)
|
||
if was_hidden != (not has_changes) and not self._columns_user_customized:
|
||
self._apply_balanced_columns(force=True)
|
||
|
||
def reset_ui_layout(self) -> None:
|
||
self.settings.remove("ui/columnWidths")
|
||
self.settings.remove("ui/mainSplitter")
|
||
self._columns_user_customized = False
|
||
self.main_splitter.setSizes([1080, 420])
|
||
self._apply_balanced_columns(force=True)
|
||
self.statusBar().showMessage("Disposition réinitialisée", 3000)
|
||
|
||
def closeEvent(self, event) -> None:
|
||
self._save_ui_layout()
|
||
super().closeEvent(event)
|
||
|
||
def _show_scan_menu(self) -> None:
|
||
"""Affiche le menu des profils sous son bouton, sans menu-indicator Qt additionnel."""
|
||
pos = self.scan_menu_btn.mapToGlobal(self.scan_menu_btn.rect().bottomLeft())
|
||
self.scan_menu.popup(pos)
|
||
|
||
def _build_menu(self) -> None:
|
||
file_menu = self.menuBar().addMenu("Fichier")
|
||
export_csv_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter CSV…", self)
|
||
export_csv_action.triggered.connect(self.export_csv_dialog)
|
||
export_json_action = QAction(themed_icon("document-export", "document-save-as"), "Exporter JSON…", self)
|
||
export_json_action.triggered.connect(self.export_json_dialog)
|
||
quit_action = QAction(themed_icon("application-exit"), "Quitter", self)
|
||
quit_action.triggered.connect(self.close)
|
||
file_menu.addActions([export_csv_action, export_json_action])
|
||
file_menu.addSeparator()
|
||
file_menu.addAction(quit_action)
|
||
|
||
scan_menu = self.menuBar().addMenu("Scan")
|
||
std = QAction(themed_icon("media-playback-start"), "Scanner", self)
|
||
std.setShortcut("F5")
|
||
std.triggered.connect(lambda: self.start_scan(profile="Standard"))
|
||
quick = QAction(themed_icon("system-run"), "Scan rapide", self)
|
||
quick.setShortcut("Ctrl+F5")
|
||
quick.triggered.connect(lambda: self.start_scan(profile="Rapide"))
|
||
deep = QAction(themed_icon("system-search"), "Scan approfondi", self)
|
||
deep.setShortcut("Shift+F5")
|
||
deep.triggered.connect(lambda: self.start_scan(profile="Approfondi"))
|
||
refresh = QAction(themed_icon("view-refresh"), "Rafraîchir les interfaces", self)
|
||
refresh.triggered.connect(self.refresh_interfaces)
|
||
history = QAction(themed_icon("view-history", "document-open-recent"), "Historique…", self)
|
||
history.triggered.connect(self.show_history)
|
||
clear = QAction(themed_icon("edit-clear"), "Effacer l’affichage", self)
|
||
clear.setToolTip("Efface uniquement les résultats visibles ; l’historique et les identifications mémorisées sont conservés")
|
||
clear.triggered.connect(self.clear_results)
|
||
clear_history = QAction(themed_icon("edit-delete", "user-trash"), "Effacer l’historique des scans…", self)
|
||
clear_history.triggered.connect(self.clear_scan_history)
|
||
forget_menu = QMenu("Oublier les identifications", self)
|
||
forget_menu.setIcon(themed_icon("edit-delete", "edit-clear-history", "user-trash"))
|
||
forget_selected = QAction(themed_icon("edit-delete"), "Équipement sélectionné…", self)
|
||
forget_selected.triggered.connect(self.forget_selected_identification)
|
||
forget_scope = QAction(themed_icon("network-workgroup", "network-wired"), "Réseau courant…", self)
|
||
forget_scope.triggered.connect(self.forget_current_scope_identifications)
|
||
forget_all = QAction(themed_icon("edit-delete", "user-trash"), "Toutes les identifications…", self)
|
||
forget_all.triggered.connect(self.forget_all_identifications)
|
||
forget_menu.addActions([forget_selected, forget_scope])
|
||
forget_menu.addSeparator()
|
||
forget_menu.addAction(forget_all)
|
||
focus_search = QAction(themed_icon("edit-find"), "Rechercher", self)
|
||
focus_search.setShortcut("Ctrl+F")
|
||
focus_search.triggered.connect(self.filter_edit.setFocus)
|
||
self.addAction(focus_search)
|
||
scan_menu.addActions([std, quick, deep])
|
||
scan_menu.addSeparator()
|
||
scan_menu.addAction(refresh)
|
||
scan_menu.addAction(history)
|
||
scan_menu.addSeparator()
|
||
scan_menu.addAction(clear)
|
||
scan_menu.addAction(clear_history)
|
||
scan_menu.addMenu(forget_menu)
|
||
|
||
view_menu = self.menuBar().addMenu("Affichage")
|
||
compact_view = QAction(themed_icon("view-list-icons", "view-list-details"), "Vue compacte", self)
|
||
compact_view.triggered.connect(lambda: self.compact_view_btn.setChecked(True))
|
||
detailed_view = QAction(themed_icon("view-list-details", "view-list-icons"), "Vue détaillée", self)
|
||
detailed_view.triggered.connect(lambda: self.detail_view_btn.setChecked(True))
|
||
reset_layout = QAction(themed_icon("view-refresh"), "Réinitialiser la disposition", self)
|
||
reset_layout.triggered.connect(self.reset_ui_layout)
|
||
view_menu.addActions([compact_view, detailed_view])
|
||
view_menu.addSeparator()
|
||
view_menu.addAction(reset_layout)
|
||
|
||
settings_menu = self.menuBar().addMenu("Paramètres")
|
||
vendor_settings = QAction(themed_icon("configure", "preferences-system"), "Identification des constructeurs…", self)
|
||
vendor_settings.triggered.connect(self.show_vendor_settings)
|
||
settings_menu.addAction(vendor_settings)
|
||
|
||
help_menu = self.menuBar().addMenu("Aide")
|
||
diagnostics = QAction(themed_icon("tools-report-bug", "system-search"), "Diagnostic des outils…", self)
|
||
diagnostics.triggered.connect(self.show_tools_diagnostic)
|
||
about = QAction(themed_icon("help-about", "help-contents"), "À propos", self)
|
||
about.triggered.connect(self.show_about)
|
||
help_menu.addAction(diagnostics)
|
||
help_menu.addSeparator()
|
||
help_menu.addAction(about)
|
||
|
||
# ---------- Privilèges ----------
|
||
def toggle_admin_mode(self, _checked: bool = False) -> None:
|
||
if self.admin_mode:
|
||
self.admin_mode = False
|
||
self._pending_scan_after_auth = False
|
||
self._update_admin_button()
|
||
self.statusBar().showMessage("Mode administrateur désactivé.", 4000)
|
||
return
|
||
self._request_admin_mode(show_confirmation=True)
|
||
|
||
def _request_admin_mode(self, *, show_confirmation: bool) -> None:
|
||
if self.auth_worker and self.auth_worker.isRunning():
|
||
return
|
||
diag = privilege_diagnostic()
|
||
if not diag.ready:
|
||
self._pending_scan_after_auth = False
|
||
QMessageBox.critical(self, "Mode administrateur indisponible", diag.detail + "\n\nRéinstalle le paquet LibreNet Scanner 1.0.0 si nécessaire.")
|
||
return
|
||
if show_confirmation:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Activer le mode administrateur",
|
||
"LibreNet va demander une authentification via Polkit.\n\n"
|
||
"Seul le helper réseau est élevé ; l'interface graphique reste avec votre utilisateur.\n\nContinuer ?",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.Yes,
|
||
)
|
||
if answer != QMessageBox.Yes:
|
||
self._pending_scan_after_auth = False
|
||
return
|
||
self.admin_btn.setEnabled(False)
|
||
self.admin_btn.setText("Authentification…")
|
||
self.activity_label.setText("Authentification Polkit…")
|
||
self.auth_worker = PrivilegeAuthWorker(self)
|
||
self.auth_worker.result.connect(self._admin_auth_result)
|
||
self.auth_worker.finished.connect(lambda: self.admin_btn.setEnabled(True))
|
||
self.auth_worker.start()
|
||
|
||
def _admin_auth_result(self, granted: bool, detail: str) -> None:
|
||
pending = self._pending_scan_after_auth
|
||
self._pending_scan_after_auth = False
|
||
if granted:
|
||
self.admin_mode = True
|
||
self._update_admin_button()
|
||
self.activity_label.setText("Mode administrateur actif")
|
||
if pending:
|
||
self.start_scan(profile=self._pending_profile, skip_admin_prompt=True)
|
||
return
|
||
self.admin_mode = False
|
||
self._update_admin_button()
|
||
self.activity_label.setText("Mode standard")
|
||
if "annulée" not in detail.casefold() and "refusée" not in detail.casefold():
|
||
QMessageBox.warning(self, "Mode administrateur", detail)
|
||
|
||
def _update_admin_button(self) -> None:
|
||
self.admin_btn.setText("Admin" if self.admin_mode else "Standard")
|
||
self.admin_btn.setIcon(themed_icon("object-unlocked" if self.admin_mode else "object-locked"))
|
||
self.admin_btn.setToolTip(
|
||
"Privilèges réseau actifs via Polkit. La GUI reste exécutée avec votre utilisateur."
|
||
if self.admin_mode else
|
||
"Activer les scans ARP/SYN/OS privilégiés via Polkit."
|
||
)
|
||
|
||
# ---------- Interfaces et cible ----------
|
||
def refresh_interfaces(self) -> None:
|
||
current_name = self.selected_interface().name if self.selected_interface() else ""
|
||
self.interfaces = list_ipv4_interfaces()
|
||
visible = [i for i in self.interfaces if not i.is_virtual] or self.interfaces
|
||
self.interface_combo.blockSignals(True)
|
||
self.interface_combo.clear()
|
||
selected_idx = 0
|
||
for idx, iface in enumerate(visible):
|
||
self.interface_combo.addItem(themed_icon("network-wired"), iface.label, iface)
|
||
if iface.name == current_name:
|
||
selected_idx = idx
|
||
self.interface_combo.blockSignals(False)
|
||
if self.interface_combo.count():
|
||
self.interface_combo.setCurrentIndex(selected_idx)
|
||
self._interface_changed(selected_idx)
|
||
elif not self.target_edit.text():
|
||
self.target_edit.setText("192.168.1.0/24")
|
||
self.statusBar().showMessage(f"{len(visible)} interface(s) IPv4 détectée(s)", 3500)
|
||
|
||
def _interface_changed(self, index: int) -> None:
|
||
iface = self.interface_combo.itemData(index)
|
||
if isinstance(iface, NetworkInterface):
|
||
net = ipaddress.ip_network(iface.network, strict=False)
|
||
if net.prefixlen == 24:
|
||
first = net.network_address + 1
|
||
last = net.broadcast_address - 1
|
||
self.target_edit.setText(f"{first} - {last}")
|
||
else:
|
||
self.target_edit.setText(iface.network)
|
||
|
||
def selected_interface(self) -> NetworkInterface | None:
|
||
data = self.interface_combo.currentData()
|
||
return data if isinstance(data, NetworkInterface) else None
|
||
|
||
# ---------- Scan ----------
|
||
def start_scan(self, _checked: bool = False, *, profile: str = "Standard", skip_admin_prompt: bool = False) -> None:
|
||
if self.worker and self.worker.isRunning():
|
||
return
|
||
try:
|
||
target = validate_target(self.target_edit.text())
|
||
count = target_address_count(target)
|
||
except ValueError as exc:
|
||
QMessageBox.warning(self, "Cible invalide", str(exc))
|
||
return
|
||
if count > 4096:
|
||
QMessageBox.warning(self, "Cible trop grande", "LibreNet limite un scan à 4096 adresses.")
|
||
return
|
||
if profile == "Approfondi" and not self.admin_mode and not skip_admin_prompt:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Scan approfondi",
|
||
"Le mode administrateur permet le SYN scan et la détection OS Nmap.\n\nL'activer maintenant ?",
|
||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||
QMessageBox.Yes,
|
||
)
|
||
if answer == QMessageBox.Cancel:
|
||
return
|
||
if answer == QMessageBox.Yes:
|
||
self._pending_scan_after_auth = True
|
||
self._pending_profile = profile
|
||
self._request_admin_mode(show_confirmation=False)
|
||
return
|
||
|
||
self.current_scan_privileged = self.admin_mode
|
||
signature = profile_signature(profile, self.current_scan_privileged)
|
||
previous = self.store.latest_scan(target, profile, signature)
|
||
if previous is None:
|
||
self.baseline_hosts, self.baseline_date = None, ""
|
||
else:
|
||
self.baseline_hosts = self.store.load_scan_hosts(int(previous["id"]))
|
||
self.baseline_date = previous["created_at"]
|
||
|
||
self.hosts.clear()
|
||
self._metadata_cache.clear()
|
||
self.tree.clear()
|
||
self._update_detail_panel(None)
|
||
self.scan_had_error = False
|
||
self.scan_warnings.clear()
|
||
self.notice_label.clear()
|
||
self.notice_frame.setVisible(False)
|
||
self._routed_scan = not target_is_on_interface(target, self.selected_interface())
|
||
self.current_identity_scope = scan_identity_scope(target, self.selected_interface())
|
||
|
||
request = ScanRequest(target=target, profile=profile, interface=self.selected_interface(), privileged=self.current_scan_privileged)
|
||
self.worker = ScanWorker(request, self)
|
||
self.worker.progress.connect(self._progress)
|
||
self.worker.progress_state.connect(self._progress_state)
|
||
self.worker.warning.connect(self._scan_warning)
|
||
self.worker.hosts_found.connect(self._merge_hosts)
|
||
self.worker.failed.connect(self._scan_failed)
|
||
self.worker.completed.connect(lambda: self._scan_finished(target, profile))
|
||
self.scan_started_at = datetime.now().astimezone()
|
||
self.scan_btn.setEnabled(False)
|
||
self.scan_menu_btn.setEnabled(False)
|
||
self.scan_btn.setText("Scan en cours")
|
||
self.stop_btn.setEnabled(True)
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(0)
|
||
self.progress.setFormat("%p%")
|
||
self.progress.setTextVisible(True)
|
||
self.progress.setVisible(True)
|
||
self.activity_label.setText(f"Préparation du scan {display_target(target)}…")
|
||
self.statusBar().clearMessage()
|
||
self.worker.start()
|
||
|
||
def stop_scan(self) -> None:
|
||
stopping = False
|
||
if self.worker and self.worker.isRunning():
|
||
self.worker.stop()
|
||
stopping = True
|
||
if self.host_worker and self.host_worker.isRunning():
|
||
self.host_worker.stop()
|
||
stopping = True
|
||
if stopping:
|
||
self.stop_btn.setEnabled(False)
|
||
self.activity_label.setText("Arrêt du scan en cours…")
|
||
self.progress.setRange(0, 0)
|
||
self.progress.setTextVisible(False)
|
||
|
||
def _progress(self, message: str) -> None:
|
||
# La barre d'état système reste réservée aux notifications ponctuelles.
|
||
# Le suivi du scan appartient à la ligne d'activité, sinon le dernier message
|
||
# de phase restait affiché après la fin du scan.
|
||
self.activity_label.setText(message)
|
||
|
||
def _progress_state(self, value: int, message: str) -> None:
|
||
self.activity_label.setText(message)
|
||
self.progress.setVisible(True)
|
||
if value < 0:
|
||
# Phase de durée inconnue (Naabu/Nmap/arp-scan) : animation honnête plutôt
|
||
# qu'un faux pourcentage figé.
|
||
self.progress.setRange(0, 0)
|
||
self.progress.setTextVisible(False)
|
||
return
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setTextVisible(True)
|
||
self.progress.setFormat("%p%")
|
||
self.progress.setValue(max(0, min(100, value)))
|
||
|
||
def _scan_warning(self, message: str) -> None:
|
||
if message not in self.scan_warnings:
|
||
self.scan_warnings.append(message)
|
||
summary = compact_warning(self.scan_warnings[0])
|
||
if len(self.scan_warnings) > 1:
|
||
summary += f" (+{len(self.scan_warnings) - 1})"
|
||
full = "\n".join(self.scan_warnings)
|
||
self.notice_label.setText(summary)
|
||
self.notice_label.setToolTip(full)
|
||
self.notice_frame.setToolTip(full)
|
||
self.notice_frame.setVisible(True)
|
||
self.statusBar().showMessage(message, 9000)
|
||
|
||
def _merge_hosts(self, incoming: list[Host]) -> None:
|
||
for host in incoming:
|
||
existing = self.hosts.get(host.ip)
|
||
if existing:
|
||
existing.merge(host)
|
||
self._apply_cached_online_vendor(existing)
|
||
enrich_host(existing)
|
||
else:
|
||
self._apply_cached_online_vendor(host)
|
||
host = enrich_host(host)
|
||
self.hosts[host.ip] = host
|
||
# La découverte puis le scan de ports arrivent par étapes : la vue se remplit donc immédiatement.
|
||
self._refresh_tree()
|
||
|
||
def _scan_failed(self, message: str) -> None:
|
||
self.scan_had_error = True
|
||
self.scan_btn.setEnabled(True)
|
||
self.scan_menu_btn.setEnabled(True)
|
||
self.scan_btn.setText("Scanner")
|
||
self.stop_btn.setEnabled(False)
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(0)
|
||
self.progress.setFormat("Erreur")
|
||
self.progress.setTextVisible(True)
|
||
self.progress.setVisible(True)
|
||
self.statusBar().clearMessage()
|
||
QMessageBox.critical(self, "Erreur de scan", message)
|
||
self.activity_label.setText("Erreur")
|
||
|
||
def _scan_finished(self, target: str, profile: str) -> None:
|
||
self.scan_btn.setEnabled(True)
|
||
self.scan_menu_btn.setEnabled(True)
|
||
self.scan_btn.setText("Scanner")
|
||
self.stop_btn.setEnabled(False)
|
||
self.statusBar().clearMessage()
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setTextVisible(True)
|
||
if self.worker and self.worker.isInterruptionRequested():
|
||
self.progress.setValue(0)
|
||
self.progress.setFormat("Interrompu")
|
||
self.progress.setVisible(True)
|
||
self.activity_label.setText("Scan interrompu")
|
||
return
|
||
if self.scan_had_error:
|
||
return
|
||
active_hosts = list(self.hosts.values())
|
||
# V0.4.9 : mémorise l'identification riche séparément du scan courant.
|
||
# Un futur Standard pourra donc afficher l'OS/type découvert ici en
|
||
# Approfondi sans polluer l'historique des ports ni la comparaison.
|
||
try:
|
||
# Appliquer la mémoire AVANT d'actualiser l'observation de l'endpoint.
|
||
# Sinon un changement d'IP/MAC LAA pourrait réécrire l'ancienne fiche
|
||
# avec les indices du scan courant avant que la corrélation soit évaluée.
|
||
self.store.apply_identifications(active_hosts, scope=self.current_identity_scope)
|
||
self.store.remember_identifications(active_hosts, profile, scope=self.current_identity_scope)
|
||
except (OSError, ValueError) as exc:
|
||
self.statusBar().showMessage(f"Identification non mémorisée : {exc}", 5000)
|
||
compared = compare_hosts(active_hosts, self.baseline_hosts)
|
||
self.hosts = {host.ip: host for host in compared}
|
||
self._refresh_tree()
|
||
try:
|
||
self.store.save_scan(target, profile, active_hosts, profile_signature(profile, self.current_scan_privileged))
|
||
except (OSError, ValueError) as exc:
|
||
self.statusBar().showMessage(f"Historique non enregistré : {exc}", 5000)
|
||
self.progress.setValue(100)
|
||
self.progress.setFormat("100%")
|
||
self.progress.setVisible(True)
|
||
elapsed = None
|
||
if self.scan_started_at is not None:
|
||
elapsed = max(0.0, (datetime.now().astimezone() - self.scan_started_at).total_seconds())
|
||
suffix = f" en {elapsed:.1f} s" if elapsed is not None else ""
|
||
if self.baseline_hosts is None:
|
||
self.activity_label.setText(f"Terminé{suffix}")
|
||
else:
|
||
self.activity_label.setText(f"Terminé{suffix} — comparaison effectuée")
|
||
if self._routed_scan:
|
||
self.statusBar().showMessage("Réseau routé : les adresses MAC ne sont généralement pas visibles au-delà du routeur.", 7000)
|
||
elif active_hosts and not any(host.mac for host in active_hosts):
|
||
self._scan_warning(
|
||
"Aucune MAC récupérée sur ce réseau local. Active le mode Administrateur ou ouvre Aide → Diagnostic des outils."
|
||
)
|
||
self._start_automatic_online_vendor_lookup()
|
||
|
||
# ---------- Constructeurs en ligne ----------
|
||
def _online_vendor_enabled(self) -> bool:
|
||
return bool(self.settings.value("privacy/onlineMacLookupEnabled", False, type=bool))
|
||
|
||
def _online_vendor_provider(self) -> str:
|
||
value = str(self.settings.value("privacy/onlineMacLookupProvider", PROVIDER_MACLOOKUP))
|
||
return value if value in PROVIDERS else PROVIDER_MACLOOKUP
|
||
|
||
def show_vendor_settings(self) -> None:
|
||
dialog = VendorSettingsDialog(
|
||
enabled=self._online_vendor_enabled(),
|
||
provider=self._online_vendor_provider(),
|
||
parent=self,
|
||
)
|
||
if dialog.exec() != QDialog.Accepted:
|
||
return
|
||
was_enabled = self._online_vendor_enabled()
|
||
self.settings.setValue("privacy/onlineMacLookupEnabled", dialog.online_enabled)
|
||
self.settings.setValue("privacy/onlineMacLookupProvider", dialog.provider)
|
||
self.settings.sync()
|
||
if dialog.online_enabled and not was_enabled:
|
||
self.statusBar().showMessage(
|
||
f"Recherche en ligne activée — fournisseur : {provider_label(dialog.provider)}", 5000
|
||
)
|
||
self._start_automatic_online_vendor_lookup()
|
||
|
||
def _apply_cached_online_vendor(self, host: Host) -> bool:
|
||
if host.vendor or not host.mac:
|
||
return False
|
||
provider = self._online_vendor_provider()
|
||
cached = self.store.online_vendor_cache(host.mac, provider)
|
||
if not cached:
|
||
return False
|
||
if bool(cached.get("found")) and cached.get("vendor"):
|
||
host.vendor = str(cached["vendor"])
|
||
return True
|
||
return False
|
||
|
||
def _lookup_selected_vendor_online(self) -> None:
|
||
host = self._selected_host()
|
||
if not host or not host.mac:
|
||
return
|
||
self._start_online_vendor_lookup([host], manual=True)
|
||
|
||
def _start_automatic_online_vendor_lookup(self) -> None:
|
||
if not self._online_vendor_enabled():
|
||
return
|
||
unknown = [host for host in self.hosts.values() if host.status == "up" and host.mac and not host.vendor]
|
||
if unknown:
|
||
self._start_online_vendor_lookup(unknown, manual=False)
|
||
|
||
def _start_online_vendor_lookup(self, hosts: list[Host], *, manual: bool) -> None:
|
||
if self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning():
|
||
if manual:
|
||
self.statusBar().showMessage("Une recherche constructeur en ligne est déjà en cours.", 4000)
|
||
return
|
||
provider = self._online_vendor_provider()
|
||
pending: list[str] = []
|
||
for host in hosts:
|
||
if not host.mac:
|
||
continue
|
||
cached = None if manual else self.store.online_vendor_cache(host.mac, provider)
|
||
if cached:
|
||
if bool(cached.get("found")) and cached.get("vendor"):
|
||
host.vendor = str(cached["vendor"])
|
||
enrich_host(host)
|
||
continue
|
||
pending.append(host.mac)
|
||
self._refresh_tree()
|
||
selected = self._selected_host()
|
||
if selected:
|
||
self._update_detail_panel(selected)
|
||
if not pending:
|
||
if manual:
|
||
self.statusBar().showMessage("Résultat constructeur chargé depuis le cache local.", 4000)
|
||
return
|
||
self._vendor_lookup_manual = manual
|
||
self.online_vendor_btn.setEnabled(False)
|
||
self.statusBar().showMessage(
|
||
f"Recherche constructeur via {provider_label(provider)}…", 0 if len(pending) > 1 else 6000
|
||
)
|
||
self.vendor_lookup_worker = OnlineVendorLookupWorker(pending, provider, self)
|
||
self.vendor_lookup_worker.result.connect(self._online_vendor_result)
|
||
self.vendor_lookup_worker.failed.connect(self._online_vendor_failed)
|
||
self.vendor_lookup_worker.finished.connect(self._online_vendor_finished)
|
||
self.vendor_lookup_worker.start()
|
||
|
||
def _online_vendor_result(self, result: OnlineVendorResult) -> None:
|
||
self.store.save_online_vendor_cache(
|
||
result.mac, result.provider, vendor=result.vendor, found=result.found,
|
||
block_type=result.block_type, is_randomized=result.is_randomized,
|
||
is_private=result.is_private, checked_at=result.checked_at,
|
||
)
|
||
for host in self.hosts.values():
|
||
if host.mac.upper() != result.mac.upper():
|
||
continue
|
||
if result.found and result.vendor:
|
||
host.vendor = result.vendor
|
||
enrich_host(host)
|
||
self._refresh_tree()
|
||
selected = self._selected_host()
|
||
if selected:
|
||
self._update_detail_panel(selected)
|
||
|
||
def _online_vendor_failed(self, mac: str, message: str) -> None:
|
||
if self._vendor_lookup_manual:
|
||
QMessageBox.warning(self, "Recherche constructeur", f"{mac} : {message}")
|
||
else:
|
||
self.statusBar().showMessage(f"Recherche constructeur : {message}", 6000)
|
||
|
||
def _online_vendor_finished(self) -> None:
|
||
self.online_vendor_btn.setEnabled(bool(self._selected_host() and self._selected_host().mac))
|
||
provider = self._online_vendor_provider()
|
||
if self._vendor_lookup_manual:
|
||
host = self._selected_host()
|
||
if host and host.vendor:
|
||
self.statusBar().showMessage(f"Constructeur trouvé : {host.vendor}", 5000)
|
||
elif host and is_locally_administered(host.mac):
|
||
self.statusBar().showMessage(
|
||
"Adresse MAC locale (LAA) : aucun constructeur fiable ne peut être déduit.", 6000
|
||
)
|
||
else:
|
||
self.statusBar().showMessage(f"Aucun constructeur trouvé via {provider_label(provider)}.", 5000)
|
||
else:
|
||
self.statusBar().showMessage("Recherche des constructeurs en ligne terminée.", 4000)
|
||
self._vendor_lookup_manual = False
|
||
|
||
# ---------- Arbre et filtrage ----------
|
||
def _host_has_shared_mac(self, host: Host) -> bool:
|
||
mac = normalize_mac(host.mac)
|
||
return bool(mac and mac in shared_macs(self.hosts.values()))
|
||
|
||
def _metadata_key(self, host: Host) -> str:
|
||
return identity_key(host, shared_mac=self._host_has_shared_mac(host))
|
||
|
||
def _metadata(self, host: Host) -> dict[str, object]:
|
||
key = self._metadata_key(host)
|
||
if key not in self._metadata_cache:
|
||
self._metadata_cache[key] = self.store.host_metadata(
|
||
host, shared_mac=self._host_has_shared_mac(host)
|
||
)
|
||
return self._metadata_cache[key]
|
||
|
||
def _host_title(self, host: Host) -> str:
|
||
return host.hostname or host.ip
|
||
|
||
def _type_version_text(self, host: Host) -> str:
|
||
"""Compact but useful summary for the main list."""
|
||
device = host.effective_device_type or "Hôte"
|
||
os_name = (host.effective_os_name or "").strip()
|
||
suffix = ""
|
||
if host.type_is_remembered or host.os_is_remembered:
|
||
suffix = " · mémorisé"
|
||
if not os_name:
|
||
return device + suffix
|
||
# Avoid repeating obvious generic family names twice.
|
||
if os_name.casefold() in device.casefold() or device.casefold() in os_name.casefold():
|
||
return device + suffix
|
||
if len(os_name) > 38:
|
||
os_name = os_name[:35].rstrip() + "…"
|
||
return f"{device} · {os_name}{suffix}"
|
||
|
||
def _identification_tooltip(self, host: Host) -> str:
|
||
lines: list[str] = []
|
||
if host.effective_device_type:
|
||
lines.append(host.effective_device_type)
|
||
if host.effective_os_name:
|
||
lines.append(host.effective_os_name)
|
||
if host.remembered_match_score:
|
||
lines.append(
|
||
f"Corrélation historique : {host.remembered_match_score}%"
|
||
+ (f" — {host.remembered_match_reason}" if host.remembered_match_reason else "")
|
||
)
|
||
if host.type_is_remembered:
|
||
detail = f"Type mémorisé depuis un scan {host.remembered_type_source or 'antérieur'}"
|
||
if host.remembered_type_seen_at:
|
||
detail += f" du {_display_timestamp(host.remembered_type_seen_at)}"
|
||
lines.append(detail)
|
||
if host.os_is_remembered:
|
||
detail = f"OS mémorisé depuis un scan {host.remembered_os_source or 'antérieur'}"
|
||
if host.remembered_os_seen_at:
|
||
detail += f" du {_display_timestamp(host.remembered_os_seen_at)}"
|
||
lines.append(detail)
|
||
if not lines and host.vendor:
|
||
lines.append(host.vendor)
|
||
return "\n".join(lines)
|
||
|
||
def _remembered_os_tooltip(self, host: Host) -> str:
|
||
if not host.os_is_remembered:
|
||
if not host.os_name:
|
||
return ""
|
||
if host.os_accuracy is None:
|
||
return "Détecté lors du scan courant"
|
||
nature = "estimation Nmap" if host.os_accuracy < 100 else "correspondance Nmap"
|
||
return f"Détecté lors du scan courant — {nature} {host.os_accuracy}%"
|
||
source = host.remembered_os_source or "scan antérieur"
|
||
when = _display_timestamp(host.remembered_os_seen_at) if host.remembered_os_seen_at else ""
|
||
suffix = f" du {when}" if when else ""
|
||
os_accuracy = (
|
||
f" — précision Nmap {host.remembered_os_accuracy}%"
|
||
if host.remembered_os_accuracy is not None else ""
|
||
)
|
||
confidence = f" — corrélation équipement {host.remembered_match_score}%" if host.remembered_match_score else ""
|
||
reason = f" ({host.remembered_match_reason})" if host.remembered_match_reason else ""
|
||
return f"Dernière identification connue — {source}{suffix}{os_accuracy}{confidence}{reason}"
|
||
|
||
def _device_icon(self, host: Host) -> QIcon:
|
||
# V0.4.12 : l'icône de la première colonne représente uniquement
|
||
# le TYPE D'ÉQUIPEMENT. L'OS dispose de sa propre icône séparée.
|
||
return device_icon(host)
|
||
|
||
def _os_icon(self, host: Host) -> QIcon:
|
||
return os_icon(host)
|
||
|
||
def _service_icon(self, port: PortInfo) -> QIcon:
|
||
if _port_url(Host("0.0.0.0", ports=[port]), port):
|
||
return themed_icon("internet-web-browser")
|
||
if port.port == 22:
|
||
return themed_icon("utilities-terminal")
|
||
if port.port == 445:
|
||
return themed_icon("folder-network")
|
||
if port.port == 3389:
|
||
return themed_icon("krdc")
|
||
if port.port in {53, 67, 68, 161, 162}:
|
||
return themed_icon("network-wired")
|
||
return themed_icon("network-server")
|
||
|
||
def _service_title(self, port: PortInfo) -> str:
|
||
return port.service or "Service réseau"
|
||
|
||
def _toggle_view_mode(self, detailed: bool) -> None:
|
||
if detailed and not self.detail_view_btn.isChecked():
|
||
self.detail_view_btn.setChecked(True)
|
||
return
|
||
if not detailed and not self.compact_view_btn.isChecked():
|
||
self.compact_view_btn.setChecked(True)
|
||
return
|
||
self.tree.setRootIsDecorated(detailed)
|
||
self._refresh_tree()
|
||
|
||
def _set_view_filter(self, value: str) -> None:
|
||
self._view_filter_value = value
|
||
self.view_filter.setText(value)
|
||
for action in self._view_filter_actions:
|
||
action.setChecked(action.text() == value)
|
||
self._apply_filters()
|
||
|
||
def _set_group_filter(self, value: str) -> None:
|
||
self._group_filter_value = value
|
||
self.group_filter.setText(value)
|
||
menu = self.group_filter.menu()
|
||
if menu is not None:
|
||
for action in menu.actions():
|
||
action.setChecked(action.text() == value)
|
||
self._apply_filters()
|
||
|
||
def _refresh_tree(self) -> None:
|
||
selected = self._selected_host()
|
||
selected_ip = selected.ip if selected else None
|
||
expanded = {
|
||
self.tree.topLevelItem(i).data(0, ROLE_IP)
|
||
for i in range(self.tree.topLevelItemCount())
|
||
if self.tree.topLevelItem(i).isExpanded()
|
||
}
|
||
detailed = self.view_mode_btn.isChecked() if hasattr(self, "view_mode_btn") else True
|
||
self.tree.setUpdatesEnabled(False)
|
||
self.tree.setSortingEnabled(False)
|
||
self.tree.clear()
|
||
self.tree.setRootIsDecorated(detailed)
|
||
selected_item = None
|
||
palette = self.tree.palette()
|
||
muted = palette.color(self.tree.foregroundRole())
|
||
muted.setAlpha(165)
|
||
|
||
for host in sorted(self.hosts.values(), key=lambda h: ipaddress.ip_address(h.ip)):
|
||
item = IPTreeWidgetItem(self.tree)
|
||
item.setData(0, ROLE_IP, host.ip)
|
||
item.setData(0, ROLE_KIND, KIND_HOST)
|
||
item.setText(0, self._host_title(host))
|
||
if self._metadata(host).get("favorite"):
|
||
font = item.font(0)
|
||
font.setBold(True)
|
||
item.setFont(0, font)
|
||
item.setToolTip(0, "Favori • " + (host.change_detail or host.effective_device_type))
|
||
item.setText(1, host.ip)
|
||
item.setText(2, self._type_version_text(host))
|
||
item.setText(3, "" if host.change_status in {"", "Inchangé"} else host.change_status)
|
||
icon = self._device_icon(host)
|
||
if not icon.isNull():
|
||
item.setIcon(0, icon)
|
||
os_family_icon = self._os_icon(host)
|
||
if host.effective_os_name and not os_family_icon.isNull():
|
||
item.setIcon(2, os_family_icon)
|
||
item.setToolTip(0, host.change_detail or host.effective_device_type)
|
||
item.setToolTip(1, f"Adresse IP : {host.ip}")
|
||
item.setToolTip(2, self._identification_tooltip(host))
|
||
item.setToolTip(3, host.change_detail)
|
||
|
||
if host.status != "up":
|
||
for col in range(self.tree.columnCount()):
|
||
item.setForeground(col, muted)
|
||
elif host.change_status == "Nouveau":
|
||
item.setIcon(3, themed_icon("list-add"))
|
||
elif host.change_status in {"Modifié", "IP modifiée"}:
|
||
item.setIcon(3, themed_icon("document-edit"))
|
||
elif host.change_status == "Disparu":
|
||
item.setIcon(3, themed_icon("list-remove"))
|
||
|
||
if detailed:
|
||
for port in sorted((p for p in host.ports if p.state == "open"), key=lambda p: (p.protocol, p.port)):
|
||
child = QTreeWidgetItem(item)
|
||
child.setData(0, ROLE_IP, host.ip)
|
||
child.setData(0, ROLE_KIND, KIND_SERVICE)
|
||
child.setData(0, ROLE_PORT, port.port)
|
||
child.setText(0, self._service_title(port))
|
||
child.setText(1, f"{port.port}/{port.protocol}")
|
||
product = " ".join(v for v in (port.product, port.version) if v).strip()
|
||
child.setText(2, product)
|
||
icon = self._service_icon(port)
|
||
if not icon.isNull():
|
||
child.setIcon(0, icon)
|
||
child.setToolTip(0, port.details)
|
||
child.setToolTip(1, "Double-clique pour ouvrir le service lorsqu'une action est disponible.")
|
||
child.setForeground(0, muted)
|
||
child.setForeground(1, muted)
|
||
child.setForeground(2, muted)
|
||
|
||
if detailed and host.ip in expanded:
|
||
item.setExpanded(True)
|
||
if selected_ip == host.ip:
|
||
selected_item = item
|
||
|
||
self.tree.setSortingEnabled(True)
|
||
self.tree.sortByColumn(1, Qt.AscendingOrder)
|
||
self._update_evolution_visibility()
|
||
self.tree.setUpdatesEnabled(True)
|
||
self._apply_filters()
|
||
if selected_item:
|
||
self.tree.setCurrentItem(selected_item)
|
||
self._update_summary()
|
||
|
||
def _apply_filters(self) -> None:
|
||
query = self.filter_edit.text().strip().casefold()
|
||
mode = self._view_filter_value
|
||
group = self._group_filter_value
|
||
visible_count = 0
|
||
for i in range(self.tree.topLevelItemCount()):
|
||
item = self.tree.topLevelItem(i)
|
||
host = self.hosts.get(str(item.data(0, ROLE_IP)))
|
||
hide = host is None
|
||
if host:
|
||
meta = self._metadata(host)
|
||
extra = f"{meta.get('group_name','')} {meta.get('note','')}".casefold()
|
||
if query and query not in (host.searchable_text + " " + extra):
|
||
hide = True
|
||
if not hide:
|
||
if mode == "Actifs" and host.status != "up": hide = True
|
||
elif mode == "Favoris" and not meta.get("favorite"): hide = True
|
||
elif mode == "Changements" and host.change_status not in {"Nouveau", "Modifié", "IP modifiée", "Disparu"}: hide = True
|
||
elif mode == "Nouveaux" and host.change_status != "Nouveau": hide = True
|
||
elif mode == "Modifiés" and host.change_status not in {"Modifié", "IP modifiée"}: hide = True
|
||
elif mode == "Disparus" and host.change_status != "Disparu": hide = True
|
||
if not hide and group != "Tous les groupes" and str(meta.get("group_name", "")) != group:
|
||
hide = True
|
||
item.setHidden(hide)
|
||
if not hide:
|
||
visible_count += 1
|
||
self._update_summary(visible_count)
|
||
|
||
def _update_summary(self, visible_count: int | None = None) -> None:
|
||
values = list(self.hosts.values())
|
||
active = sum(h.status == "up" for h in values)
|
||
services = sum(sum(p.state == "open" for p in h.ports) for h in values)
|
||
favorites = sum(bool(self._metadata(h).get("favorite")) for h in values) if values else 0
|
||
changes = sum(h.change_status in {"Nouveau", "Modifié", "IP modifiée", "Disparu"} for h in values)
|
||
if visible_count is None:
|
||
visible_count = sum(not self.tree.topLevelItem(i).isHidden() for i in range(self.tree.topLevelItemCount()))
|
||
parts = [f"{active} appareil(s)", f"{services} service(s)"]
|
||
if favorites:
|
||
parts.append(f"{favorites} favori(s)")
|
||
if changes:
|
||
parts.append(f"{changes} évolution(s)")
|
||
if visible_count != len(values):
|
||
parts.append(f"{visible_count} affiché(s)")
|
||
self.summary_label.setText(" • ".join(parts))
|
||
|
||
def _reload_group_choices(self) -> None:
|
||
groups = self.store.known_groups()
|
||
current_filter = self._group_filter_value if hasattr(self, "_group_filter_value") else "Tous les groupes"
|
||
current_edit = self.group_edit.currentText() if hasattr(self, "group_edit") else ""
|
||
if hasattr(self, "group_filter"):
|
||
if current_filter != "Tous les groupes" and current_filter not in groups:
|
||
current_filter = "Tous les groupes"
|
||
self._group_filter_value = current_filter
|
||
menu = QMenu(self.group_filter)
|
||
for label in ["Tous les groupes", *groups]:
|
||
action = menu.addAction(themed_icon("folder"), label)
|
||
action.setCheckable(True)
|
||
action.setChecked(label == current_filter)
|
||
action.triggered.connect(lambda _checked=False, value=label: self._set_group_filter(value))
|
||
self.group_filter.setMenu(menu)
|
||
self.group_filter.setText(current_filter)
|
||
self.group_filter.setVisible(bool(groups))
|
||
if hasattr(self, "group_edit"):
|
||
self.group_edit.clear()
|
||
self.group_edit.addItems(groups)
|
||
self.group_edit.setEditText(current_edit)
|
||
|
||
# ---------- Sélection, détails, favoris ----------
|
||
def _host_from_item(self, item: QTreeWidgetItem | None) -> Host | None:
|
||
if item is None:
|
||
return None
|
||
ip = item.data(0, ROLE_IP)
|
||
return self.hosts.get(str(ip)) if ip else None
|
||
|
||
def _selected_host(self) -> Host | None:
|
||
return self._host_from_item(self.tree.currentItem())
|
||
|
||
def _selection_changed(self, item: QTreeWidgetItem | None) -> None:
|
||
self._update_detail_panel(self._host_from_item(item))
|
||
|
||
def _detail_service_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
|
||
host = self._selected_host()
|
||
if not host:
|
||
return
|
||
port_no = item.data(0, ROLE_PORT)
|
||
if port_no is None:
|
||
return
|
||
port = next((p for p in host.ports if p.port == int(port_no) and p.state == "open"), None)
|
||
self._open_service(host, port)
|
||
|
||
def _web_action_label(self, host: Host) -> str:
|
||
if host.effective_device_type == "Hyperviseur Proxmox":
|
||
return "Ouvrir Proxmox"
|
||
if host.effective_device_type == "Proxmox Backup Server":
|
||
return "Ouvrir PBS"
|
||
if host.effective_device_type == "NAS Synology":
|
||
return "Ouvrir DSM"
|
||
if host.effective_device_type == "Pare-feu / routeur":
|
||
return "Ouvrir l'interface"
|
||
return "Ouvrir le Web"
|
||
|
||
def _rebuild_more_menu(self, host: Host | None) -> None:
|
||
menu = QMenu(self.more_btn)
|
||
if not host:
|
||
self.more_btn.setMenu(menu)
|
||
self.more_btn.setEnabled(False)
|
||
return
|
||
|
||
open_ports = {p.port for p in host.ports if p.state == "open"}
|
||
if _port_url(host) is not None:
|
||
web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host))
|
||
web.triggered.connect(self._open_selected_web)
|
||
if 22 in open_ports:
|
||
ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH")
|
||
ssh.triggered.connect(lambda: self._run_terminal(["ssh", host.ip]))
|
||
if 445 in open_ports:
|
||
smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages")
|
||
smb.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/")))
|
||
if 3389 in open_ports and shutil.which("remmina") is not None:
|
||
rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP")
|
||
rdp.triggered.connect(self._open_selected_rdp)
|
||
if menu.actions():
|
||
menu.addSeparator()
|
||
|
||
ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping")
|
||
ping.triggered.connect(lambda: self._run_terminal(["ping", host.ip]))
|
||
traceroute = menu.addAction(themed_icon("network-wired"), "Traceroute")
|
||
traceroute.setEnabled(shutil.which("traceroute") is not None)
|
||
traceroute.triggered.connect(lambda: self._run_terminal(["traceroute", host.ip]))
|
||
scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports")
|
||
scan.setEnabled(host.status == "up")
|
||
scan.triggered.connect(self.scan_selected_host)
|
||
wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN")
|
||
wol.setEnabled(bool(host.mac))
|
||
wol.triggered.connect(self._wake_selected)
|
||
menu.addSeparator()
|
||
copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP")
|
||
copy_ip.triggered.connect(lambda: QApplication.clipboard().setText(host.ip))
|
||
copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC")
|
||
copy_mac.setEnabled(bool(host.mac))
|
||
copy_mac.triggered.connect(lambda: QApplication.clipboard().setText(host.mac))
|
||
self.more_btn.setMenu(menu)
|
||
self.more_btn.setEnabled(True)
|
||
|
||
def _update_detail_panel(self, host: Host | None) -> None:
|
||
enabled = host is not None
|
||
for widget in (
|
||
self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn,
|
||
self.hostscan_btn, self.wol_btn, self.favorite_btn,
|
||
self.group_edit, self.note_edit, self.save_meta_btn, self.online_vendor_btn,
|
||
):
|
||
widget.setEnabled(enabled)
|
||
|
||
self.detail_services_tree.clear()
|
||
self._rebuild_more_menu(host)
|
||
|
||
if not host:
|
||
icon = themed_icon("network-wired")
|
||
self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36))
|
||
self.detail_name.setText("Aucun équipement sélectionné")
|
||
self.detail_type.setText("Sélectionne un appareil pour afficher ses détails et ses actions.")
|
||
for label in (
|
||
self.detail_ip, self.detail_mac, self.detail_vendor,
|
||
self.detail_os, self.detail_latency, self.detail_seen,
|
||
):
|
||
label.setText("—")
|
||
self.detail_os_icon.clear()
|
||
self.detail_services_tree.setVisible(False)
|
||
self.detail_services.setVisible(True)
|
||
self.detail_services.setText("Aucun service à afficher")
|
||
for button in (self.web_btn, self.ssh_btn, self.smb_btn, self.rdp_btn):
|
||
button.setVisible(False)
|
||
self.more_btn.setVisible(False)
|
||
self.favorite_btn.blockSignals(True)
|
||
self.favorite_btn.setChecked(False)
|
||
self.favorite_btn.setText("Ajouter aux favoris")
|
||
self.favorite_btn.blockSignals(False)
|
||
self.group_edit.setEditText("")
|
||
self.note_edit.clear()
|
||
self.online_vendor_btn.setVisible(False)
|
||
return
|
||
|
||
meta = self._metadata(host)
|
||
icon = self._device_icon(host)
|
||
self.detail_icon.setPixmap(icon.pixmap(46, 46) if not icon.isNull() else QIcon().pixmap(36, 36))
|
||
self.detail_name.setText(host.hostname or host.ip)
|
||
status_parts = [host.effective_device_type + (" (mémorisé)" if host.type_is_remembered else "")]
|
||
if host.change_status and host.change_status != "Inchangé":
|
||
status_parts.append(host.change_status)
|
||
if host.status != "up":
|
||
status_parts.append("hors ligne")
|
||
self.detail_type.setText(" • ".join(status_parts))
|
||
self.detail_ip.setText(host.ip + (f" (avant : {host.previous_ip})" if host.previous_ip else ""))
|
||
self.detail_mac.setText(host.mac or "—")
|
||
if host.vendor:
|
||
self.detail_vendor.setText(host.vendor)
|
||
elif host.mac and is_locally_administered(host.mac):
|
||
self.detail_vendor.setText("Non déterminable — adresse locale (LAA)")
|
||
else:
|
||
self.detail_vendor.setText("—")
|
||
self.online_vendor_btn.setVisible(bool(host.mac))
|
||
self.online_vendor_btn.setEnabled(bool(host.mac) and not (self.vendor_lookup_worker and self.vendor_lookup_worker.isRunning()))
|
||
self.online_vendor_btn.setText("Actualiser en ligne" if host.vendor else "Rechercher en ligne")
|
||
self.online_vendor_btn.setToolTip(
|
||
f"Envoyer cette MAC à {provider_label(self._online_vendor_provider())} et mettre le résultat en cache localement"
|
||
)
|
||
effective_os = host.effective_os_name
|
||
if effective_os:
|
||
if host.os_is_remembered:
|
||
os_text = effective_os + " (mémorisé)"
|
||
elif host.os_accuracy is not None and host.os_accuracy < 100:
|
||
os_text = f"{effective_os} (estimation {host.os_accuracy} %)"
|
||
else:
|
||
os_text = effective_os
|
||
else:
|
||
os_text = "—"
|
||
self.detail_os.setText(os_text)
|
||
self.detail_os.setToolTip(self._remembered_os_tooltip(host))
|
||
os_family_icon = self._os_icon(host)
|
||
self.detail_os_icon.setPixmap(
|
||
os_family_icon.pixmap(24, 24) if effective_os and not os_family_icon.isNull() else QIcon().pixmap(22, 22)
|
||
)
|
||
self.detail_os_icon.setToolTip("Famille de système d'exploitation")
|
||
self.detail_latency.setText(f"{host.latency_ms:.1f} ms" if host.latency_ms is not None else "—")
|
||
self.detail_seen.setText(_display_timestamp(host.last_seen) or "—")
|
||
|
||
open_services = [p for p in host.ports if p.state == "open"]
|
||
self.detail_services_tree.setVisible(bool(open_services))
|
||
self.detail_services.setVisible(not bool(open_services))
|
||
self.detail_services.setText("Aucun service détecté")
|
||
for port in sorted(open_services, key=lambda p: (p.protocol, p.port)):
|
||
item = QTreeWidgetItem(self.detail_services_tree)
|
||
item.setData(0, ROLE_PORT, port.port)
|
||
item.setText(0, self._service_title(port))
|
||
item.setText(1, f"{port.port}/{port.protocol}")
|
||
icon = self._service_icon(port)
|
||
if not icon.isNull():
|
||
item.setIcon(0, icon)
|
||
item.setToolTip(0, port.details)
|
||
|
||
fav = bool(meta.get("favorite"))
|
||
self.favorite_btn.blockSignals(True)
|
||
self.favorite_btn.setChecked(fav)
|
||
self.favorite_btn.setText("Favori" if fav else "Ajouter aux favoris")
|
||
fav_icon = themed_icon("rating" if fav else "rating-unrated")
|
||
if not fav_icon.isNull():
|
||
self.favorite_btn.setIcon(fav_icon)
|
||
self.favorite_btn.blockSignals(False)
|
||
self.group_edit.setEditText(str(meta.get("group_name", "")))
|
||
self.note_edit.setPlainText(str(meta.get("note", "")))
|
||
|
||
open_ports = {p.port for p in open_services}
|
||
is_up = host.status == "up"
|
||
web_available = is_up and _port_url(host) is not None
|
||
ssh_available = is_up and 22 in open_ports
|
||
smb_available = is_up and 445 in open_ports
|
||
rdp_available = is_up and 3389 in open_ports and shutil.which("remmina") is not None
|
||
|
||
self.web_btn.setEnabled(web_available)
|
||
self.web_btn.setText(self._web_action_label(host))
|
||
self.ssh_btn.setEnabled(ssh_available)
|
||
self.smb_btn.setEnabled(smb_available)
|
||
self.rdp_btn.setEnabled(rdp_available)
|
||
|
||
# Deux actions directes maximum : le reste reste disponible via « Plus ».
|
||
direct = [
|
||
(self.web_btn, web_available),
|
||
(self.ssh_btn, ssh_available),
|
||
(self.rdp_btn, rdp_available),
|
||
(self.smb_btn, smb_available),
|
||
]
|
||
shown = 0
|
||
for button, available in direct:
|
||
show = bool(available and shown < 2)
|
||
button.setVisible(show)
|
||
if show:
|
||
shown += 1
|
||
self.more_btn.setVisible(True)
|
||
self.hostscan_btn.setEnabled(is_up)
|
||
self.wol_btn.setEnabled(bool(host.mac))
|
||
|
||
def _toggle_favorite(self, checked: bool) -> None:
|
||
host = self._selected_host()
|
||
if not host: return
|
||
self.store.save_host_metadata(host, favorite=checked, shared_mac=self._host_has_shared_mac(host))
|
||
self._metadata_cache.pop(self._metadata_key(host), None)
|
||
self._reload_group_choices()
|
||
self._refresh_tree()
|
||
self.statusBar().showMessage("Ajouté aux favoris" if checked else "Retiré des favoris", 3000)
|
||
|
||
def _save_selected_metadata(self) -> None:
|
||
host = self._selected_host()
|
||
if not host: return
|
||
self.store.save_host_metadata(
|
||
host, group_name=self.group_edit.currentText(), note=self.note_edit.toPlainText(),
|
||
shared_mac=self._host_has_shared_mac(host),
|
||
)
|
||
self._metadata_cache.pop(self._metadata_key(host), None)
|
||
self._reload_group_choices()
|
||
self._refresh_tree()
|
||
self.statusBar().showMessage("Groupe et note enregistrés", 3000)
|
||
|
||
# ---------- Actions ----------
|
||
def _tree_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
|
||
host = self._host_from_item(item)
|
||
if not host:
|
||
return
|
||
if item.data(0, ROLE_KIND) == KIND_SERVICE:
|
||
port_no = int(item.data(0, ROLE_PORT))
|
||
port = next((p for p in host.ports if p.port == port_no and p.state == "open"), None)
|
||
self._open_service(host, port)
|
||
return
|
||
if not self.view_mode_btn.isChecked():
|
||
self.view_mode_btn.setChecked(True)
|
||
# _toggle_view_mode reconstruit l'arbre ; on retrouve ensuite l'hôte.
|
||
for idx in range(self.tree.topLevelItemCount()):
|
||
candidate = self.tree.topLevelItem(idx)
|
||
if candidate.data(0, ROLE_IP) == host.ip:
|
||
candidate.setExpanded(True)
|
||
self.tree.setCurrentItem(candidate)
|
||
break
|
||
else:
|
||
item.setExpanded(not item.isExpanded())
|
||
|
||
def _open_service(self, host: Host, port: PortInfo | None) -> None:
|
||
if not port:
|
||
return
|
||
url = _port_url(host, port)
|
||
if url:
|
||
QDesktopServices.openUrl(QUrl(url))
|
||
elif port.port == 22:
|
||
self._run_terminal(["ssh", host.ip])
|
||
elif port.port == 445:
|
||
QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))
|
||
elif port.port == 3389 and shutil.which("remmina"):
|
||
subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"])
|
||
else:
|
||
self.statusBar().showMessage(f"{port.details} — aucune action directe associée", 4000)
|
||
|
||
def _context_menu(self, pos) -> None:
|
||
item = self.tree.itemAt(pos)
|
||
if item is None:
|
||
return
|
||
self.tree.setCurrentItem(item)
|
||
host = self._host_from_item(item)
|
||
if not host:
|
||
return
|
||
menu = QMenu(self)
|
||
if item.data(0, ROLE_KIND) == KIND_SERVICE:
|
||
open_action = menu.addAction(themed_icon("document-open"), "Ouvrir ce service")
|
||
menu.addSeparator()
|
||
else:
|
||
open_action = None
|
||
|
||
web = menu.addAction(themed_icon("internet-web-browser"), self._web_action_label(host))
|
||
ssh = menu.addAction(themed_icon("utilities-terminal"), "Ouvrir en SSH")
|
||
smb = menu.addAction(themed_icon("folder-network"), "Parcourir les partages")
|
||
rdp = menu.addAction(themed_icon("krdc"), "Ouvrir en RDP")
|
||
menu.addSeparator()
|
||
ping = menu.addAction(themed_icon("network-transmit-receive"), "Ping")
|
||
scan = menu.addAction(themed_icon("system-search"), "Scan détaillé — 1000 ports")
|
||
wol = menu.addAction(themed_icon("system-run"), "Wake-on-LAN")
|
||
menu.addSeparator()
|
||
meta = self._metadata(host)
|
||
favorite = menu.addAction(
|
||
themed_icon("rating" if not meta.get("favorite") else "rating-unrated"),
|
||
"Retirer des favoris" if meta.get("favorite") else "Ajouter aux favoris",
|
||
)
|
||
menu.addSeparator()
|
||
copy_ip = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse IP")
|
||
copy_mac = menu.addAction(themed_icon("edit-copy"), "Copier l'adresse MAC")
|
||
copy_mac.setEnabled(bool(host.mac))
|
||
web.setEnabled(_port_url(host) is not None)
|
||
ssh.setEnabled(any(p.port == 22 and p.state == "open" for p in host.ports))
|
||
smb.setEnabled(any(p.port == 445 and p.state == "open" for p in host.ports))
|
||
rdp.setEnabled(any(p.port == 3389 and p.state == "open" for p in host.ports) and shutil.which("remmina") is not None)
|
||
wol.setEnabled(bool(host.mac))
|
||
|
||
chosen = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||
if chosen == open_action:
|
||
port_no = int(item.data(0, ROLE_PORT))
|
||
self._open_service(host, next((p for p in host.ports if p.port == port_no), None))
|
||
elif chosen == web:
|
||
self._open_selected_web()
|
||
elif chosen == ssh:
|
||
self._run_terminal(["ssh", host.ip])
|
||
elif chosen == smb:
|
||
QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))
|
||
elif chosen == rdp:
|
||
self._open_selected_rdp()
|
||
elif chosen == ping:
|
||
self._run_terminal(["ping", host.ip])
|
||
elif chosen == scan:
|
||
self.scan_selected_host()
|
||
elif chosen == wol:
|
||
self._wake_host(host)
|
||
elif chosen == favorite:
|
||
self._toggle_favorite(not bool(meta.get("favorite")))
|
||
elif chosen == copy_ip:
|
||
QApplication.clipboard().setText(host.ip)
|
||
elif chosen == copy_mac:
|
||
QApplication.clipboard().setText(host.mac)
|
||
|
||
def _run_terminal(self, command: list[str]) -> None:
|
||
terminal = shutil.which("konsole")
|
||
if not terminal:
|
||
QMessageBox.warning(self, "Konsole absent", "Konsole n'est pas installé ou n'est pas dans le PATH.")
|
||
return
|
||
try:
|
||
subprocess.Popen([terminal, "-e", *command])
|
||
except OSError as exc:
|
||
QMessageBox.critical(self, "Erreur", str(exc))
|
||
|
||
def _run_selected_terminal(self, prefix: list[str]) -> None:
|
||
host = self._selected_host()
|
||
if host: self._run_terminal([*prefix, host.ip])
|
||
|
||
def _open_selected_web(self) -> None:
|
||
host = self._selected_host()
|
||
if not host: return
|
||
url = _port_url(host)
|
||
if url: QDesktopServices.openUrl(QUrl(url))
|
||
|
||
def _open_selected_smb(self) -> None:
|
||
host = self._selected_host()
|
||
if host: QDesktopServices.openUrl(QUrl(f"smb://{host.ip}/"))
|
||
|
||
def _open_selected_rdp(self) -> None:
|
||
host = self._selected_host()
|
||
if not host or not shutil.which("remmina"): return
|
||
try:
|
||
subprocess.Popen(["remmina", "-c", f"rdp://{host.ip}"])
|
||
except OSError as exc:
|
||
QMessageBox.critical(self, "Erreur Remmina", str(exc))
|
||
|
||
def _wake_selected(self) -> None:
|
||
host = self._selected_host()
|
||
if host: self._wake_host(host)
|
||
|
||
def _wake_host(self, host: Host) -> None:
|
||
iface = self.selected_interface()
|
||
broadcast = "255.255.255.255"
|
||
if iface and target_is_on_interface(host.ip, iface):
|
||
broadcast = str(ipaddress.ip_network(iface.network, strict=False).broadcast_address)
|
||
try:
|
||
send_magic_packet(host.mac, broadcast=broadcast)
|
||
except (OSError, ValueError) as exc:
|
||
QMessageBox.critical(self, "Wake-on-LAN", str(exc))
|
||
return
|
||
self.statusBar().showMessage(f"Paquet Wake-on-LAN envoyé à {host.mac}", 4500)
|
||
|
||
def scan_selected_host(self) -> None:
|
||
host = self._selected_host()
|
||
if not host or host.status != "up": return
|
||
if self.host_worker and self.host_worker.isRunning():
|
||
QMessageBox.information(self, "Scan en cours", "Un scan détaillé est déjà en cours.")
|
||
return
|
||
self.host_worker = HostScanWorker(host.ip, self, privileged=self.admin_mode)
|
||
self.host_worker.progress.connect(self._progress)
|
||
self.host_worker.result.connect(self._host_scan_result)
|
||
self.host_worker.failed.connect(lambda m: QMessageBox.critical(self, "Erreur Nmap", m))
|
||
self.host_worker.completed.connect(self._host_scan_finished)
|
||
self.progress.setRange(0, 0)
|
||
self.progress.setTextVisible(False)
|
||
self.progress.setVisible(True)
|
||
self.statusBar().clearMessage()
|
||
self.host_worker.start()
|
||
|
||
def _host_scan_finished(self) -> None:
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(100)
|
||
self.progress.setFormat("100%")
|
||
self.progress.setTextVisible(True)
|
||
self.progress.setVisible(True)
|
||
self.activity_label.setText("Scan détaillé terminé")
|
||
self.statusBar().clearMessage()
|
||
|
||
def _host_scan_result(self, result: Host) -> None:
|
||
if result.ip in self.hosts:
|
||
self.hosts[result.ip].merge(result)
|
||
enrich_host(self.hosts[result.ip])
|
||
host = self.hosts[result.ip]
|
||
else:
|
||
host = enrich_host(result)
|
||
self.hosts[result.ip] = host
|
||
try:
|
||
shared = shared_macs(self.hosts.values())
|
||
self.store.remember_host_identification(
|
||
host, "Détaillé", shared_mac=normalize_mac(host.mac) in shared,
|
||
scope=self.current_identity_scope,
|
||
)
|
||
self.store.apply_identifications(list(self.hosts.values()), scope=self.current_identity_scope)
|
||
except (OSError, ValueError):
|
||
pass
|
||
self._refresh_tree()
|
||
|
||
# ---------- Export / historique / diagnostic ----------
|
||
def export_csv_dialog(self) -> None:
|
||
if not self.hosts:
|
||
QMessageBox.information(self, "Export", "Aucun résultat à exporter.")
|
||
return
|
||
filename, _ = QFileDialog.getSaveFileName(self, "Exporter en CSV", "librenet-scan.csv", "CSV (*.csv)")
|
||
if filename:
|
||
export_csv(filename, list(self.hosts.values()))
|
||
self.statusBar().showMessage(f"Export CSV : {filename}", 5000)
|
||
|
||
def export_json_dialog(self) -> None:
|
||
if not self.hosts:
|
||
QMessageBox.information(self, "Export", "Aucun résultat à exporter.")
|
||
return
|
||
filename, _ = QFileDialog.getSaveFileName(self, "Exporter en JSON", "librenet-scan.json", "JSON (*.json)")
|
||
if filename:
|
||
export_json(filename, list(self.hosts.values()))
|
||
self.statusBar().showMessage(f"Export JSON : {filename}", 5000)
|
||
|
||
def show_history(self) -> None:
|
||
HistoryDialog(self.store, self).exec()
|
||
|
||
def clear_results(self) -> None:
|
||
"""Efface seulement la vue courante, jamais les données persistantes."""
|
||
self.hosts.clear()
|
||
self.baseline_hosts = None
|
||
self.baseline_date = ""
|
||
self._metadata_cache.clear()
|
||
self.tree.clear()
|
||
self._update_detail_panel(None)
|
||
self.summary_label.setText("0 appareil")
|
||
self.activity_label.setText("Prêt")
|
||
self.statusBar().showMessage(
|
||
"Affichage effacé — historique et identifications mémorisées conservés", 4500
|
||
)
|
||
|
||
def clear_scan_history(self) -> None:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Effacer l’historique des scans",
|
||
"Supprimer tous les anciens scans ?\n\n"
|
||
"Les favoris, groupes, notes et identifications mémorisées seront conservés.",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.No,
|
||
)
|
||
if answer != QMessageBox.Yes:
|
||
return
|
||
try:
|
||
count = self.store.clear_scan_history()
|
||
except (OSError, ValueError) as exc:
|
||
QMessageBox.critical(self, "Historique", str(exc))
|
||
return
|
||
self.baseline_hosts = None
|
||
self.baseline_date = ""
|
||
self.statusBar().showMessage(f"Historique effacé : {count} scan(s)", 5000)
|
||
|
||
def _identity_scope_for_current_target(self) -> str:
|
||
try:
|
||
return scan_identity_scope(self.target_edit.text().strip(), self.selected_interface())
|
||
except ValueError:
|
||
return self.current_identity_scope
|
||
|
||
@staticmethod
|
||
def _clear_remembered_fields(host: Host) -> None:
|
||
host.remembered_os_name = ""
|
||
host.remembered_os_accuracy = None
|
||
host.remembered_device_type = ""
|
||
host.remembered_os_source = ""
|
||
host.remembered_type_source = ""
|
||
host.remembered_os_seen_at = ""
|
||
host.remembered_type_seen_at = ""
|
||
host.remembered_match_score = 0
|
||
host.remembered_match_reason = ""
|
||
host.remembered_identity_kind = ""
|
||
|
||
def forget_selected_identification(self) -> None:
|
||
host = self._selected_host()
|
||
if host is None:
|
||
QMessageBox.information(self, "Identification", "Sélectionne d’abord un équipement.")
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Oublier l’identification",
|
||
f"Oublier l’OS/type mémorisé pour {host.hostname or host.ip} ?\n\n"
|
||
"Les résultats du scan, favoris, groupe et notes ne seront pas supprimés.",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.No,
|
||
)
|
||
if answer != QMessageBox.Yes:
|
||
return
|
||
shared = normalize_mac(host.mac) in shared_macs(self.hosts.values())
|
||
scope = self._identity_scope_for_current_target()
|
||
count = self.store.forget_identification_for_host(host, shared_mac=shared, scope=scope)
|
||
self._clear_remembered_fields(host)
|
||
self._refresh_tree()
|
||
self._update_detail_panel(host)
|
||
self.statusBar().showMessage(f"Identification oubliée ({count} entrée(s))", 5000)
|
||
|
||
def forget_current_scope_identifications(self) -> None:
|
||
scope = self._identity_scope_for_current_target()
|
||
if not scope:
|
||
QMessageBox.information(self, "Identification", "Impossible de déterminer le réseau courant.")
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Oublier les identifications du réseau",
|
||
f"Oublier toutes les identifications mémorisées pour :\n{scope} ?\n\n"
|
||
"L’historique des scans, les favoris, groupes et notes sont conservés.",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.No,
|
||
)
|
||
if answer != QMessageBox.Yes:
|
||
return
|
||
count = self.store.forget_identifications_for_scope(scope)
|
||
for host in self.hosts.values():
|
||
if not host.is_local:
|
||
self._clear_remembered_fields(host)
|
||
self._refresh_tree()
|
||
self._update_detail_panel(self._selected_host())
|
||
self.statusBar().showMessage(f"{count} identification(s) oubliée(s) pour le réseau courant", 5000)
|
||
|
||
def forget_all_identifications(self) -> None:
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Oublier toutes les identifications",
|
||
"Oublier tous les OS/types mémorisés ?\n\n"
|
||
"Cette action ne supprime ni l’historique des scans, ni les favoris, groupes ou notes.",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.No,
|
||
)
|
||
if answer != QMessageBox.Yes:
|
||
return
|
||
count = self.store.forget_all_identifications()
|
||
for host in self.hosts.values():
|
||
self._clear_remembered_fields(host)
|
||
self._refresh_tree()
|
||
self._update_detail_panel(self._selected_host())
|
||
self.statusBar().showMessage(f"Toutes les identifications ont été oubliées ({count} entrée(s))", 5000)
|
||
|
||
def show_tools_diagnostic(self) -> None:
|
||
diag = arp_scan_diagnostic()
|
||
ndiag = naabu_diagnostic()
|
||
pdiag = privilege_diagnostic()
|
||
cap = "OK — CAP_NET_RAW détectée" if diag.cap_net_raw is True else "ABSENTE / non détectée" if diag.cap_net_raw is False else "INCONNUE"
|
||
recommendation = ""
|
||
if diag.path and diag.cap_net_raw is False:
|
||
recommendation = f"<br><br><b>Alternative permanente :</b><br><code>sudo setcap cap_net_raw+p {diag.path}</code>"
|
||
QMessageBox.information(
|
||
self,
|
||
"Diagnostic des outils",
|
||
"<b>Outils réseau</b><br><br>"
|
||
f"Nmap : <code>{shutil.which('nmap') or 'introuvable'}</code><br>"
|
||
f"Naabu utilisateur : <code>{ndiag.user_path or 'introuvable'}</code><br>"
|
||
f" ↳ {ndiag.user_detail}<br>"
|
||
f"Naabu Admin SYN : <code>{ndiag.admin_path or 'indisponible'}</code><br>"
|
||
f" ↳ {ndiag.admin_detail}<br>"
|
||
f"iproute2 : <code>{shutil.which('ip') or 'introuvable'}</code><br>"
|
||
f"arp-scan : <code>{diag.path or 'introuvable'}</code><br>"
|
||
f"CAP_NET_RAW : <b>{cap}</b><br><br>"
|
||
"<b>Élévation Polkit</b><br>"
|
||
f"Mode : <b>{'ACTIF' if self.admin_mode else 'inactif'}</b><br>"
|
||
f"pkexec : <code>{pdiag.pkexec_path or 'introuvable'}</code><br>"
|
||
f"helper : <code>{pdiag.helper_path or 'introuvable'}</code><br>"
|
||
f"politique : <code>{pdiag.policy_path or 'introuvable'}</code><br>"
|
||
f"État : <b>{'OK' if pdiag.ready else 'INCOMPLET'}</b> — {pdiag.detail}{recommendation}",
|
||
)
|
||
|
||
def show_about(self) -> None:
|
||
QMessageBox.about(
|
||
self,
|
||
"À propos de LibreNet Scanner",
|
||
f"<b>LibreNet Scanner {__version__}</b><br><br>"
|
||
"Scanner réseau graphique libre pour Debian 13/KDE.<br>"
|
||
"Python + PySide6/Qt6, arp-scan, Naabu 2.6.1 provisionné et Nmap.<br><br>"
|
||
"V0.4 : interface KDE/Breeze épurée, vue Compacte/Détaillée, fiche équipement et actions contextuelles.<br><br>"
|
||
"Licence : GPL-3.0-or-later.",
|
||
)
|