Add Remote DNS
This commit is contained in:
@@ -235,6 +235,8 @@ cmds = [c for c in ( \
|
|||||||
w('ip') + ' link delete dev *' if w('ip') else '', \
|
w('ip') + ' link delete dev *' if w('ip') else '', \
|
||||||
w('resolvconf') + ' -d *' if w('resolvconf') else '', \
|
w('resolvconf') + ' -d *' if w('resolvconf') else '', \
|
||||||
w('resolvectl') + ' revert *' if w('resolvectl') else '', \
|
w('resolvectl') + ' revert *' if w('resolvectl') else '', \
|
||||||
|
w('resolvectl') + ' dns *' if w('resolvectl') else '', \
|
||||||
|
w('resolvectl') + ' domain *' if w('resolvectl') else '', \
|
||||||
w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \
|
w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \
|
||||||
) if c]; \
|
) if c]; \
|
||||||
rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \
|
rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \
|
||||||
|
|||||||
+65
-90
@@ -183,13 +183,23 @@ def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> s
|
|||||||
"""
|
"""
|
||||||
if not domains:
|
if not domains:
|
||||||
return ""
|
return ""
|
||||||
# Build namespace list for NRPT rule
|
clean = [d.strip().strip(".") for d in domains if d.strip()]
|
||||||
namespace_list = ", ".join(f"'{d.strip().strip('.')}'" for d in domains if d.strip())
|
# NRPT : un namespace sans point de tête ('h3adm.lan') ne matche que ce
|
||||||
|
# nom exact, jamais ses sous-domaines ; un namespace avec point de tête
|
||||||
|
# ('.h3adm.lan') ne matche que les sous-domaines, jamais le domaine lui-
|
||||||
|
# même. Les deux formes sont nécessaires pour couvrir toute la zone —
|
||||||
|
# comme `~domaine` sous systemd-resolved (Linux) couvre déjà les deux.
|
||||||
|
namespace_list = ", ".join(f"'{d}', '.{d}'" for d in clean)
|
||||||
return (
|
return (
|
||||||
f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; "
|
f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; "
|
||||||
f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; "
|
f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; "
|
||||||
f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) "
|
f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) "
|
||||||
f"-NameServer '{server}' -Comment 'WGSecure Split-DNS' -ErrorAction SilentlyContinue"
|
f"-NameServer '{server}' -Comment 'WGSecure Split-DNS'; "
|
||||||
|
# Sans ce contrôle, une erreur réelle d'Add-DnsClientNrptRule restait
|
||||||
|
# invisible : le script se terminait quand même avec le code 0, et
|
||||||
|
# `setup_split_dns` rapportait un succès alors que la règle n'avait
|
||||||
|
# jamais été posée.
|
||||||
|
"if (-not $?) { exit 1 }"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -217,76 +227,36 @@ def _systemd_resolved_available() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]:
|
def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]:
|
||||||
"""Configure le split-DNS sous Linux via systemd-resolved.
|
"""Configure le split-DNS sous Linux via systemd-resolved, par lien.
|
||||||
|
|
||||||
Crée un fichier dans /etc/systemd/resolved.conf.d/ pour router
|
Écrivait auparavant un fichier global dans /etc/systemd/resolved.conf.d/
|
||||||
les domaines vers le serveur DNS spécifié.
|
et redémarrait tout le service — deux privilèges que `make setup-sudoers`
|
||||||
|
ne couvrait pas (échec silencieux ou dialogue à chaque connexion), pour
|
||||||
|
un réglage qui entrait en concurrence avec la configuration éventuelle
|
||||||
|
d'autres liens et survivait à un arrêt brutal du tunnel.
|
||||||
|
|
||||||
Note: Le redémarrage de systemd-resolved est nécessaire pour appliquer
|
`resolvectl dns`/`resolvectl domain` posent la même route mais rattachée
|
||||||
la configuration, mais il est effectué de manière asynchrone pour
|
à l'interface WireGuard elle-même — comme `wg-quick` le fait déjà pour le
|
||||||
éviter de bloquer le handshake WireGuard.
|
DNS non split. `resolvectl revert <iface>` (déjà autorisé sans mot de
|
||||||
|
passe, cf. `make setup-sudoers`) l'annule d'un coup, et systemd-resolved
|
||||||
Args:
|
la retire de lui-même dès que l'interface disparaît : plus de résidu à
|
||||||
server: Adresse IP du serveur DNS (ex: 192.168.1.210)
|
surveiller après un crash.
|
||||||
domains: Liste de domaines (ex: ["local", "internal"])
|
|
||||||
iface: Nom de l'interface WireGuard
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(success, message)
|
|
||||||
"""
|
"""
|
||||||
|
if not shutil.which("resolvectl"):
|
||||||
|
return False, "resolvectl introuvable : split-DNS indisponible sur ce système"
|
||||||
if not _systemd_resolved_available():
|
if not _systemd_resolved_available():
|
||||||
return False, "systemd-resolved non disponible. Utilisez resolvconf ou configurez manuellement."
|
return False, "systemd-resolved non actif : split-DNS indisponible"
|
||||||
|
|
||||||
# Créer le nom du fichier de config
|
|
||||||
config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf"
|
|
||||||
|
|
||||||
# Contenu du fichier
|
|
||||||
domain_str = ", ".join(f"~{d.strip()}" for d in domains if d.strip())
|
|
||||||
content = f"[Resolve]\nDNS={server}\nDomains={domain_str}\n"
|
|
||||||
|
|
||||||
# Écrire le fichier (nécessite sudo)
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Créer un fichier temporaire
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
|
|
||||||
f.write(content)
|
|
||||||
temp_path = f.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Copier le fichier temporaire vers /etc/systemd/resolved.conf.d/
|
|
||||||
code, out, err = run_privileged(
|
|
||||||
["sudo", "cp", temp_path, config_file],
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
|
code, out, err = run_privileged(["resolvectl", "dns", iface, server], timeout=10)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return False, f"Échec de la copie du fichier: {err or out}"
|
return False, f"Échec de configuration du DNS distant : {err or out or 'erreur inconnue'}"
|
||||||
|
|
||||||
# Redémarrer systemd-resolved en arrière-plan pour ne pas bloquer
|
routing_domains = [f"~{d}" for d in domains]
|
||||||
# Utiliser systemd-run pour un redémarrage asynchrone
|
code, out, err = run_privileged(["resolvectl", "domain", iface, *routing_domains], timeout=10)
|
||||||
restart_cmd = ["sudo", "systemctl", "restart", "systemd-resolved"]
|
if code != 0:
|
||||||
try:
|
return False, f"Échec de configuration du domaine distant : {err or out or 'erreur inconnue'}"
|
||||||
# Lancer en arrière-plan avec nohup pour éviter de bloquer
|
|
||||||
subprocess.Popen(
|
|
||||||
restart_cmd,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass # Ignorer l'erreur, le fichier est déjà en place
|
|
||||||
|
|
||||||
return True, (
|
return True, f"Split-DNS configuré pour {server} (domaines : {', '.join(domains)})"
|
||||||
f"Split-DNS configuré pour {server} (domaines: {', '.join(domains)}). "
|
|
||||||
f"Redémarrez systemd-resolved manuellement si nécessaire: "
|
|
||||||
f"`sudo systemctl restart systemd-resolved`"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(temp_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def setup_split_dns(cfg: Config) -> tuple[bool, str]:
|
def setup_split_dns(cfg: Config) -> tuple[bool, str]:
|
||||||
@@ -334,34 +304,19 @@ def setup_split_dns(cfg: Config) -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]:
|
def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]:
|
||||||
"""Supprime la configuration split-DNS sous Linux.
|
"""Annule la config split-DNS par-lien de cette interface.
|
||||||
|
|
||||||
Note: Ne redémarre PAS systemd-resolved pour éviter de casser
|
`force_cleanup()` appelle ceci après avoir déjà supprimé l'interface la
|
||||||
la résolution DNS pendant une reconnexion. Le service sera
|
plupart du temps (systemd-resolved retire alors sa config par-lien tout
|
||||||
redémarré manuellement par l'utilisateur si nécessaire.
|
seul) : `resolvectl revert` échoue simplement sur une interface déjà
|
||||||
|
partie, ce qui n'est pas une erreur — rien à nettoyer, pas un échec.
|
||||||
Args:
|
|
||||||
iface: Nom de l'interface WireGuard
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(success, message)
|
|
||||||
"""
|
"""
|
||||||
config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf"
|
if not shutil.which("resolvectl"):
|
||||||
|
return True, ""
|
||||||
# Supprimer le fichier de config (nécessite sudo)
|
code, out, err = run_privileged(["resolvectl", "revert", iface], timeout=10)
|
||||||
import os
|
if code == 0:
|
||||||
if not os.path.exists(config_file):
|
return True, "Configuration split-DNS retirée"
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
code, out, err = run_privileged(
|
|
||||||
["sudo", "rm", "-f", config_file],
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if code != 0:
|
|
||||||
return False, f"Échec de la suppression du fichier split-DNS: {err or out}"
|
|
||||||
|
|
||||||
return True, "Configuration split-DNS supprimée (redémarrez systemd-resolved si nécessaire)"
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_split_dns(cfg: Config) -> tuple[bool, str]:
|
def cleanup_split_dns(cfg: Config) -> tuple[bool, str]:
|
||||||
@@ -488,6 +443,26 @@ def resolution_works(timeout: float = 3.0) -> bool:
|
|||||||
socket.setdefaulttimeout(old)
|
socket.setdefaulttimeout(old)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_host(host: str, timeout: float = 3.0) -> tuple[bool, list[str]]:
|
||||||
|
"""Résout un nom via le résolveur système. Retourne (succès, IPs uniques).
|
||||||
|
|
||||||
|
Sert au split-DNS : `resolution_works()` ne confirme qu'un résolveur
|
||||||
|
quelconque répond, jamais que les noms du réseau distant (le domaine
|
||||||
|
configuré en split-DNS, ou un hôte à l'intérieur) résolvent bien vers ce
|
||||||
|
réseau plutôt que vers un NXDOMAIN ou une réponse publique inattendue.
|
||||||
|
"""
|
||||||
|
old = socket.getdefaulttimeout()
|
||||||
|
socket.setdefaulttimeout(timeout)
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
ips = list(dict.fromkeys(info[4][0] for info in infos))
|
||||||
|
return True, ips
|
||||||
|
except OSError:
|
||||||
|
return False, []
|
||||||
|
finally:
|
||||||
|
socket.setdefaulttimeout(old)
|
||||||
|
|
||||||
|
|
||||||
def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]:
|
def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]:
|
||||||
"""Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne.
|
"""Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne.
|
||||||
|
|
||||||
|
|||||||
+34
-7
@@ -268,6 +268,20 @@ def _wait_connected(cfg: Config, timeout: int = 15) -> bool:
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_connect_message(cfg: Config) -> str:
|
||||||
|
"""Message de succès de connexion, complété d'un avertissement si le
|
||||||
|
split-DNS configuré n'a pas pu être appliqué.
|
||||||
|
|
||||||
|
Son échec était auparavant ignoré silencieusement (`setup_split_dns`
|
||||||
|
appelé sans regarder son retour) : le tunnel se déclarait « activé »
|
||||||
|
même quand les noms du réseau distant ne se résolvaient jamais.
|
||||||
|
"""
|
||||||
|
ok, msg = dns_util.setup_split_dns(cfg)
|
||||||
|
if not ok:
|
||||||
|
return f"Tunnel WireGuard activé\n⚠ {msg}"
|
||||||
|
return "Tunnel WireGuard activé"
|
||||||
|
|
||||||
|
|
||||||
def connect(cfg: Config) -> tuple[bool, str]:
|
def connect(cfg: Config) -> tuple[bool, str]:
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return False, "WireGuard non configuré. Ouvrez le panneau Admin."
|
return False, "WireGuard non configuré. Ouvrez le panneau Admin."
|
||||||
@@ -302,9 +316,7 @@ def connect(cfg: Config) -> tuple[bool, str]:
|
|||||||
# démarrage effectif du tunnel est asynchrone. Tester l'état
|
# démarrage effectif du tunnel est asynchrone. Tester l'état
|
||||||
# immédiatement conclurait « échec » sur un tunnel qui monte.
|
# immédiatement conclurait « échec » sur un tunnel qui monte.
|
||||||
if code == 0 and _wait_connected(cfg, timeout=15):
|
if code == 0 and _wait_connected(cfg, timeout=15):
|
||||||
# Configurer le split-DNS si un serveur dédié est défini
|
return True, _post_connect_message(cfg)
|
||||||
dns_util.setup_split_dns(cfg)
|
|
||||||
return True, "Tunnel WireGuard activé"
|
|
||||||
force_cleanup(cfg)
|
force_cleanup(cfg)
|
||||||
return False, err or f"Échec de l'installation du tunnel (code {code})"
|
return False, err or f"Échec de l'installation du tunnel (code {code})"
|
||||||
|
|
||||||
@@ -320,9 +332,7 @@ def connect(cfg: Config) -> tuple[bool, str]:
|
|||||||
# Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement
|
# Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement
|
||||||
code, _, err = run_privileged(["wg-quick", "up", name], timeout=90)
|
code, _, err = run_privileged(["wg-quick", "up", name], timeout=90)
|
||||||
if code == 0:
|
if code == 0:
|
||||||
# Configurer le split-DNS si un serveur dédié est défini
|
return True, _post_connect_message(cfg)
|
||||||
dns_util.setup_split_dns(cfg)
|
|
||||||
return True, "Tunnel WireGuard activé"
|
|
||||||
|
|
||||||
# Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le
|
# Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le
|
||||||
# dépassement de délai avant son trap de nettoyage). On démonte
|
# dépassement de délai avant son trap de nettoyage). On démonte
|
||||||
@@ -862,9 +872,26 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
|
|||||||
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
|
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
|
||||||
"Plus aucun nom ne se résout — utilisez "
|
"Plus aucun nom ne se résout — utilisez "
|
||||||
"« Réparer le DNS »"))
|
"« Réparer le DNS »"))
|
||||||
else:
|
return steps
|
||||||
steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle"))
|
steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle"))
|
||||||
|
|
||||||
|
# Un résolveur qui répond ne dit rien sur le split-DNS lui-même : le nom
|
||||||
|
# peut très bien se résoudre... vers une réponse publique inattendue au
|
||||||
|
# lieu du réseau distant. On vérifie donc explicitement le(s) domaine(s)
|
||||||
|
# configurés, pas seulement qu'une résolution quelconque fonctionne.
|
||||||
|
domains = wg.get("split_dns_domains") or []
|
||||||
|
for domain in domains:
|
||||||
|
ok, ips = dns_util.resolve_host(domain, timeout=3.0)
|
||||||
|
if ok:
|
||||||
|
steps.append(_step(_OK, f"Résolution distante ({domain})",
|
||||||
|
"→ " + ", ".join(ips)))
|
||||||
|
else:
|
||||||
|
steps.append(_step(
|
||||||
|
_WARN, f"Résolution distante ({domain})",
|
||||||
|
"Ne se résout pas — vérifiez le serveur DNS distant "
|
||||||
|
f"({wg.get('split_dns_server', '?')}) et les règles NRPT/"
|
||||||
|
"systemd-resolved du split-DNS"))
|
||||||
|
|
||||||
return steps
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+422
-270
File diff suppressed because it is too large
Load Diff
+16
-19
@@ -8,28 +8,25 @@ from PyQt6.QtCore import Qt
|
|||||||
from PyQt6.QtGui import QColor, QFont
|
from PyQt6.QtGui import QColor, QFont
|
||||||
|
|
||||||
from app.core import history as hist
|
from app.core import history as hist
|
||||||
|
from app.ui import theme
|
||||||
|
|
||||||
_DARK = "#1c2833"
|
|
||||||
_DARK2 = "#17202a"
|
|
||||||
|
|
||||||
_CSS = f"""
|
_CSS = f"""
|
||||||
QDialog {{ background: {_DARK2}; }}
|
QDialog {{ background: {theme.BG_SUNKEN}; }}
|
||||||
QLabel {{ color: white; background: transparent; }}
|
QLabel {{ color: {theme.TEXT}; background: transparent; }}
|
||||||
QTableWidget {{
|
QTableWidget {{
|
||||||
background: {_DARK}; color: white; gridline-color: #2e4057;
|
background: {theme.BG}; color: {theme.TEXT}; gridline-color: {theme.BG_RAISED};
|
||||||
border: none; font-size: 11px;
|
border: none; font-size: 11px;
|
||||||
}}
|
}}
|
||||||
QHeaderView::section {{
|
QHeaderView::section {{
|
||||||
background: #2e4057; color: white; padding: 5px;
|
background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px;
|
||||||
border: none; font-weight: bold; font-size: 11px;
|
border: none; font-weight: bold; font-size: 11px;
|
||||||
}}
|
}}
|
||||||
QTableWidget::item:selected {{ background: #2471a3; }}
|
QTableWidget::item:selected {{ background: {theme.ACCENT}; }}
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: #2e4057; color: white; border-radius: 5px;
|
background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px;
|
||||||
padding: 6px 14px; border: none;
|
padding: 6px 14px; border: none;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #3d5166; }}
|
QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +48,7 @@ class HistoryDialog(QDialog):
|
|||||||
banner = QLabel(" 📊 Historique des sessions VPN")
|
banner = QLabel(" 📊 Historique des sessions VPN")
|
||||||
banner.setFixedHeight(40)
|
banner.setFixedHeight(40)
|
||||||
banner.setStyleSheet(
|
banner.setStyleSheet(
|
||||||
f"background: {_DARK2}; color: white; font-size: 14px; font-weight: bold;"
|
f"background: {theme.BG_SUNKEN}; color: {theme.TEXT}; font-size: 14px; font-weight: bold;"
|
||||||
)
|
)
|
||||||
layout.addWidget(banner)
|
layout.addWidget(banner)
|
||||||
|
|
||||||
@@ -68,14 +65,14 @@ class HistoryDialog(QDialog):
|
|||||||
self._table.setAlternatingRowColors(True)
|
self._table.setAlternatingRowColors(True)
|
||||||
self._table.setStyleSheet(
|
self._table.setStyleSheet(
|
||||||
_CSS +
|
_CSS +
|
||||||
"QTableWidget { alternate-background-color: #212f3d; }"
|
f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}"
|
||||||
)
|
)
|
||||||
layout.addWidget(self._table)
|
layout.addWidget(self._table)
|
||||||
|
|
||||||
# Barre infos + boutons
|
# Barre infos + boutons
|
||||||
bar = QLabel("")
|
bar = QLabel("")
|
||||||
bar.setFixedHeight(1)
|
bar.setFixedHeight(1)
|
||||||
bar.setStyleSheet(f"background: #2e4057;")
|
bar.setStyleSheet(f"background: {theme.BG_RAISED};")
|
||||||
layout.addWidget(bar)
|
layout.addWidget(bar)
|
||||||
|
|
||||||
btn_row_w = QVBoxLayout()
|
btn_row_w = QVBoxLayout()
|
||||||
@@ -85,15 +82,15 @@ class HistoryDialog(QDialog):
|
|||||||
bottom.setContentsMargins(14, 8, 14, 12)
|
bottom.setContentsMargins(14, 8, 14, 12)
|
||||||
|
|
||||||
self._summary_lbl = QLabel("")
|
self._summary_lbl = QLabel("")
|
||||||
self._summary_lbl.setStyleSheet("color: #5d6d7e; font-size: 11px;")
|
self._summary_lbl.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
|
||||||
bottom.addWidget(self._summary_lbl)
|
bottom.addWidget(self._summary_lbl)
|
||||||
bottom.addStretch()
|
bottom.addStretch()
|
||||||
|
|
||||||
btn_clear = QPushButton("🗑️ Effacer l'historique")
|
btn_clear = QPushButton("🗑️ Effacer l'historique")
|
||||||
btn_clear.setStyleSheet(
|
btn_clear.setStyleSheet(
|
||||||
"QPushButton { background: #6e2e1c; color: white; border-radius: 5px;"
|
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; border-radius: 5px;"
|
||||||
" padding: 6px 14px; border: none; }"
|
" padding: 6px 14px; border: none; }"
|
||||||
"QPushButton:hover { background: #922b21; }"
|
"QPushButton:hover { background: #e74c3c; }"
|
||||||
)
|
)
|
||||||
btn_clear.clicked.connect(self._clear)
|
btn_clear.clicked.connect(self._clear)
|
||||||
btn_close = QPushButton("Fermer")
|
btn_close = QPushButton("Fermer")
|
||||||
@@ -102,7 +99,7 @@ class HistoryDialog(QDialog):
|
|||||||
bottom.addWidget(btn_close)
|
bottom.addWidget(btn_close)
|
||||||
|
|
||||||
bottom_w = QWidget()
|
bottom_w = QWidget()
|
||||||
bottom_w.setStyleSheet(f"background: {_DARK2};")
|
bottom_w.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||||||
bottom_w.setLayout(bottom)
|
bottom_w.setLayout(bottom)
|
||||||
layout.addWidget(bottom_w)
|
layout.addWidget(bottom_w)
|
||||||
|
|
||||||
@@ -114,7 +111,7 @@ class HistoryDialog(QDialog):
|
|||||||
for row, s in enumerate(sessions):
|
for row, s in enumerate(sessions):
|
||||||
self._table.insertRow(row)
|
self._table.insertRow(row)
|
||||||
end = s.get("end")
|
end = s.get("end")
|
||||||
color = "#a9dfbf" if end else "#f9e79f" # vert=terminée, jaune=en cours
|
color = theme.OK_TEXT if end else theme.WARN_TEXT # vert=terminée, jaune=en cours
|
||||||
|
|
||||||
cells = [
|
cells = [
|
||||||
s.get("start", "—"),
|
s.get("start", "—"),
|
||||||
|
|||||||
+251
-173
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
|
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
|
||||||
QLineEdit, QListWidget, QListWidgetItem, QApplication,
|
QLineEdit, QListWidget, QListWidgetItem,
|
||||||
QComboBox, QSizePolicy,
|
QComboBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer, QDateTime
|
from PyQt6.QtCore import Qt, QTimer, QDateTime
|
||||||
from PyQt6.QtGui import QFont, QCloseEvent, QColor
|
from PyQt6.QtGui import QFont, QCloseEvent, QColor
|
||||||
@@ -20,24 +20,7 @@ from app.ui.admin_window import AdminWindow
|
|||||||
from app.ui.bw_graph import BandwidthGraph
|
from app.ui.bw_graph import BandwidthGraph
|
||||||
from app.ui.history_dialog import HistoryDialog
|
from app.ui.history_dialog import HistoryDialog
|
||||||
from app.ui import icons
|
from app.ui import icons
|
||||||
|
from app.ui import theme
|
||||||
_DARK = "#1c2833"
|
|
||||||
_DARK2 = "#17202a"
|
|
||||||
_CARD = "#212f3d"
|
|
||||||
|
|
||||||
_LOG_COLORS = {
|
|
||||||
"success": "#a9dfbf",
|
|
||||||
"error": "#f1948a",
|
|
||||||
"warning": "#f9e79f",
|
|
||||||
"info": "#aed6f1",
|
|
||||||
}
|
|
||||||
|
|
||||||
_PING_CSS = {
|
|
||||||
"good": "background:#1e8449; color:white;",
|
|
||||||
"medium": "background:#d4ac0d; color:black;",
|
|
||||||
"bad": "background:#922b21; color:white;",
|
|
||||||
"offline": "background:#2e4057; color:#5d6d7e;",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -58,7 +41,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._warned_foreign: set[str] = set()
|
self._warned_foreign: set[str] = set()
|
||||||
|
|
||||||
self.setWindowTitle("WGSecure")
|
self.setWindowTitle("WGSecure")
|
||||||
self.setFixedSize(430, 680)
|
self.setFixedWidth(430)
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(
|
||||||
Qt.WindowType.Window
|
Qt.WindowType.Window
|
||||||
| Qt.WindowType.WindowTitleHint
|
| Qt.WindowType.WindowTitleHint
|
||||||
@@ -67,6 +50,11 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.setWindowIcon(icons.icon_app())
|
self.setWindowIcon(icons.icon_app())
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
# Repliée par défaut : au lancement, seuls statut et action de
|
||||||
|
# connexion sont utiles — le détail ne sert qu'à qui va le chercher.
|
||||||
|
self._details_expanded = False
|
||||||
|
self._details.setVisible(False)
|
||||||
|
self._sync_window_height()
|
||||||
|
|
||||||
# Timers
|
# Timers
|
||||||
self._status_timer = QTimer(self)
|
self._status_timer = QTimer(self)
|
||||||
@@ -98,198 +86,287 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Construction UI
|
# Construction UI
|
||||||
|
#
|
||||||
|
# Ordre pensé pour l'usage réel : statut et action de connexion sont
|
||||||
|
# immédiats (pas besoin de traverser la config ou le journal pour les
|
||||||
|
# atteindre) ; viennent ensuite les valeurs dynamiques (transfert,
|
||||||
|
# handshake), puis la config statique — consultée rarement une fois
|
||||||
|
# réglée — et enfin bande passante / journal, les plus techniques.
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
central.setStyleSheet(f"background: {_DARK};")
|
central.setStyleSheet(f"background: {theme.BG};")
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
root = QVBoxLayout(central)
|
root = QVBoxLayout(central)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
root.setSpacing(0)
|
root.setSpacing(0)
|
||||||
|
|
||||||
# ── En-tête ──────────────────────────────────────────────────
|
root.addWidget(self._build_header())
|
||||||
|
root.addWidget(self._build_status_action())
|
||||||
|
root.addWidget(self._build_details())
|
||||||
|
|
||||||
|
# ── Pied de page ─────────────────────────────────────────────────
|
||||||
|
root.addWidget(self._build_footer())
|
||||||
|
|
||||||
|
# ── Section repliable : tout ce qui n'est pas statut/action ──────────
|
||||||
|
def _build_details(self) -> QWidget:
|
||||||
|
self._details = QWidget()
|
||||||
|
d = QVBoxLayout(self._details)
|
||||||
|
d.setContentsMargins(0, 0, 0, 0)
|
||||||
|
d.setSpacing(0)
|
||||||
|
|
||||||
|
d.addWidget(self._hsep())
|
||||||
|
d.addWidget(self._build_dynamic_details())
|
||||||
|
d.addWidget(self._hsep())
|
||||||
|
d.addWidget(self._build_config_summary())
|
||||||
|
d.addWidget(self._hsep())
|
||||||
|
|
||||||
|
# ── Bande passante ───────────────────────────────────────────────
|
||||||
|
d.addWidget(self._section_header("📊 Bande passante"))
|
||||||
|
self._bw_graph = BandwidthGraph()
|
||||||
|
self._bw_graph.setFixedHeight(75)
|
||||||
|
d.addWidget(self._bw_graph)
|
||||||
|
d.addWidget(self._hsep())
|
||||||
|
|
||||||
|
# ── Journal ───────────────────────────────────────────────────────
|
||||||
|
d.addWidget(self._section_header("📋 Journal", btn_text="Effacer",
|
||||||
|
btn_slot=self._clear_log))
|
||||||
|
self._log_list = QListWidget()
|
||||||
|
self._log_list.setFixedHeight(78)
|
||||||
|
self._log_list.setStyleSheet(
|
||||||
|
f"QListWidget {{ background: {theme.BG_SUNKEN}; color: {theme.ACCENT_LIGHT};"
|
||||||
|
" border: none; font-size: 10px; font-family: Courier; }"
|
||||||
|
"QListWidget::item { padding: 1px 6px; }"
|
||||||
|
)
|
||||||
|
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
d.addWidget(self._log_list)
|
||||||
|
d.addWidget(self._hsep())
|
||||||
|
|
||||||
|
return self._details
|
||||||
|
|
||||||
|
# ── En-tête : identité + profil ─────────────────────────────────────
|
||||||
|
def _build_header(self) -> QWidget:
|
||||||
header = QWidget()
|
header = QWidget()
|
||||||
header.setFixedHeight(72)
|
header.setFixedHeight(64)
|
||||||
header.setStyleSheet(f"background: {_DARK2};")
|
header.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||||||
h = QHBoxLayout(header)
|
h = QHBoxLayout(header)
|
||||||
h.setContentsMargins(14, 8, 14, 8)
|
h.setContentsMargins(14, 8, 14, 8)
|
||||||
|
|
||||||
self._icon_label = QLabel()
|
self._icon_label = QLabel()
|
||||||
self._icon_label.setFixedSize(40, 40)
|
self._icon_label.setFixedSize(36, 36)
|
||||||
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
h.addWidget(self._icon_label)
|
h.addWidget(self._icon_label)
|
||||||
|
|
||||||
title_col = QVBoxLayout()
|
|
||||||
title_col.setSpacing(1)
|
|
||||||
title_lbl = QLabel("WGSecure")
|
title_lbl = QLabel("WGSecure")
|
||||||
title_lbl.setStyleSheet(
|
title_lbl.setStyleSheet(
|
||||||
"color: white; font-size: 16px; font-weight: bold; background: transparent;"
|
f"color: {theme.TEXT}; font-size: 16px; font-weight: bold; background: transparent;"
|
||||||
)
|
)
|
||||||
self._profile_combo = QComboBox()
|
h.addWidget(title_lbl)
|
||||||
self._profile_combo.setFixedHeight(22)
|
|
||||||
self._profile_combo.setStyleSheet(
|
|
||||||
"QComboBox { background: #2e4057; color: #aed6f1; border: none;"
|
|
||||||
" border-radius: 3px; font-size: 10px; padding: 0 6px; }"
|
|
||||||
"QComboBox::drop-down { border: none; }"
|
|
||||||
"QComboBox QAbstractItemView { background: #2e4057; color: white; }"
|
|
||||||
)
|
|
||||||
self._profile_combo.currentTextChanged.connect(self._on_profile_changed)
|
|
||||||
title_col.addWidget(title_lbl)
|
|
||||||
title_col.addWidget(self._profile_combo)
|
|
||||||
h.addLayout(title_col)
|
|
||||||
h.addStretch()
|
h.addStretch()
|
||||||
|
|
||||||
# Ping badge
|
profile_col = QVBoxLayout()
|
||||||
|
profile_col.setSpacing(1)
|
||||||
|
profile_lbl = QLabel("Profil actif")
|
||||||
|
profile_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
profile_lbl.setStyleSheet(
|
||||||
|
f"color: {theme.TEXT_FAINT}; font-size: 9px; background: transparent;"
|
||||||
|
)
|
||||||
|
profile_col.addWidget(profile_lbl)
|
||||||
|
|
||||||
|
self._profile_combo = QComboBox()
|
||||||
|
self._profile_combo.setFixedHeight(22)
|
||||||
|
self._profile_combo.setMinimumWidth(150)
|
||||||
|
self._profile_combo.setStyleSheet(
|
||||||
|
f"QComboBox {{ background: {theme.BG_RAISED}; color: {theme.ACCENT_LIGHT};"
|
||||||
|
" border: none; border-radius: 3px; font-size: 10px; padding: 0 6px; }"
|
||||||
|
"QComboBox::drop-down { border: none; }"
|
||||||
|
f"QComboBox QAbstractItemView {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
|
||||||
|
)
|
||||||
|
self._profile_combo.currentIndexChanged.connect(self._on_profile_changed)
|
||||||
|
profile_col.addWidget(self._profile_combo)
|
||||||
|
h.addLayout(profile_col)
|
||||||
|
|
||||||
|
return header
|
||||||
|
|
||||||
|
# ── Statut + action principale ───────────────────────────────────────
|
||||||
|
def _build_status_action(self) -> QWidget:
|
||||||
|
status_area = QWidget()
|
||||||
|
status_area.setStyleSheet(f"background: {theme.BG_RAISED};")
|
||||||
|
s = QVBoxLayout(status_area)
|
||||||
|
s.setContentsMargins(14, 12, 14, 12)
|
||||||
|
s.setSpacing(8)
|
||||||
|
|
||||||
|
top_row = QHBoxLayout()
|
||||||
|
top_row.setSpacing(8)
|
||||||
|
|
||||||
|
status_col = QVBoxLayout()
|
||||||
|
status_col.setSpacing(1)
|
||||||
|
self._status_badge = QLabel("● Déconnecté")
|
||||||
|
self._status_badge.setStyleSheet(
|
||||||
|
f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
|
||||||
|
)
|
||||||
|
status_col.addWidget(self._status_badge)
|
||||||
|
self._duration_label = QLabel("")
|
||||||
|
self._duration_label.setStyleSheet(
|
||||||
|
f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
|
||||||
|
)
|
||||||
|
status_col.addWidget(self._duration_label)
|
||||||
|
top_row.addLayout(status_col)
|
||||||
|
top_row.addStretch()
|
||||||
|
|
||||||
|
# Badge de ping : santé de cette connexion, donc juste à côté de son
|
||||||
|
# statut plutôt que dans l'en-tête d'identité.
|
||||||
self._ping_badge = QLabel("— ms")
|
self._ping_badge = QLabel("— ms")
|
||||||
self._ping_badge.setFixedSize(62, 22)
|
self._ping_badge.setFixedSize(66, 24)
|
||||||
self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self._ping_badge.setStyleSheet(
|
self._ping_badge.setStyleSheet(
|
||||||
f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}"
|
f"border-radius: 12px; font-size: 10px; font-weight: bold;"
|
||||||
|
f" {theme.PING_CSS['offline']}"
|
||||||
)
|
)
|
||||||
h.addWidget(self._ping_badge)
|
top_row.addWidget(self._ping_badge, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
s.addLayout(top_row)
|
||||||
|
|
||||||
# Copier clé publique
|
self._btn_connect = QPushButton("Se connecter")
|
||||||
self._btn_copy_key = QPushButton("📋 Clé pub.")
|
self._btn_connect.setFixedHeight(44)
|
||||||
self._btn_copy_key.setFixedHeight(26)
|
self._btn_connect.setStyleSheet(
|
||||||
self._btn_copy_key.setStyleSheet(
|
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
||||||
"QPushButton { background: #2471a3; color: white; border-radius: 5px;"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
" font-size: 10px; padding: 0 8px; border: none; }"
|
"QPushButton:hover { background: #27ae60; }"
|
||||||
"QPushButton:hover { background: #1a5276; }"
|
f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
|
||||||
"QPushButton:disabled { background: #2e4057; color: #5d6d7e; }"
|
|
||||||
)
|
)
|
||||||
self._btn_copy_key.clicked.connect(self._copy_public_key)
|
self._btn_connect.clicked.connect(self._on_connect)
|
||||||
h.addWidget(self._btn_copy_key)
|
s.addWidget(self._btn_connect)
|
||||||
root.addWidget(header)
|
|
||||||
|
|
||||||
# ── Statut ───────────────────────────────────────────────────
|
# Repli/dépli du détail (transfert, config, bande passante, journal) :
|
||||||
status_area = QWidget()
|
# rattaché à l'action qu'il détaille, pas à l'en-tête.
|
||||||
status_area.setFixedHeight(80)
|
self._btn_toggle_details = QPushButton("▾ Afficher le détail")
|
||||||
status_area.setStyleSheet(f"background: {_CARD};")
|
self._btn_toggle_details.setFixedHeight(20)
|
||||||
s = QVBoxLayout(status_area)
|
self._btn_toggle_details.setStyleSheet(
|
||||||
s.setContentsMargins(10, 6, 10, 6)
|
f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
|
||||||
s.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
" font-size: 10px; border: none; }"
|
||||||
|
f"QPushButton:hover {{ color: {theme.TEXT}; }}"
|
||||||
self._status_badge = QLabel("● Déconnecté")
|
|
||||||
self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
self._status_badge.setStyleSheet(
|
|
||||||
"color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;"
|
|
||||||
)
|
)
|
||||||
s.addWidget(self._status_badge)
|
self._btn_toggle_details.clicked.connect(self._toggle_details)
|
||||||
|
s.addWidget(self._btn_toggle_details)
|
||||||
|
|
||||||
self._duration_label = QLabel("")
|
return status_area
|
||||||
self._duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
self._duration_label.setStyleSheet(
|
|
||||||
"color: #5d6d7e; font-size: 11px; background: transparent;"
|
|
||||||
)
|
|
||||||
s.addWidget(self._duration_label)
|
|
||||||
root.addWidget(status_area)
|
|
||||||
|
|
||||||
root.addWidget(self._hsep())
|
# ── Détails dynamiques : transfert + handshake ───────────────────────
|
||||||
|
def _build_dynamic_details(self) -> QWidget:
|
||||||
# ── Infos ─────────────────────────────────────────────────────
|
frame = QWidget()
|
||||||
info_frame = QFrame()
|
frame.setStyleSheet(f"background: {theme.BG};")
|
||||||
info_frame.setStyleSheet(f"background: {_DARK};")
|
dl = QHBoxLayout(frame)
|
||||||
info_frame.setAutoFillBackground(True)
|
dl.setContentsMargins(14, 8, 14, 8)
|
||||||
il = QVBoxLayout(info_frame)
|
dl.setSpacing(16)
|
||||||
il.setContentsMargins(14, 8, 14, 8)
|
|
||||||
il.setSpacing(5)
|
|
||||||
|
|
||||||
self._info_labels: dict[str, QLabel] = {}
|
self._info_labels: dict[str, QLabel] = {}
|
||||||
|
for key, icon_txt, label in [
|
||||||
|
("rx_tx", "↕️", "Transfert"),
|
||||||
|
("handshake", "🤝", "Handshake"),
|
||||||
|
]:
|
||||||
|
col = QVBoxLayout()
|
||||||
|
col.setSpacing(2)
|
||||||
|
hdr = QLabel(f"{icon_txt} {label}")
|
||||||
|
hdr.setStyleSheet(
|
||||||
|
f"color: {theme.TEXT_FAINT}; font-size: 10px; background: transparent;"
|
||||||
|
)
|
||||||
|
col.addWidget(hdr)
|
||||||
|
val = self._lbl("—", color=theme.ACCENT_LIGHT, size=13)
|
||||||
|
val.setWordWrap(True)
|
||||||
|
self._info_labels[key] = val
|
||||||
|
col.addWidget(val)
|
||||||
|
dl.addLayout(col, 1)
|
||||||
|
|
||||||
|
return frame
|
||||||
|
|
||||||
|
# ── Config statique (repliée visuellement) ───────────────────────────
|
||||||
|
def _build_config_summary(self) -> QWidget:
|
||||||
|
frame = QFrame()
|
||||||
|
frame.setStyleSheet(f"background: {theme.BG};")
|
||||||
|
il = QVBoxLayout(frame)
|
||||||
|
il.setContentsMargins(14, 8, 14, 8)
|
||||||
|
il.setSpacing(4)
|
||||||
|
|
||||||
for key, icon_txt, label in [
|
for key, icon_txt, label in [
|
||||||
("server", "🌐", "Serveur"),
|
("server", "🌐", "Serveur"),
|
||||||
("iface", "🔌", "Interface"),
|
("iface", "🔌", "Interface"),
|
||||||
("addr", "📍", "Adresse IP"),
|
("addr", "📍", "Adresse IP"),
|
||||||
("mfa", "🔐", "MFA"),
|
("mfa", "🔐", "MFA"),
|
||||||
("rx_tx", "↕️ ", "Transfert"),
|
|
||||||
("handshake", "🤝", "Handshake"),
|
|
||||||
]:
|
]:
|
||||||
rw = QWidget()
|
rw = QWidget()
|
||||||
rw.setStyleSheet("background: transparent;")
|
rw.setStyleSheet("background: transparent;")
|
||||||
rl = QHBoxLayout(rw)
|
rl = QHBoxLayout(rw)
|
||||||
rl.setContentsMargins(0, 0, 0, 0)
|
rl.setContentsMargins(0, 0, 0, 0)
|
||||||
rl.setSpacing(5)
|
rl.setSpacing(5)
|
||||||
rl.addWidget(self._lbl(icon_txt, fixed=20, color="#5d6d7e", size=11))
|
rl.addWidget(self._lbl(icon_txt, fixed=18, color=theme.TEXT_FAINT, size=10))
|
||||||
rl.addWidget(self._lbl(f"{label} :", fixed=100, color="#5d6d7e", size=12))
|
rl.addWidget(self._lbl(f"{label} :", fixed=90, color=theme.TEXT_FAINT, size=11))
|
||||||
val = self._lbl("—", color="#aed6f1", size=12)
|
val = self._lbl("—", color=theme.TEXT_MUTED, size=11)
|
||||||
val.setWordWrap(True)
|
val.setWordWrap(True)
|
||||||
self._info_labels[key] = val
|
self._info_labels[key] = val
|
||||||
rl.addWidget(val, 1)
|
rl.addWidget(val, 1)
|
||||||
il.addWidget(rw)
|
il.addWidget(rw)
|
||||||
|
|
||||||
root.addWidget(info_frame)
|
return frame
|
||||||
root.addWidget(self._hsep())
|
|
||||||
|
|
||||||
# ── Graphique bande passante ───────────────────────────────────
|
# ── Pied de page : accès secondaires ─────────────────────────────────
|
||||||
bw_header = self._section_header("📊 Bande passante")
|
def _build_footer(self) -> QWidget:
|
||||||
root.addWidget(bw_header)
|
|
||||||
|
|
||||||
self._bw_graph = BandwidthGraph()
|
|
||||||
self._bw_graph.setFixedHeight(75)
|
|
||||||
root.addWidget(self._bw_graph)
|
|
||||||
root.addWidget(self._hsep())
|
|
||||||
|
|
||||||
# ── Journal ───────────────────────────────────────────────────
|
|
||||||
log_hdr = self._section_header("📋 Journal", btn_text="Effacer",
|
|
||||||
btn_slot=self._clear_log)
|
|
||||||
root.addWidget(log_hdr)
|
|
||||||
|
|
||||||
self._log_list = QListWidget()
|
|
||||||
self._log_list.setFixedHeight(78)
|
|
||||||
self._log_list.setStyleSheet(
|
|
||||||
f"QListWidget {{ background: {_DARK2}; color: #aed6f1;"
|
|
||||||
" border: none; font-size: 10px; font-family: Courier; }"
|
|
||||||
"QListWidget::item { padding: 1px 6px; }"
|
|
||||||
)
|
|
||||||
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
||||||
root.addWidget(self._log_list)
|
|
||||||
root.addWidget(self._hsep())
|
|
||||||
|
|
||||||
# ── Boutons ───────────────────────────────────────────────────
|
|
||||||
btn_area = QWidget()
|
btn_area = QWidget()
|
||||||
btn_area.setStyleSheet(f"background: {_DARK2};")
|
btn_area.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||||||
bl = QVBoxLayout(btn_area)
|
bl = QHBoxLayout(btn_area)
|
||||||
bl.setContentsMargins(14, 10, 14, 12)
|
bl.setContentsMargins(14, 10, 14, 12)
|
||||||
bl.setSpacing(7)
|
bl.setSpacing(6)
|
||||||
|
|
||||||
self._btn_connect = QPushButton("Se connecter")
|
|
||||||
self._btn_connect.setFixedHeight(42)
|
|
||||||
self._btn_connect.setStyleSheet(
|
|
||||||
"QPushButton { background: #1e8449; color: white; font-size: 14px;"
|
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
|
||||||
"QPushButton:hover { background: #27ae60; }"
|
|
||||||
"QPushButton:disabled { background: #2e4057; color: #5d6d7e; }"
|
|
||||||
)
|
|
||||||
self._btn_connect.clicked.connect(self._on_connect)
|
|
||||||
bl.addWidget(self._btn_connect)
|
|
||||||
|
|
||||||
secondary_row = QHBoxLayout()
|
|
||||||
secondary_row.setSpacing(6)
|
|
||||||
|
|
||||||
btn_admin = QPushButton("⚙️ Administrateur")
|
btn_admin = QPushButton("⚙️ Administrateur")
|
||||||
btn_admin.setFixedHeight(30)
|
btn_admin.setFixedHeight(30)
|
||||||
btn_admin.setStyleSheet(
|
btn_admin.setStyleSheet(
|
||||||
"QPushButton { background: #2e4057; color: white; border-radius: 6px;"
|
f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
|
||||||
" font-size: 11px; border: none; }"
|
" font-size: 11px; border: none; }"
|
||||||
"QPushButton:hover { background: #3d5166; }"
|
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
|
||||||
)
|
)
|
||||||
btn_admin.clicked.connect(self._open_admin)
|
btn_admin.clicked.connect(self._open_admin)
|
||||||
secondary_row.addWidget(btn_admin)
|
bl.addWidget(btn_admin)
|
||||||
|
|
||||||
btn_history = QPushButton("📊 Historique")
|
btn_history = QPushButton("📊 Historique")
|
||||||
btn_history.setFixedHeight(30)
|
btn_history.setFixedHeight(30)
|
||||||
btn_history.setStyleSheet(
|
btn_history.setStyleSheet(
|
||||||
"QPushButton { background: #2e4057; color: white; border-radius: 6px;"
|
f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
|
||||||
" font-size: 11px; border: none; }"
|
" font-size: 11px; border: none; }"
|
||||||
"QPushButton:hover { background: #3d5166; }"
|
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
|
||||||
)
|
)
|
||||||
btn_history.clicked.connect(self._open_history)
|
btn_history.clicked.connect(self._open_history)
|
||||||
secondary_row.addWidget(btn_history)
|
bl.addWidget(btn_history)
|
||||||
|
|
||||||
bl.addLayout(secondary_row)
|
return btn_area
|
||||||
root.addWidget(btn_area)
|
|
||||||
|
# ── Repli/dépli du détail ─────────────────────────────────────────────
|
||||||
|
def _toggle_details(self):
|
||||||
|
# `QWidget.isVisible()` reflète la visibilité effective à l'écran,
|
||||||
|
# qui dépend de toute la chaîne de parents : avant que la fenêtre
|
||||||
|
# elle-même ne soit affichée, elle vaut toujours False. Un drapeau
|
||||||
|
# explicite évite ce piège.
|
||||||
|
self._details_expanded = not self._details_expanded
|
||||||
|
self._details.setVisible(self._details_expanded)
|
||||||
|
self._btn_toggle_details.setText(
|
||||||
|
"▴ Masquer le détail" if self._details_expanded else "▾ Afficher le détail"
|
||||||
|
)
|
||||||
|
self._sync_window_height()
|
||||||
|
|
||||||
|
def _sync_window_height(self):
|
||||||
|
"""Réajuste la hauteur fixe de la fenêtre à son contenu visible.
|
||||||
|
|
||||||
|
La fenêtre reste de taille fixe (pas de redimensionnement manuel),
|
||||||
|
mais cette taille change avec le repli/dépli du détail — il faut
|
||||||
|
donc lever la contrainte précédente avant de laisser le layout
|
||||||
|
recalculer sa hauteur naturelle, puis la reverrouiller.
|
||||||
|
"""
|
||||||
|
self.setMinimumHeight(0)
|
||||||
|
self.setMaximumHeight(16777215)
|
||||||
|
self.centralWidget().adjustSize()
|
||||||
|
self.adjustSize()
|
||||||
|
self.setFixedHeight(self.sizeHint().height())
|
||||||
|
|
||||||
# ── Helpers UI ───────────────────────────────────────────────────────
|
# ── Helpers UI ───────────────────────────────────────────────────────
|
||||||
def _lbl(self, text: str, fixed: int = 0, color: str = "white",
|
def _lbl(self, text: str, fixed: int = 0, color: str = theme.TEXT,
|
||||||
size: int = 12) -> QLabel:
|
size: int = 12) -> QLabel:
|
||||||
lbl = QLabel(text)
|
lbl = QLabel(text)
|
||||||
lbl.setStyleSheet(
|
lbl.setStyleSheet(
|
||||||
@@ -303,18 +380,18 @@ class MainWindow(QMainWindow):
|
|||||||
f = QFrame()
|
f = QFrame()
|
||||||
f.setFrameShape(QFrame.Shape.HLine)
|
f.setFrameShape(QFrame.Shape.HLine)
|
||||||
f.setFixedHeight(1)
|
f.setFixedHeight(1)
|
||||||
f.setStyleSheet(f"background: {_DARK2};")
|
f.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||||||
return f
|
return f
|
||||||
|
|
||||||
def _section_header(self, title: str, btn_text: str = "",
|
def _section_header(self, title: str, btn_text: str = "",
|
||||||
btn_slot=None) -> QWidget:
|
btn_slot=None) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
w.setFixedHeight(24)
|
w.setFixedHeight(24)
|
||||||
w.setStyleSheet(f"background: {_DARK2};")
|
w.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||||||
hl = QHBoxLayout(w)
|
hl = QHBoxLayout(w)
|
||||||
hl.setContentsMargins(12, 0, 12, 0)
|
hl.setContentsMargins(12, 0, 12, 0)
|
||||||
t = QLabel(title)
|
t = QLabel(title)
|
||||||
t.setStyleSheet("color: #5d6d7e; font-size: 10px; font-weight: bold;"
|
t.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 10px; font-weight: bold;"
|
||||||
" background: transparent;")
|
" background: transparent;")
|
||||||
hl.addWidget(t)
|
hl.addWidget(t)
|
||||||
hl.addStretch()
|
hl.addStretch()
|
||||||
@@ -322,9 +399,9 @@ class MainWindow(QMainWindow):
|
|||||||
b = QPushButton(btn_text)
|
b = QPushButton(btn_text)
|
||||||
b.setFixedHeight(18)
|
b.setFixedHeight(18)
|
||||||
b.setStyleSheet(
|
b.setStyleSheet(
|
||||||
"QPushButton { background: transparent; color: #5d6d7e;"
|
f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
|
||||||
" font-size: 9px; border: none; padding: 0 4px; }"
|
" font-size: 9px; border: none; padding: 0 4px; }"
|
||||||
"QPushButton:hover { color: white; }"
|
f"QPushButton:hover {{ color: {theme.TEXT}; }}"
|
||||||
)
|
)
|
||||||
b.clicked.connect(btn_slot)
|
b.clicked.connect(btn_slot)
|
||||||
hl.addWidget(b)
|
hl.addWidget(b)
|
||||||
@@ -336,16 +413,18 @@ class MainWindow(QMainWindow):
|
|||||||
def _reload_profiles(self):
|
def _reload_profiles(self):
|
||||||
self._profile_combo.blockSignals(True)
|
self._profile_combo.blockSignals(True)
|
||||||
self._profile_combo.clear()
|
self._profile_combo.clear()
|
||||||
self._profile_combo.addItem(f"⭐ {self._cfg.active_profile} (actif)")
|
profiles = self._cfg.list_profiles()
|
||||||
for name in self._cfg.list_profiles():
|
active = self._cfg.active_profile
|
||||||
marker = "✓ " if name == self._cfg.active_profile else " "
|
if active not in profiles:
|
||||||
self._profile_combo.addItem(f"{marker}{name}")
|
profiles = [active] + profiles
|
||||||
|
self._profile_combo.addItems(profiles)
|
||||||
|
idx = self._profile_combo.findText(active)
|
||||||
|
if idx >= 0:
|
||||||
|
self._profile_combo.setCurrentIndex(idx)
|
||||||
self._profile_combo.blockSignals(False)
|
self._profile_combo.blockSignals(False)
|
||||||
|
|
||||||
def _on_profile_changed(self, text: str):
|
def _on_profile_changed(self, index: int):
|
||||||
if "(actif)" in text:
|
name = self._profile_combo.itemText(index)
|
||||||
return
|
|
||||||
name = text.lstrip("✓ ").strip()
|
|
||||||
if not name or name == self._cfg.active_profile:
|
if not name or name == self._cfg.active_profile:
|
||||||
return
|
return
|
||||||
if self._cfg.load_profile(name):
|
if self._cfg.load_profile(name):
|
||||||
@@ -372,7 +451,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._add_log_item(ts, msg, kind)
|
self._add_log_item(ts, msg, kind)
|
||||||
|
|
||||||
def _add_log_item(self, ts: str, msg: str, kind: str):
|
def _add_log_item(self, ts: str, msg: str, kind: str):
|
||||||
color = _LOG_COLORS.get(kind, "#aed6f1")
|
color = theme.LOG_COLORS.get(kind, theme.ACCENT_LIGHT)
|
||||||
item = QListWidgetItem(f"[{ts}] {msg}")
|
item = QListWidgetItem(f"[{ts}] {msg}")
|
||||||
item.setForeground(QColor(color))
|
item.setForeground(QColor(color))
|
||||||
self._log_list.addItem(item)
|
self._log_list.addItem(item)
|
||||||
@@ -446,8 +525,8 @@ class MainWindow(QMainWindow):
|
|||||||
def _set_ping_badge(self, _ms, text: str, css_key: str):
|
def _set_ping_badge(self, _ms, text: str, css_key: str):
|
||||||
self._ping_badge.setText(text)
|
self._ping_badge.setText(text)
|
||||||
self._ping_badge.setStyleSheet(
|
self._ping_badge.setStyleSheet(
|
||||||
"border-radius: 11px; font-size: 10px; font-weight: bold; "
|
"border-radius: 12px; font-size: 10px; font-weight: bold; "
|
||||||
+ _PING_CSS[css_key]
|
+ theme.PING_CSS[css_key]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_reconnect(self):
|
def _check_reconnect(self):
|
||||||
@@ -484,7 +563,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._info_labels["mfa"].setText(
|
self._info_labels["mfa"].setText(
|
||||||
"Activé ✓" if self._cfg.mfa_enabled else "Désactivé"
|
"Activé ✓" if self._cfg.mfa_enabled else "Désactivé"
|
||||||
)
|
)
|
||||||
self._btn_copy_key.setEnabled(bool(wg.get("client_public_key", "")))
|
|
||||||
|
|
||||||
# Auto-reconnect timer
|
# Auto-reconnect timer
|
||||||
if self._cfg.get("ui", "auto_reconnect"):
|
if self._cfg.get("ui", "auto_reconnect"):
|
||||||
@@ -509,11 +587,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._connected_since = QDateTime.currentDateTime()
|
self._connected_since = QDateTime.currentDateTime()
|
||||||
self._status_badge.setText("● Connecté")
|
self._status_badge.setText("● Connecté")
|
||||||
self._status_badge.setStyleSheet(
|
self._status_badge.setStyleSheet(
|
||||||
"color: #27ae60; font-size: 15px; font-weight: bold; background: transparent;"
|
f"color: {theme.OK_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
|
||||||
)
|
)
|
||||||
self._btn_connect.setText("Se déconnecter")
|
self._btn_connect.setText("Se déconnecter")
|
||||||
self._btn_connect.setStyleSheet(
|
self._btn_connect.setStyleSheet(
|
||||||
"QPushButton { background: #922b21; color: white; font-size: 14px;"
|
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
"QPushButton:hover { background: #c0392b; }"
|
"QPushButton:hover { background: #c0392b; }"
|
||||||
)
|
)
|
||||||
@@ -522,7 +600,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._info_labels["handshake"].setText(info.get("last_handshake", "—"))
|
self._info_labels["handshake"].setText(info.get("last_handshake", "—"))
|
||||||
self._duration_label.setStyleSheet(
|
self._duration_label.setStyleSheet(
|
||||||
"color: #a9dfbf; font-size: 11px; background: transparent;"
|
f"color: {theme.OK_TEXT}; font-size: 11px; background: transparent;"
|
||||||
)
|
)
|
||||||
self.setWindowIcon(icons.icon_connected())
|
self.setWindowIcon(icons.icon_connected())
|
||||||
else:
|
else:
|
||||||
@@ -533,18 +611,18 @@ class MainWindow(QMainWindow):
|
|||||||
self._prev_rx = self._prev_tx = None
|
self._prev_rx = self._prev_tx = None
|
||||||
self._status_badge.setText("● Déconnecté")
|
self._status_badge.setText("● Déconnecté")
|
||||||
self._status_badge.setStyleSheet(
|
self._status_badge.setStyleSheet(
|
||||||
"color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;"
|
f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
|
||||||
)
|
)
|
||||||
self._btn_connect.setText("Se connecter")
|
self._btn_connect.setText("Se connecter")
|
||||||
self._btn_connect.setStyleSheet(
|
self._btn_connect.setStyleSheet(
|
||||||
"QPushButton { background: #1e8449; color: white; font-size: 14px;"
|
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
|
||||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||||
"QPushButton:hover { background: #27ae60; }"
|
"QPushButton:hover { background: #27ae60; }"
|
||||||
)
|
)
|
||||||
self._info_labels["rx_tx"].setText("—")
|
self._info_labels["rx_tx"].setText("—")
|
||||||
self._info_labels["handshake"].setText("—")
|
self._info_labels["handshake"].setText("—")
|
||||||
self._duration_label.setStyleSheet(
|
self._duration_label.setStyleSheet(
|
||||||
"color: #5d6d7e; font-size: 11px; background: transparent;"
|
f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
|
||||||
)
|
)
|
||||||
self.setWindowIcon(icons.icon_disconnected())
|
self.setWindowIcon(icons.icon_disconnected())
|
||||||
|
|
||||||
@@ -554,12 +632,6 @@ class MainWindow(QMainWindow):
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Actions
|
# Actions
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
def _copy_public_key(self):
|
|
||||||
pub = self._cfg.wg.get("client_public_key", "")
|
|
||||||
if pub:
|
|
||||||
QApplication.clipboard().setText(pub)
|
|
||||||
self._add_log("Clé publique copiée dans le presse-papiers", "info")
|
|
||||||
|
|
||||||
def _on_connect(self):
|
def _on_connect(self):
|
||||||
"""Bascule connexion/déconnexion. Le travail réel part dans un thread."""
|
"""Bascule connexion/déconnexion. Le travail réel part dans un thread."""
|
||||||
if self._worker is not None:
|
if self._worker is not None:
|
||||||
@@ -635,6 +707,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._session_id = hist.start_session(endpoint,
|
self._session_id = hist.start_session(endpoint,
|
||||||
self._cfg.active_profile)
|
self._cfg.active_profile)
|
||||||
self._add_log(f"Connexion établie → {endpoint}", "success")
|
self._add_log(f"Connexion établie → {endpoint}", "success")
|
||||||
|
# `connect()` ajoute une ligne d'avertissement au message de
|
||||||
|
# succès quand le split-DNS n'a pas pu être appliqué — sans
|
||||||
|
# quoi cet échec restait invisible.
|
||||||
|
base, _, extra = msg.partition("\n")
|
||||||
|
if extra:
|
||||||
|
self._add_log(extra, "warning")
|
||||||
QTimer.singleShot(5000, self._update_ping)
|
QTimer.singleShot(5000, self._update_ping)
|
||||||
else:
|
else:
|
||||||
self._reconnect_failures += 1
|
self._reconnect_failures += 1
|
||||||
|
|||||||
+21
-23
@@ -5,9 +5,7 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import Qt, QTimer
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtGui import QFont
|
from PyQt6.QtGui import QFont
|
||||||
from app.core import mfa as mfa_core
|
from app.core import mfa as mfa_core
|
||||||
|
from app.ui import theme
|
||||||
_DARK = "#1c2833"
|
|
||||||
_DARK2 = "#17202a"
|
|
||||||
|
|
||||||
|
|
||||||
class MFADialog(QDialog):
|
class MFADialog(QDialog):
|
||||||
@@ -19,8 +17,8 @@ class MFADialog(QDialog):
|
|||||||
self.setFixedWidth(340)
|
self.setFixedWidth(340)
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
QDialog {{ background: {_DARK2}; }}
|
QDialog {{ background: {theme.BG_SUNKEN}; }}
|
||||||
QLabel {{ color: white; background: transparent; }}
|
QLabel {{ color: {theme.TEXT}; background: transparent; }}
|
||||||
""")
|
""")
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
@@ -44,12 +42,12 @@ class MFADialog(QDialog):
|
|||||||
sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.")
|
sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.")
|
||||||
sub.setWordWrap(True)
|
sub.setWordWrap(True)
|
||||||
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
sub.setStyleSheet("color: rgba(255,255,255,0.6);")
|
sub.setStyleSheet(f"color: {theme.TEXT_MUTED};")
|
||||||
layout.addWidget(sub)
|
layout.addWidget(sub)
|
||||||
|
|
||||||
sep = QFrame()
|
sep = QFrame()
|
||||||
sep.setFrameShape(QFrame.Shape.HLine)
|
sep.setFrameShape(QFrame.Shape.HLine)
|
||||||
sep.setStyleSheet("color: rgba(255,255,255,0.15);")
|
sep.setStyleSheet(f"color: {theme.BORDER_SOFT};")
|
||||||
layout.addWidget(sep)
|
layout.addWidget(sep)
|
||||||
|
|
||||||
self._code_input = QLineEdit()
|
self._code_input = QLineEdit()
|
||||||
@@ -60,15 +58,15 @@ class MFADialog(QDialog):
|
|||||||
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
|
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
|
||||||
self._code_input.setFont(f2)
|
self._code_input.setFont(f2)
|
||||||
self._code_input.setStyleSheet(
|
self._code_input.setStyleSheet(
|
||||||
"QLineEdit { padding: 8px; border: 2px solid #2471a3;"
|
f"QLineEdit {{ padding: 8px; border: 2px solid {theme.ACCENT};"
|
||||||
f" border-radius: 6px; background: {_DARK}; color: white; }}"
|
f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
|
||||||
)
|
)
|
||||||
self._code_input.returnPressed.connect(self._verify)
|
self._code_input.returnPressed.connect(self._verify)
|
||||||
layout.addWidget(self._code_input)
|
layout.addWidget(self._code_input)
|
||||||
|
|
||||||
self._error_label = QLabel("")
|
self._error_label = QLabel("")
|
||||||
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self._error_label.setStyleSheet("color: #f1948a; font-weight: bold;")
|
self._error_label.setStyleSheet(f"color: {theme.FAIL_TEXT}; font-weight: bold;")
|
||||||
layout.addWidget(self._error_label)
|
layout.addWidget(self._error_label)
|
||||||
|
|
||||||
self._progress = QProgressBar()
|
self._progress = QProgressBar()
|
||||||
@@ -76,31 +74,31 @@ class MFADialog(QDialog):
|
|||||||
self._progress.setTextVisible(False)
|
self._progress.setTextVisible(False)
|
||||||
self._progress.setFixedHeight(6)
|
self._progress.setFixedHeight(6)
|
||||||
self._progress.setStyleSheet(
|
self._progress.setStyleSheet(
|
||||||
f"QProgressBar {{ border-radius: 3px; background: {_DARK}; }}"
|
f"QProgressBar {{ border-radius: 3px; background: {theme.BG}; }}"
|
||||||
"QProgressBar::chunk { background: #5dade2; border-radius: 3px; }"
|
f"QProgressBar::chunk {{ background: {theme.ACCENT_LIGHT}; border-radius: 3px; }}"
|
||||||
)
|
)
|
||||||
layout.addWidget(self._progress)
|
layout.addWidget(self._progress)
|
||||||
|
|
||||||
self._timer_label = QLabel("")
|
self._timer_label = QLabel("")
|
||||||
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self._timer_label.setStyleSheet("color: #5d6d7e; font-size: 11px;")
|
self._timer_label.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
|
||||||
layout.addWidget(self._timer_label)
|
layout.addWidget(self._timer_label)
|
||||||
|
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_cancel = QPushButton("Annuler")
|
btn_cancel = QPushButton("Annuler")
|
||||||
btn_cancel.setStyleSheet(
|
btn_cancel.setStyleSheet(
|
||||||
"QPushButton { padding: 8px 16px; border-radius: 5px; border: none;"
|
f"QPushButton {{ padding: 8px 16px; border-radius: 5px; border: none;"
|
||||||
f" background: #2e4057; color: white; }}"
|
f" background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
|
||||||
"QPushButton:hover { background: #3d5166; }"
|
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
|
||||||
)
|
)
|
||||||
btn_cancel.clicked.connect(self.reject)
|
btn_cancel.clicked.connect(self.reject)
|
||||||
|
|
||||||
self._btn_ok = QPushButton("Vérifier")
|
self._btn_ok = QPushButton("Vérifier")
|
||||||
self._btn_ok.setDefault(True)
|
self._btn_ok.setDefault(True)
|
||||||
self._btn_ok.setStyleSheet(
|
self._btn_ok.setStyleSheet(
|
||||||
"QPushButton { padding: 8px 20px; border-radius: 5px; border: none;"
|
f"QPushButton {{ padding: 8px 20px; border-radius: 5px; border: none;"
|
||||||
" background: #2471a3; color: white; font-weight: bold; }"
|
f" background: {theme.ACCENT}; color: {theme.TEXT}; font-weight: bold; }}"
|
||||||
"QPushButton:hover { background: #1a5276; }"
|
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
|
||||||
)
|
)
|
||||||
self._btn_ok.clicked.connect(self._verify)
|
self._btn_ok.clicked.connect(self._verify)
|
||||||
btn_row.addWidget(btn_cancel)
|
btn_row.addWidget(btn_cancel)
|
||||||
@@ -118,8 +116,8 @@ class MFADialog(QDialog):
|
|||||||
if len(raw) != 6 or not raw.isdigit():
|
if len(raw) != 6 or not raw.isdigit():
|
||||||
self._error_label.setText("Entrez exactement 6 chiffres.")
|
self._error_label.setText("Entrez exactement 6 chiffres.")
|
||||||
self._code_input.setStyleSheet(
|
self._code_input.setStyleSheet(
|
||||||
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;"
|
f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};"
|
||||||
f" border-radius: 6px; background: {_DARK}; color: white; }}"
|
f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if mfa_core.verify_code(self._secret, raw):
|
if mfa_core.verify_code(self._secret, raw):
|
||||||
@@ -129,8 +127,8 @@ class MFADialog(QDialog):
|
|||||||
self._error_label.setText("Code incorrect. Réessayez.")
|
self._error_label.setText("Code incorrect. Réessayez.")
|
||||||
self._code_input.clear()
|
self._code_input.clear()
|
||||||
self._code_input.setStyleSheet(
|
self._code_input.setStyleSheet(
|
||||||
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;"
|
f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};"
|
||||||
f" border-radius: 6px; background: {_DARK}; color: white; }}"
|
f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_verified(self) -> bool:
|
def is_verified(self) -> bool:
|
||||||
|
|||||||
+48
-4
@@ -13,11 +13,13 @@ BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code
|
|||||||
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
|
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
|
||||||
BORDER = "rgba(255,255,255,0.18)"
|
BORDER = "rgba(255,255,255,0.18)"
|
||||||
BORDER_SOFT = "rgba(255,255,255,0.10)"
|
BORDER_SOFT = "rgba(255,255,255,0.10)"
|
||||||
|
INPUT_BG = "rgba(255,255,255,0.10)" # fond des champs de saisie et listes
|
||||||
|
|
||||||
# ── Textes ───────────────────────────────────────────────────────────────────
|
# ── Textes ───────────────────────────────────────────────────────────────────
|
||||||
TEXT = "white"
|
TEXT = "white"
|
||||||
TEXT_MUTED = "rgba(255,255,255,0.70)"
|
TEXT_MUTED = "rgba(255,255,255,0.70)"
|
||||||
TEXT_FAINT = "rgba(255,255,255,0.50)"
|
TEXT_FAINT = "rgba(255,255,255,0.50)"
|
||||||
|
TEXT_CODE = "#a8d8ea" # texte à chasse fixe (aperçus de config, rapports)
|
||||||
|
|
||||||
# ── Accent ───────────────────────────────────────────────────────────────────
|
# ── Accent ───────────────────────────────────────────────────────────────────
|
||||||
ACCENT = "#2471a3"
|
ACCENT = "#2471a3"
|
||||||
@@ -32,6 +34,12 @@ WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b"
|
|||||||
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
|
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
|
||||||
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
|
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
|
||||||
|
|
||||||
|
# Teintes de survol des boutons pleins ok/fail — reprises telles quelles à
|
||||||
|
# plusieurs endroits (panneau admin, fenêtre principale) : un seul nom leur
|
||||||
|
# évite de dériver l'une de l'autre au fil des modifications.
|
||||||
|
OK_HOVER = "#27ae60"
|
||||||
|
FAIL_HOVER = "#e74c3c"
|
||||||
|
|
||||||
_STATE = {
|
_STATE = {
|
||||||
"ok": (OK_TEXT, OK_BG),
|
"ok": (OK_TEXT, OK_BG),
|
||||||
"warn": (WARN_TEXT, WARN_BG),
|
"warn": (WARN_TEXT, WARN_BG),
|
||||||
@@ -40,6 +48,22 @@ _STATE = {
|
|||||||
"idle": (TEXT_MUTED, "transparent"),
|
"idle": (TEXT_MUTED, "transparent"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Badge de ping (fenêtre principale) ──────────────────────────────────────
|
||||||
|
PING_CSS = {
|
||||||
|
"good": "background:#1e8449; color:white;",
|
||||||
|
"medium": "background:#d4ac0d; color:black;",
|
||||||
|
"bad": "background:#922b21; color:white;",
|
||||||
|
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Entrées de journal (fenêtre principale) ─────────────────────────────────
|
||||||
|
LOG_COLORS = {
|
||||||
|
"success": OK_TEXT,
|
||||||
|
"error": FAIL_TEXT,
|
||||||
|
"warning": WARN_TEXT,
|
||||||
|
"info": ACCENT_LIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
# ── Bandeaux d'onglets ───────────────────────────────────────────────────────
|
# ── Bandeaux d'onglets ───────────────────────────────────────────────────────
|
||||||
# Cinq teintes calées sur la même luminosité et la même saturation : les
|
# Cinq teintes calées sur la même luminosité et la même saturation : les
|
||||||
# onglets restent distinguables sans que l'un paraisse plus clair ou plus
|
# onglets restent distinguables sans que l'un paraisse plus clair ou plus
|
||||||
@@ -118,7 +142,7 @@ TAB_CSS = f"""
|
|||||||
color: {TEXT};
|
color: {TEXT};
|
||||||
}}
|
}}
|
||||||
QLineEdit {{
|
QLineEdit {{
|
||||||
background: rgba(255,255,255,0.10);
|
background: {INPUT_BG};
|
||||||
color: {TEXT};
|
color: {TEXT};
|
||||||
border: 1px solid {BORDER};
|
border: 1px solid {BORDER};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -128,7 +152,7 @@ TAB_CSS = f"""
|
|||||||
QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
|
QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
|
||||||
color: {TEXT_FAINT}; }}
|
color: {TEXT_FAINT}; }}
|
||||||
QSpinBox {{
|
QSpinBox {{
|
||||||
background: rgba(255,255,255,0.10);
|
background: {INPUT_BG};
|
||||||
color: {TEXT};
|
color: {TEXT};
|
||||||
border: 1px solid {BORDER};
|
border: 1px solid {BORDER};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -137,9 +161,22 @@ TAB_CSS = f"""
|
|||||||
QSpinBox::up-button, QSpinBox::down-button {{
|
QSpinBox::up-button, QSpinBox::down-button {{
|
||||||
background: rgba(255,255,255,0.15);
|
background: rgba(255,255,255,0.15);
|
||||||
}}
|
}}
|
||||||
|
QComboBox {{
|
||||||
|
background: {INPUT_BG};
|
||||||
|
color: {TEXT};
|
||||||
|
border: 1px solid {BORDER};
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}}
|
||||||
|
QComboBox::drop-down {{ border: none; }}
|
||||||
|
QComboBox QAbstractItemView {{
|
||||||
|
background: {BG_RAISED};
|
||||||
|
color: {TEXT};
|
||||||
|
selection-background-color: {ACCENT};
|
||||||
|
}}
|
||||||
QTextEdit {{
|
QTextEdit {{
|
||||||
background: {BG_SUNKEN};
|
background: {BG_SUNKEN};
|
||||||
color: #a8d8ea;
|
color: {TEXT_CODE};
|
||||||
border: 1px solid {BORDER};
|
border: 1px solid {BORDER};
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}}
|
}}
|
||||||
@@ -171,13 +208,20 @@ TABBAR_CSS = f"""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def primary_button_style(solid: str = OK_SOLID, hover: str = "#27ae60") -> str:
|
def primary_button_style(solid: str = OK_SOLID, hover: str = OK_HOVER) -> str:
|
||||||
"""Bouton d'action principale d'une page (lancer un test, par exemple)."""
|
"""Bouton d'action principale d'une page (lancer un test, par exemple)."""
|
||||||
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
|
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
|
||||||
f" border-radius: 5px; font-weight: bold; border: none; }}"
|
f" border-radius: 5px; font-weight: bold; border: none; }}"
|
||||||
f"QPushButton:hover {{ background: {hover}; }}")
|
f"QPushButton:hover {{ background: {hover}; }}")
|
||||||
|
|
||||||
|
|
||||||
|
def secondary_button_style() -> str:
|
||||||
|
"""Bouton d'action secondaire d'une page (à côté d'une action principale)."""
|
||||||
|
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED}; color: {TEXT};"
|
||||||
|
f" border-radius: 5px; border: none; }}"
|
||||||
|
f"QPushButton:hover {{ background: {ACCENT_HOVER}; }}")
|
||||||
|
|
||||||
|
|
||||||
def hint_style() -> str:
|
def hint_style() -> str:
|
||||||
"""Encart d'introduction posé en tête de chaque onglet.
|
"""Encart d'introduction posé en tête de chaque onglet.
|
||||||
|
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ def _privileged_commands() -> tuple[tuple[list[str], str], ...]:
|
|||||||
"retirer l'entrée DNS du tunnel"),
|
"retirer l'entrée DNS du tunnel"),
|
||||||
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
|
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
|
||||||
"purger le DNS systemd-resolved"),
|
"purger le DNS systemd-resolved"),
|
||||||
|
(["resolvectl", "dns", _PRIVILEGE_PROBE_IFACE, "0.0.0.0"],
|
||||||
|
"configurer le DNS distant (split-DNS)"),
|
||||||
|
(["resolvectl", "domain", _PRIVILEGE_PROBE_IFACE, "~invalid"],
|
||||||
|
"configurer le domaine distant (split-DNS)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user