feat: ajouter les tags de session et les parametres par defaut de groupe

Tags : une session peut porter plusieurs tags libres (Session.tags), saisis
comme une liste separee par des virgules dans l'editeur (normalises : espaces
retires, doublons insensibles a la casse elimines), avec autocompletion sur
les tags deja utilises. Affiches dans l'arbre des sessions, et filtrables via
un champ dedie au-dessus de l'arbre (masque les sessions ne correspondant pas,
sans toucher aux dossiers/terminaux locaux/hosts OpenSSH).

Parametres de groupe : choix architectural deliberement plus simple qu'une
resolution dynamique a la connexion. app/groups.py (GroupSettingsStore, meme
pattern que les autres stores) associe un dossier (Session.group) a des
valeurs par defaut (utilisateur, port, authentification, cle, bastion,
keepalive). Ces valeurs ne s'appliquent qu'au moment de la creation d'une
session, via un bouton explicite "↓ Heriter du groupe" dans SessionDialog -
jamais de resolution automatique/retroactive. Ce choix evite d'introduire des
valeurs sentinelles "herite vs. explicite" sur chaque champ (port=22 est a la
fois une valeur par defaut ET une vraie valeur possible, par exemple) dans
tout le reste du code (ConnectionController, SSHConnection...), pour une
fonctionnalite que la priorisation du projet place parmi les secondaires. Une
session reste ensuite un ensemble de valeurs normales, librement modifiables
- "surcharger une valeur heritee" est donc trivialement satisfait.

Clic droit sur un dossier dans le panneau lateral : "Nouvelle session dans ce
dossier..." (pre-remplit le champ Dossier) et "Parametres du groupe..."
(GroupSettingsDialog).

tests/test_tags_and_groups.py : persistance des tags, CRUD GroupSettingsStore,
normalisation de la saisie des tags, heritage applique/absent, filtrage dans
l'arbre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 03:10:01 +02:00
parent ff4a73e6e6
commit b615ed52af
8 changed files with 610 additions and 9 deletions

View File

@@ -9,13 +9,15 @@ import uuid
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem,
QMenu, QMessageBox,
QMenu, QMessageBox, QComboBox, QLabel,
)
from app.session_manager import SessionStore, Session
from app.key_manager import KeyManager
from app.groups import GroupSettingsStore
from app.local_terminal import SHELL_LABELS
from app.dialogs.session_dialog import SessionDialog
from app.dialogs.group_settings_dialog import GroupSettingsDialog
from app.openssh.config import (
OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host,
)
@@ -23,6 +25,7 @@ from app.openssh.config import (
SESSION_ROLE = Qt.UserRole + 10
LOCAL_SHELL_ROLE = Qt.UserRole + 11
OPENSSH_HOST_ROLE = Qt.UserRole + 12
GROUP_PATH_ROLE = Qt.UserRole + 13
class SessionController(QObject):
@@ -41,10 +44,12 @@ class SessionController(QObject):
broadcast_button: QToolButton,
parent_widget: QWidget | None = None,
openssh_config_path=None,
group_store: GroupSettingsStore | None = None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self.group_store = group_store
self._parent_widget = parent_widget
self._openssh_config_path = openssh_config_path or default_config_path()
@@ -57,7 +62,7 @@ class SessionController(QObject):
new_btn = QToolButton()
new_btn.setObjectName("primary")
new_btn.setText("+ Session")
new_btn.clicked.connect(self.new_session)
new_btn.clicked.connect(lambda: self.new_session())
toolbar.addWidget(new_btn)
term_btn = QToolButton()
@@ -93,6 +98,16 @@ class SessionController(QObject):
layout.addLayout(toolbar)
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("🏷"))
self.tag_filter_combo = QComboBox()
self.tag_filter_combo.setEditable(True)
self.tag_filter_combo.setInsertPolicy(QComboBox.NoInsert)
self.tag_filter_combo.lineEdit().setPlaceholderText("Filtrer les sessions par tag...")
self.tag_filter_combo.currentTextChanged.connect(self._apply_tag_filter)
filter_row.addWidget(self.tag_filter_combo, 1)
layout.addLayout(filter_row)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
@@ -138,13 +153,17 @@ class SessionController(QObject):
parent_item = get_folder(parent_path)
item = QTreeWidgetItem([f"📁 {name}"])
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
item.setData(0, GROUP_PATH_ROLE, path)
parent_item.addChild(item)
folder_items[path] = item
return item
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
parent = get_folder(session.group)
item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"])
label = f"🖥 {session.name} ({session.host})"
if session.tags:
label += " [" + ", ".join(session.tags) + "]"
item = QTreeWidgetItem([label])
item.setData(0, SESSION_ROLE, session.id)
parent.addChild(item)
@@ -178,6 +197,30 @@ class SessionController(QObject):
openssh_header.addChild(hint)
self.tree.expandAll()
self._reload_tag_filter()
self._apply_tag_filter()
def _reload_tag_filter(self) -> None:
current = self.tag_filter_combo.currentText()
self.tag_filter_combo.blockSignals(True)
self.tag_filter_combo.clear()
self.tag_filter_combo.addItem("") # aucun filtre
self.tag_filter_combo.addItems(self.session_store.tags())
self.tag_filter_combo.setCurrentText(current)
self.tag_filter_combo.blockSignals(False)
def _apply_tag_filter(self, _text: str = "") -> None:
query = self.tag_filter_combo.currentText().strip().lower()
def visit(item: QTreeWidgetItem) -> None:
session = self._session_from_item(item)
if session is not None:
item.setHidden(bool(query) and not any(query in t.lower() for t in session.tags))
for i in range(item.childCount()):
visit(item.child(i))
for i in range(self.tree.topLevelItemCount()):
visit(self.tree.topLevelItem(i))
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
if not item:
@@ -197,6 +240,11 @@ class SessionController(QObject):
return None
return item.data(0, OPENSSH_HOST_ROLE)
def _group_from_item(self, item: QTreeWidgetItem) -> str | None:
if not item:
return None
return item.data(0, GROUP_PATH_ROLE)
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
session = self._session_from_item(item)
if session:
@@ -216,6 +264,7 @@ class SessionController(QObject):
session = self._session_from_item(item) if item else None
shell = self._shell_from_item(item) if item else None
openssh_host = self._openssh_host_from_item(item) if item else None
group_path = self._group_from_item(item) if item else None
if session:
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
@@ -252,6 +301,15 @@ class SessionController(QObject):
self.import_openssh_host(openssh_host)
elif action == refresh_action:
self.reload()
elif group_path:
new_in_group_action = menu.addAction("Nouvelle session dans ce dossier...")
menu.addSeparator()
group_settings_action = menu.addAction("Parametres du groupe...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == new_in_group_action:
self.new_session(group=group_path)
elif action == group_settings_action:
self.open_group_settings(group_path)
else:
new_action = menu.addAction("Nouvelle session...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
@@ -259,15 +317,20 @@ class SessionController(QObject):
self.new_session()
# ---------- CRUD ----------
def new_session(self) -> None:
dialog = SessionDialog(self.key_manager, parent=self._parent_widget, session_store=self.session_store)
def new_session(self, group: str = "") -> None:
prefill = Session(group=group) if group else None
dialog = SessionDialog(
self.key_manager, session=prefill, parent=self._parent_widget,
session_store=self.session_store, group_store=self.group_store,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
def edit_session(self, session: Session) -> None:
dialog = SessionDialog(
self.key_manager, session=session, parent=self._parent_widget, session_store=self.session_store,
self.key_manager, session=session, parent=self._parent_widget,
session_store=self.session_store, group_store=self.group_store,
)
if dialog.exec():
self.session_store.update(dialog.result_session())
@@ -294,7 +357,17 @@ class SessionController(QObject):
dialog = SessionDialog(
self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget,
title=f"Importer « {host.alias} » comme session", session_store=self.session_store,
group_store=self.group_store,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
# ---------- parametres de groupe ----------
def open_group_settings(self, group_path: str) -> None:
if self.group_store is None:
return
dialog = GroupSettingsDialog(
group_path, self.group_store, self.key_manager, self.session_store, parent=self._parent_widget,
)
dialog.exec()

View File

@@ -0,0 +1,213 @@
"""Valeurs par defaut d'un groupe : appliquees en pre-remplissage a la creation
d'une nouvelle session dans ce dossier (voir app.groups)."""
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QCheckBox, QStackedWidget, QLabel,
)
from app.session_manager import (
SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT,
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL,
)
from app.key_manager import KeyManager
from app.groups import GroupSettings, GroupSettingsStore
from app.openssh.config import load_openssh_hosts
class GroupSettingsDialog(QDialog):
"""Meme principe que la section Bastion de SessionDialog, en plus compact :
ce sont des valeurs par defaut, pas la configuration d'une connexion reelle."""
def __init__(
self, group_path: str, store: GroupSettingsStore, key_manager: KeyManager,
session_store: SessionStore, parent=None,
):
super().__init__(parent)
self.group_path = group_path
self.store = store
self.key_manager = key_manager
self.session_store = session_store
self.settings = store.get(group_path) or GroupSettings(path=group_path)
self.setWindowTitle(f"Parametres par defaut — {group_path}")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
info = QLabel(
"Ces valeurs pre-remplissent une nouvelle session creee dans ce dossier "
"(bouton « Heriter du groupe » dans son editeur). Elles ne changent jamais "
"les sessions deja creees."
)
info.setWordWrap(True)
layout.addWidget(info)
form = QFormLayout()
self.username_edit = QLineEdit(self.settings.username)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(self.settings.port or 22)
self.auth_combo = QComboBox()
self.auth_combo.addItem("Automatique (comme la commande ssh)", AUTH_AUTO)
self.auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
self.auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
self.auth_combo.addItem("Agent SSH", AUTH_AGENT)
idx = self.auth_combo.findData(self.settings.auth_type)
self.auth_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.key_combo = QComboBox()
for entry in self.key_manager.keys:
self.key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
self.browse_key_btn = QPushButton("Parcourir...")
key_row = QWidget()
key_layout = QHBoxLayout(key_row)
key_layout.setContentsMargins(0, 0, 0, 0)
key_layout.addWidget(self.key_combo, 1)
key_layout.addWidget(self.browse_key_btn)
if self.settings.key_path:
existing = self.key_combo.findData(self.settings.key_path)
if existing >= 0:
self.key_combo.setCurrentIndex(existing)
else:
self.key_combo.addItem(self.settings.key_path, self.settings.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
form.addRow("Utilisateur :", self.username_edit)
form.addRow("Port :", self.port_spin)
form.addRow("Authentification :", self.auth_combo)
form.addRow("Cle privee :", key_row)
layout.addLayout(form)
layout.addWidget(self._build_bastion_group())
keepalive_form = QFormLayout()
self.keepalive_check = QCheckBox("Keepalive actif")
self.keepalive_check.setChecked(self.settings.keepalive_enabled)
self.keepalive_interval_spin = QSpinBox()
self.keepalive_interval_spin.setRange(5, 3600)
self.keepalive_interval_spin.setSuffix(" s")
self.keepalive_interval_spin.setValue(self.settings.keepalive_interval or 30)
self.keepalive_count_spin = QSpinBox()
self.keepalive_count_spin.setRange(1, 20)
self.keepalive_count_spin.setValue(self.settings.keepalive_count_max or 3)
keepalive_form.addRow(self.keepalive_check)
keepalive_form.addRow("Intervalle :", self.keepalive_interval_spin)
keepalive_form.addRow("Echecs avant abandon :", self.keepalive_count_spin)
layout.addLayout(keepalive_form)
self.auth_combo.currentIndexChanged.connect(self._update_key_row)
self.browse_key_btn.clicked.connect(self._browse_key)
self._update_key_row()
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _update_key_row(self) -> None:
is_key_auth = self.auth_combo.currentData() == AUTH_KEY
self.key_combo.setEnabled(is_key_auth)
self.browse_key_btn.setEnabled(is_key_auth)
def _browse_key(self) -> None:
from PySide6.QtWidgets import QFileDialog
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee")
if path:
self.key_combo.addItem(path, path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
# ---------- Bastion (identique dans l'esprit a SessionDialog, en plus compact) ----------
def _build_bastion_group(self):
from PySide6.QtWidgets import QGroupBox
bastion = self.settings.bastion
group = QGroupBox("Bastion / Jump Host par defaut")
v = QVBoxLayout(group)
self.bastion_mode_combo = QComboBox()
self.bastion_mode_combo.addItem("Aucun", BASTION_NONE)
self.bastion_mode_combo.addItem("Une session PySSH existante", BASTION_SESSION)
self.bastion_mode_combo.addItem("Un Host de ~/.ssh/config", BASTION_OPENSSH)
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
v.addWidget(self.bastion_mode_combo)
self.bastion_stack = QStackedWidget()
v.addWidget(self.bastion_stack)
self.bastion_stack.addWidget(QWidget())
session_page = QWidget()
session_form = QFormLayout(session_page)
session_form.setContentsMargins(0, 4, 0, 0)
self.bastion_session_combo = QComboBox()
for s in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
self.bastion_session_combo.addItem(f"{s.name} ({s.username}@{s.host})", s.id)
if self.bastion_session_combo.count() == 0:
self.bastion_session_combo.addItem("(aucune session enregistree)", "")
self.bastion_session_combo.setEnabled(False)
session_form.addRow("Session :", self.bastion_session_combo)
self.bastion_stack.addWidget(session_page)
openssh_page = QWidget()
openssh_form = QFormLayout(openssh_page)
openssh_form.setContentsMargins(0, 4, 0, 0)
self.bastion_openssh_combo = QComboBox()
hosts = load_openssh_hosts()
for h in hosts:
self.bastion_openssh_combo.addItem(f"{h.alias} ({h.display_target})", h.alias)
if not hosts:
self.bastion_openssh_combo.addItem("(aucun host dans ~/.ssh/config)", "")
self.bastion_openssh_combo.setEnabled(False)
openssh_form.addRow("Host :", self.bastion_openssh_combo)
self.bastion_stack.addWidget(openssh_page)
manual_page = QWidget()
manual_form = QFormLayout(manual_page)
manual_form.setContentsMargins(0, 4, 0, 0)
self.bastion_host_edit = QLineEdit(bastion.host if bastion.mode == BASTION_MANUAL else "")
self.bastion_port_spin = QSpinBox()
self.bastion_port_spin.setRange(1, 65535)
self.bastion_port_spin.setValue(bastion.port or 22)
self.bastion_user_edit = QLineEdit(bastion.username if bastion.mode == BASTION_MANUAL else "")
manual_form.addRow("Hote :", self.bastion_host_edit)
manual_form.addRow("Port :", self.bastion_port_spin)
manual_form.addRow("Utilisateur :", self.bastion_user_edit)
self.bastion_stack.addWidget(manual_page)
self.bastion_mode_combo.currentIndexChanged.connect(self.bastion_stack.setCurrentIndex)
mode_idx = self.bastion_mode_combo.findData(bastion.mode)
self.bastion_mode_combo.setCurrentIndex(mode_idx if mode_idx >= 0 else 0)
self.bastion_stack.setCurrentIndex(self.bastion_mode_combo.currentIndex())
if bastion.mode == BASTION_SESSION:
found = self.bastion_session_combo.findData(bastion.session_id)
if found >= 0:
self.bastion_session_combo.setCurrentIndex(found)
elif bastion.mode == BASTION_OPENSSH:
found = self.bastion_openssh_combo.findData(bastion.openssh_alias)
if found >= 0:
self.bastion_openssh_combo.setCurrentIndex(found)
return group
def _result_bastion(self) -> BastionConfig:
mode = self.bastion_mode_combo.currentData()
bastion = BastionConfig(mode=mode)
if mode == BASTION_SESSION:
bastion.session_id = self.bastion_session_combo.currentData() or ""
elif mode == BASTION_OPENSSH:
bastion.openssh_alias = self.bastion_openssh_combo.currentData() or ""
elif mode == BASTION_MANUAL:
bastion.host = self.bastion_host_edit.text().strip()
bastion.port = self.bastion_port_spin.value()
bastion.username = self.bastion_user_edit.text().strip()
return bastion
def _on_accept(self) -> None:
self.settings.path = self.group_path
self.settings.username = self.username_edit.text().strip()
self.settings.port = self.port_spin.value()
self.settings.auth_type = self.auth_combo.currentData()
self.settings.key_path = self.key_combo.currentData() or ""
self.settings.bastion = self._result_bastion()
self.settings.keepalive_enabled = self.keepalive_check.isChecked()
self.settings.keepalive_interval = self.keepalive_interval_spin.value()
self.settings.keepalive_count_max = self.keepalive_count_spin.value()
self.store.set(self.settings)
self.accept()

View File

@@ -1,10 +1,11 @@
"""Boite de dialogue de creation/edition d'une session SSH."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
QCheckBox, QMessageBox, QGroupBox, QStackedWidget,
QCheckBox, QMessageBox, QGroupBox, QStackedWidget, QCompleter,
)
from app.session_manager import (
@@ -13,6 +14,7 @@ from app.session_manager import (
)
from app.key_manager import KeyManager
from app.openssh.config import load_openssh_hosts
from app.groups import GroupSettingsStore
class SessionDialog(QDialog):
@@ -22,11 +24,12 @@ class SessionDialog(QDialog):
def __init__(
self, key_manager: KeyManager, session: Session | None = None,
parent=None, connect_label: str = "", title: str = "",
session_store: SessionStore | None = None,
session_store: SessionStore | None = None, group_store: GroupSettingsStore | None = None,
):
super().__init__(parent)
self.key_manager = key_manager
self.session_store = session_store
self.group_store = group_store
self.session = session or Session()
self.quick_mode = bool(connect_label)
self.setWindowTitle(title or ("Nouvelle session" if session is None else "Modifier la session"))
@@ -38,6 +41,25 @@ class SessionDialog(QDialog):
self.name_edit = QLineEdit(self.session.name)
self.group_edit = QLineEdit(self.session.group)
self.group_edit.setPlaceholderText("ex: Production/Web")
self.inherit_group_btn = QPushButton("↓ Heriter du groupe")
self.inherit_group_btn.setToolTip(
"Pre-remplit ce formulaire avec les valeurs par defaut enregistrees pour ce dossier"
" (Parametres du groupe..., dans le panneau des sessions)."
)
self.inherit_group_btn.clicked.connect(self._apply_group_defaults)
group_row = QWidget()
group_layout = QHBoxLayout(group_row)
group_layout.setContentsMargins(0, 0, 0, 0)
group_layout.addWidget(self.group_edit, 1)
group_layout.addWidget(self.inherit_group_btn)
self.tags_edit = QLineEdit(", ".join(self.session.tags))
self.tags_edit.setPlaceholderText("ex: prod, docker, client-x (separes par des virgules)")
if self.session_store is not None:
completer = QCompleter(self.session_store.tags(), self.tags_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.tags_edit.setCompleter(completer)
self.host_edit = QLineEdit(self.session.host)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
@@ -70,7 +92,8 @@ class SessionDialog(QDialog):
self.notes_edit.setFixedHeight(60)
form.addRow("Nom :", self.name_edit)
form.addRow("Dossier :", self.group_edit)
form.addRow("Dossier :", group_row)
form.addRow("Tags :", self.tags_edit)
form.addRow("Hote :", self.host_edit)
form.addRow("Port :", self.port_spin)
form.addRow("Utilisateur :", self.user_edit)
@@ -125,6 +148,55 @@ class SessionDialog(QDialog):
self.key_combo.addItem(path, path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
# ---------- heritage des parametres du groupe ----------
def _apply_group_defaults(self) -> None:
group_path = self.group_edit.text().strip()
if not group_path:
QMessageBox.information(self, "Heriter du groupe", "Indiquez d'abord un dossier.")
return
settings = self.group_store.get(group_path) if self.group_store else None
if settings is None:
QMessageBox.information(
self, "Heriter du groupe",
f"Aucun parametre par defaut enregistre pour « {group_path} ».\n\n"
"Definissez-en depuis le panneau des sessions : clic droit sur le dossier "
"-> Parametres du groupe...",
)
return
if settings.username:
self.user_edit.setText(settings.username)
if settings.port:
self.port_spin.setValue(settings.port)
auth_idx = self.auth_combo.findData(settings.auth_type)
if auth_idx >= 0:
self.auth_combo.setCurrentIndex(auth_idx)
if settings.key_path:
existing = self.key_combo.findData(settings.key_path)
if existing >= 0:
self.key_combo.setCurrentIndex(existing)
else:
self.key_combo.addItem(settings.key_path, settings.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
if settings.bastion.mode != BASTION_NONE:
mode_idx = self.bastion_mode_combo.findData(settings.bastion.mode)
if mode_idx >= 0:
self.bastion_mode_combo.setCurrentIndex(mode_idx)
if settings.bastion.mode == BASTION_SESSION:
found = self.bastion_session_combo.findData(settings.bastion.session_id)
if found >= 0:
self.bastion_session_combo.setCurrentIndex(found)
elif settings.bastion.mode == BASTION_OPENSSH:
found = self.bastion_openssh_combo.findData(settings.bastion.openssh_alias)
if found >= 0:
self.bastion_openssh_combo.setCurrentIndex(found)
elif settings.bastion.mode == BASTION_MANUAL:
self.bastion_host_edit.setText(settings.bastion.host)
self.bastion_port_spin.setValue(settings.bastion.port or 22)
self.bastion_user_edit.setText(settings.bastion.username)
self.keepalive_check.setChecked(settings.keepalive_enabled)
self.keepalive_interval_spin.setValue(settings.keepalive_interval or 30)
self.keepalive_count_spin.setValue(settings.keepalive_count_max or 3)
# ---------- Bastion / Jump Host ----------
def _build_bastion_group(self) -> QGroupBox:
"""Un seul saut pour l'instant (voir BastionConfig) : Aucun / une session
@@ -359,6 +431,14 @@ class SessionDialog(QDialog):
self.session.auth_type = self.auth_combo.currentData()
self.session.key_path = self.key_combo.currentData() or ""
self.session.notes = self.notes_edit.toPlainText()
seen = set()
tags = []
for raw in self.tags_edit.text().split(","):
tag = raw.strip()
if tag and tag.lower() not in seen:
seen.add(tag.lower())
tags.append(tag)
self.session.tags = tags
self.session.bastion = self._result_bastion()
self.session.keepalive_enabled = self.keepalive_check.isChecked()
self.session.keepalive_interval = self.keepalive_interval_spin.value()

93
app/groups.py Normal file
View File

@@ -0,0 +1,93 @@
"""Valeurs par defaut d'un groupe (dossier de sessions) : username, port, cle
SSH, bastion, keepalive.
Choix delibere : l'heritage s'applique au moment de la creation d'une session
(pre-remplissage explicite dans l'editeur), pas par une resolution dynamique a
chaque connexion. Modifier les parametres d'un groupe ne change donc jamais
retroactivement les sessions deja creees - elles restent des valeurs normales,
librement modifiables, sans indirection a suivre dans tout le reste du code
(ConnectionController, SSHConnection, resolve_bastion_target...)."""
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from pathlib import Path
from app.paths import DATA_DIR
from app.storage.json_store import load_json_document, save_json_document
from app.session_manager import BastionConfig, AUTH_AUTO
GROUPS_FILE = DATA_DIR / "groups.json"
SCHEMA_VERSION = 1
@dataclass
class GroupSettings:
path: str = "" # ex: "Production/Web", correspond a Session.group
username: str = ""
port: int = 22
auth_type: str = AUTH_AUTO
key_path: str = ""
bastion: BastionConfig = field(default_factory=BastionConfig)
keepalive_enabled: bool = True
keepalive_interval: int = 30
keepalive_count_max: int = 3
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> "GroupSettings":
g = GroupSettings()
for k in GroupSettings.__dataclass_fields__:
if k not in data:
continue
v = data[k]
if k == "bastion" and isinstance(v, dict):
v = BastionConfig.from_dict(v)
setattr(g, k, v)
return g
class GroupSettingsStore:
"""Charge/sauvegarde les parametres par defaut de chaque groupe depuis
data/groups.json. Un groupe sans entree ici n'a simplement aucune valeur
par defaut (comportement actuel, inchange)."""
def __init__(self, path: Path = GROUPS_FILE):
self.path = path
self.groups: list[GroupSettings] = []
self.load_warning: str | None = None
self.load()
def load(self) -> None:
was_missing = not self.path.exists()
document, warning = load_json_document(
self.path,
default={"schema_version": SCHEMA_VERSION, "groups": []},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.groups = [GroupSettings.from_dict(g) for g in document.get("groups", [])]
if was_missing:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "groups": [g.to_dict() for g in self.groups]}
save_json_document(self.path, payload)
def get(self, group_path: str) -> GroupSettings | None:
return next((g for g in self.groups if g.path == group_path), None)
def set(self, settings: GroupSettings) -> None:
"""Cree ou remplace les parametres du groupe (identifie par son path)."""
for i, g in enumerate(self.groups):
if g.path == settings.path:
self.groups[i] = settings
self.save()
return
self.groups.append(settings)
self.save()
def delete(self, group_path: str) -> None:
self.groups = [g for g in self.groups if g.path != group_path]
self.save()

View File

@@ -24,6 +24,7 @@ from app.dialogs.quick_connect_dialog import QuickConnectDialog
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.workspace import WorkspaceStore
from app.snippets import SnippetStore
from app.groups import GroupSettingsStore
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController
from app.controllers.connection_controller import ConnectionController
@@ -44,6 +45,7 @@ class MainWindow(QMainWindow):
self.key_manager = KeyManager()
self.workspace_store = WorkspaceStore()
self.snippet_store = SnippetStore()
self.group_store = GroupSettingsStore()
self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController(
@@ -52,6 +54,7 @@ class MainWindow(QMainWindow):
)
self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
group_store=self.group_store,
)
self.workspace_manager = WorkspaceManager(
self.workspace_store, self.session_store, self.connection_controller, parent_widget=self,

View File

@@ -79,6 +79,8 @@ class Session:
reconnect_initial_delay: int = 5
reconnect_max_attempts: int = 10 # 0 = illimite
tags: list[str] = field(default_factory=list) # ex: ["prod", "docker"]
def to_dict(self) -> dict:
return asdict(self)
@@ -138,3 +140,9 @@ class SessionStore:
def groups(self) -> list[str]:
return sorted({s.group for s in self.sessions if s.group})
def tags(self) -> list[str]:
all_tags: set[str] = set()
for s in self.sessions:
all_tags.update(s.tags)
return sorted(all_tags, key=str.lower)