diff --git a/.gitignore b/.gitignore index 0050526..87ce805 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ data/sessions.json* data/keys.json* data/workspaces.json* data/snippets.json* +data/groups.json* data/known_hosts* data/keys/* !data/keys/.gitkeep diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index b180a82..fb15047 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -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() diff --git a/app/dialogs/group_settings_dialog.py b/app/dialogs/group_settings_dialog.py new file mode 100644 index 0000000..693599e --- /dev/null +++ b/app/dialogs/group_settings_dialog.py @@ -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() diff --git a/app/dialogs/session_dialog.py b/app/dialogs/session_dialog.py index b595f8c..6ceb896 100644 --- a/app/dialogs/session_dialog.py +++ b/app/dialogs/session_dialog.py @@ -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() diff --git a/app/groups.py b/app/groups.py new file mode 100644 index 0000000..c00d154 --- /dev/null +++ b/app/groups.py @@ -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() diff --git a/app/main_window.py b/app/main_window.py index 59b8908..e73f1f6 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -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, diff --git a/app/session_manager.py b/app/session_manager.py index ba134bb..d503c72 100644 --- a/app/session_manager.py +++ b/app/session_manager.py @@ -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) diff --git a/tests/test_tags_and_groups.py b/tests/test_tags_and_groups.py new file mode 100644 index 0000000..f20a8c8 --- /dev/null +++ b/tests/test_tags_and_groups.py @@ -0,0 +1,130 @@ +"""Tags de session et parametres par defaut d'un groupe : persistance, saisie/ +normalisation des tags dans l'editeur, heritage explicite, filtrage par tag +dans le panneau lateral.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication, QWidget + +from app.session_manager import Session, SessionStore, AUTH_KEY +from app.key_manager import KeyManager +from app.groups import GroupSettings, GroupSettingsStore +from app.dialogs.session_dialog import SessionDialog +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.session_controller import SessionController + +app = QApplication.instance() or QApplication([]) + +tmp = Path(tempfile.mkdtemp()) +absent_ssh_config = tmp / "no-such-ssh-config" + +print("=== 1. Tags : persistance via SessionStore ===") +store = SessionStore(path=tmp / "sessions.json") +session = Session(name="web1", host="10.0.0.1", username="root", tags=["prod", "docker"]) +store.add(session) +reloaded = SessionStore(path=store.path) +assert reloaded.get(session.id).tags == ["prod", "docker"] +print(" OK") + +print() +print("=== 2. SessionStore.tags() : ensemble trie de tous les tags connus ===") +store.add(Session(name="web2", host="10.0.0.2", username="root", tags=["prod", "linux"])) +print(" tags:", store.tags()) +assert store.tags() == ["docker", "linux", "prod"] +print(" OK") + +print() +print("=== 3. GroupSettingsStore : CRUD et persistance ===") +gstore = GroupSettingsStore(path=tmp / "groups.json") +settings = GroupSettings(path="Production/Web", username="deploy", port=2222) +gstore.set(settings) +assert gstore.get("Production/Web").username == "deploy" +reloaded_gstore = GroupSettingsStore(path=gstore.path) +assert reloaded_gstore.get("Production/Web").port == 2222 +settings.username = "admin" +gstore.set(settings) # met a jour (meme path), ne duplique pas +assert len(gstore.groups) == 1 +assert gstore.get("Production/Web").username == "admin" +gstore.delete("Production/Web") +assert gstore.get("Production/Web") is None +print(" OK") + +print() +print("=== 4. SessionDialog : normalisation des tags saisis ===") +key_manager = KeyManager(registry_path=tmp / "keys.json") +dlg = SessionDialog(key_manager, session=Session(name="t", host="h", username="u"), session_store=store) +dlg.tags_edit.setText(" Prod , docker,, prod , Docker ") +dlg.host_edit.setText("h") +dlg.user_edit.setText("u") +dlg._on_accept() +print(" tags normalises:", dlg.session.tags) +# doublons retires en respectant la casse (et l'ordre) de la premiere occurrence +assert dlg.session.tags == ["Prod", "docker"], dlg.session.tags +print(" OK") + +print() +print("=== 5. SessionDialog : heriter du groupe applique les valeurs par defaut ===") +gstore2 = GroupSettingsStore(path=tmp / "groups2.json") +gstore2.set(GroupSettings(path="Homelab", username="admin", port=2200, auth_type=AUTH_KEY)) +dlg2 = SessionDialog(key_manager, session=Session(name="t2", host="h", group="Homelab"), + session_store=store, group_store=gstore2) +dlg2._apply_group_defaults() +print(" utilisateur/port apres heritage:", dlg2.user_edit.text(), dlg2.port_spin.value()) +assert dlg2.user_edit.text() == "admin" +assert dlg2.port_spin.value() == 2200 +assert dlg2.auth_combo.currentData() == AUTH_KEY +print(" OK") + +print() +print("=== 6. SessionDialog : heriter d'un groupe sans parametres -> message, pas de crash ===") +dlg3 = SessionDialog(key_manager, session=Session(name="t3", host="h", group="Inconnu"), + session_store=store, group_store=gstore2) +with patch("app.dialogs.session_dialog.QMessageBox.information") as info: + dlg3._apply_group_defaults() + assert info.called +print(" OK") + +print() +print("=== 7. Filtrage par tag dans le panneau lateral (masque les sessions non concernees) ===") +store3 = SessionStore(path=tmp / "sessions3.json") +store3.add(Session(name="avec-tag", host="10.0.0.9", username="root", tags=["special"])) +store3.add(Session(name="sans-tag", host="10.0.0.10", username="root")) +host_widget = QWidget() +broadcast = BroadcastManager(lambda: [host_widget]) +sc = SessionController(store3, key_manager, broadcast.button, parent_widget=host_widget, + openssh_config_path=absent_ssh_config) + + +def find_item(name): + def visit(item): + if item.text(0).strip().startswith(f"🖥 {name}"): + return item + for i in range(item.childCount()): + found = visit(item.child(i)) + if found: + return found + return None + for i in range(sc.tree.topLevelItemCount()): + found = visit(sc.tree.topLevelItem(i)) + if found: + return found + return None + + +sc.tag_filter_combo.setCurrentText("special") +tagged_item = find_item("avec-tag") +untagged_item = find_item("sans-tag") +print(" 'avec-tag' masque:", tagged_item.isHidden(), "| 'sans-tag' masque:", untagged_item.isHidden()) +assert not tagged_item.isHidden() +assert untagged_item.isHidden() + +sc.tag_filter_combo.setCurrentText("") +assert not find_item("sans-tag").isHidden() +print(" OK") + +print() +print("TAGS ET GROUPES : TOUS LES TESTS OK")