From 743b8d7a5ee0dd69cabf7e6c4dbda87cb984045e Mon Sep 17 00:00:00 2001 From: Pepe44DEV Date: Sun, 6 Sep 2026 22:57:39 +0000 Subject: [PATCH] Features: Quick Commands ausfuehrbar, Gruppenfilter, Duplizieren, Erreichbarkeits-Check, Themes, Kontextmenue - Quick Commands lokal oder via SSH auf dem ausgewaehlten Server starten - Server duplizieren, SSH-Befehl in die Zwischenablage kopieren - Erreichbarkeits-Check pro Server (TCP, Thread-basiert) mit Statuspunkt - Gruppenfilter-Chips in der Sidebar - Themes: neon-green, ocean-blue, sunset-orange, cyber-purple - Doppelklick verbindet, Rechtsklick-Kontextmenue, Shortcuts F5/Ctrl+N/Ctrl+S - Quick-Commands-Seite mit Buttons, About-Dialog, Config-Ordner oeffnen - Version 1.1.0 --- README.md | 14 +- desktop/pulsegate_desktop.py | 466 +++++++++++++++++++++++++++++------ installer/installer.iss | 2 +- 3 files changed, 399 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index e55e310..7fbbae5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,15 @@ Läuft nativ auf **Linux** und **Windows**. Für Windows liegt ein fertiger Inst - Desktop-UI ohne Browser - Serverliste mit Suche -- Server hinzufügen, bearbeiten und löschen -- SSH-Verbindung per Button starten -- SSH-Befehl pro Server anzeigen -- Terminal-Settings verwalten -- Quick Commands anzeigen +- Server hinzufügen, bearbeiten, duplizieren und löschen +- Gruppenfilter in der Sidebar +- SSH-Verbindung per Button, per Doppelklick oder über das Kontextmenü starten +- Erreichbarkeits-Check pro Server (TCP-Ping) +- SSH-Befehl pro Server anzeigen und in die Zwischenablage kopieren +- **Quick Commands ausführbar** – lokal oder via SSH auf dem ausgewählten Server +- Konfigurierbare Themes (neon-green, ocean-blue, sunset-orange, cyber-purple) +- Tastenkürzel: F5 neu laden, Ctrl+N neuer Server, Ctrl+S speichern +- Terminal-Settings verwalten, Config-Ordner direkt öffnen - Gemeinsame Konfiguration mit PulseGate TUI ## Voraussetzungen diff --git a/desktop/pulsegate_desktop.py b/desktop/pulsegate_desktop.py index 166893d..22f9d7b 100644 --- a/desktop/pulsegate_desktop.py +++ b/desktop/pulsegate_desktop.py @@ -5,8 +5,10 @@ from __future__ import annotations import os import shlex import shutil +import socket import subprocess import sys +import threading import tkinter as tk from pathlib import Path from tkinter import messagebox @@ -15,24 +17,73 @@ import yaml APP_TITLE = "PulseGate Desktop" -APP_VERSION = "1.0.0" +APP_VERSION = "1.1.0" +APP_URL = "https://git.pepe44.dev/Pepe44DEV/PulseGate-GUI" IS_WINDOWS = sys.platform == "win32" +CONNECT_TIMEOUT = 2.5 +STATUS_OK = "#00e07a" -COLORS = { - "bg": "#07110d", - "sidebar": "#050c09", - "surface": "#0d1b15", - "surface_2": "#13251e", - "surface_3": "#172c24", - "field": "#09150f", - "line": "#1f4b38", - "text": "#d7ffe9", - "muted": "#8aa99b", - "accent": "#00ff99", - "accent_2": "#33ccff", - "danger": "#ff6666", +THEMES = { + "neon-green": { + "bg": "#07110d", + "sidebar": "#050c09", + "surface": "#0d1b15", + "surface_2": "#13251e", + "surface_3": "#172c24", + "field": "#09150f", + "line": "#1f4b38", + "text": "#d7ffe9", + "muted": "#8aa99b", + "accent": "#00ff99", + "accent_2": "#33ccff", + "danger": "#ff6666", + }, + "ocean-blue": { + "bg": "#071018", + "sidebar": "#040b12", + "surface": "#0e1b26", + "surface_2": "#142638", + "surface_3": "#1a324a", + "field": "#091420", + "line": "#1e4a6e", + "text": "#d7ecff", + "muted": "#8aa7c0", + "accent": "#3ddcff", + "accent_2": "#5a8cff", + "danger": "#ff6a6a", + }, + "sunset-orange": { + "bg": "#120d07", + "sidebar": "#0b0804", + "surface": "#221711", + "surface_2": "#2f2215", + "surface_3": "#3b2d1d", + "field": "#17100a", + "line": "#6e4a1e", + "text": "#ffe9d7", + "muted": "#c0a98a", + "accent": "#ff9d3d", + "accent_2": "#ff6a5a", + "danger": "#ff6a6a", + }, + "cyber-purple": { + "bg": "#0f0711", + "sidebar": "#09050b", + "surface": "#1d0e22", + "surface_2": "#2a1133", + "surface_3": "#371a44", + "field": "#140918", + "line": "#5a1f6e", + "text": "#f0d7ff", + "muted": "#b38ac0", + "accent": "#c95aff", + "accent_2": "#6affcc", + "danger": "#ff6a6a", + }, } +COLORS = dict(THEMES["neon-green"]) + UI_FONT = ("Segoe UI", 11) if IS_WINDOWS else ("Inter", 11) UI_FONT_SMALL = ("Segoe UI", 9) if IS_WINDOWS else ("Inter", 9) UI_FONT_MONO = ("Consolas", 10) if IS_WINDOWS else ("JetBrains Mono", 10) @@ -84,17 +135,19 @@ def default_config() -> dict: "quick_commands": [ {"name": "Disk Usage", "command": "df -h"}, {"name": "RAM Usage", "command": "free -h"}, - {"name": "Uptime", "command": "uptime"}, + {"name": "Container Status", "command": "docker ps"}, ], } def normalize_config(config: dict) -> dict: config.setdefault("settings", {}) - config["settings"].setdefault("theme", "neon-green") - config["settings"].setdefault("terminal", {}) - config["settings"]["terminal"].setdefault("term", "xterm-256color") - config["settings"]["terminal"].setdefault("enable_kitty_fix", True) + settings = config["settings"] + settings.setdefault("theme", "neon-green") + settings.setdefault("quick_commands_enabled", True) + settings.setdefault("terminal", {}) + settings["terminal"].setdefault("term", "xterm-256color") + settings["terminal"].setdefault("enable_kitty_fix", True) config.setdefault("servers", []) config.setdefault("quick_commands", []) @@ -139,7 +192,7 @@ def expand_home(value: str) -> str: if IS_WINDOWS: profile = os.environ.get("USERPROFILE") if profile: - return os.path.join(profile, value[2:].replace("/", "\\")) + return profile + "\\" + value[2:].replace("/", "\\") return str(Path(value).expanduser()) @@ -158,6 +211,15 @@ def display_command(server: dict) -> str: return " ".join(shlex.quote(arg) for arg in args) +def remote_ssh_command(server: dict, command: str) -> str: + """Führt einen Befehl auf dem Server via SSH aus (im Terminal).""" + if IS_WINDOWS: + base = subprocess.list2cmdline(ssh_args(server)) + return base + " " + subprocess.list2cmdline([command]) + base = " ".join(shlex.quote(arg) for arg in ssh_args(server)) + return base + " " + shlex.quote(command) + + def detect_terminal() -> str | None: if IS_WINDOWS: for name in ("wt", "cmd", "powershell"): @@ -173,37 +235,44 @@ def detect_terminal() -> str | None: return None -def terminal_command(terminal: str, server: dict) -> list[str]: - title = f"PulseGate - {server.get('name') or server.get('host')}" - +def open_in_terminal(terminal: str, title: str, command: str, hold: bool = True, done_message: str = "Fertig.") -> list[str]: if IS_WINDOWS: - params = subprocess.list2cmdline(ssh_args(server)) stem = Path(terminal).stem.lower() if stem in ("powershell", "pwsh"): - hold = f"& {params}; Write-Host ''; Write-Host 'SSH beendet.'; Read-Host 'Enter zum Schliessen...'" - return [terminal, "-NoExit", "-Command", hold] - hold = f"{params} & echo. & echo SSH beendet. Bitte Fenster schliessen... & pause" + suffix = f"; Write-Host ''; Write-Host '{done_message}'; Read-Host 'Enter zum Schliessen...'" if hold else "" + return [terminal, "-NoExit", "-Command", f"& {command}{suffix}"] + suffix = f" & echo. & echo {done_message} & pause" if hold else "" + inner = command + suffix if stem == "wt": - return [terminal, "new-tab", "--title", title, "cmd.exe", "/k", hold] - return [terminal, "/k", hold] - - hold = " ".join(shlex.quote(arg) for arg in ssh_args(server)) + "; printf '\\nSSH beendet. Enter zum Schließen...'; read _" + return [terminal, "new-tab", "--title", title, "cmd.exe", "/k", inner] + return [terminal, "/k", inner] + suffix = f"; printf '\\n{done_message} Enter zum Schließen...'; read _" if hold else "" + inner = command + suffix name = Path(terminal).name if name == "kitty": - return [terminal, "--title", title, "sh", "-lc", hold] + return [terminal, "--title", title, "sh", "-lc", inner] if name == "alacritty": - return [terminal, "--title", title, "-e", "sh", "-lc", hold] + return [terminal, "--title", title, "-e", "sh", "-lc", inner] if name == "konsole": - return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", hold] + return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", inner] if name == "gnome-terminal": - return [terminal, "--title", title, "--", "sh", "-lc", hold] + return [terminal, "--title", title, "--", "sh", "-lc", inner] if name == "xfce4-terminal": - return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(hold)}"] + return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(inner)}"] if name == "xterm": - return [terminal, "-T", title, "-e", "sh", "-lc", hold] + return [terminal, "-T", title, "-e", "sh", "-lc", inner] - return [terminal, "-e", "sh", "-lc", hold] + return [terminal, "-e", "sh", "-lc", inner] + + +def terminal_command(terminal: str, server: dict) -> list[str]: + title = f"PulseGate - {server.get('name') or server.get('host')}" + if IS_WINDOWS: + command = subprocess.list2cmdline(ssh_args(server)) + return open_in_terminal(terminal, title, command, done_message="SSH beendet.") + command = " ".join(shlex.quote(arg) for arg in ssh_args(server)) + return open_in_terminal(terminal, title, command, done_message="SSH beendet.") class PulseGateDesktop(tk.Tk): @@ -213,7 +282,13 @@ class PulseGateDesktop(tk.Tk): self.config_data = load_config(path) self.selected_index = 0 self.current_view = "servers" + self.group_filter = "Alle" self.terminal = detect_terminal() + self.reachability: dict[int, bool] = {} + self.checked_hosts: dict[tuple[str, int], bool] = {} + self.reach_labels: dict[int, tk.Label] = {} + + COLORS.update(THEMES.get(self.config_data["settings"].get("theme", "neon-green"), THEMES["neon-green"])) self.vars: dict[str, tk.Variable] = {} self.settings_vars: dict[str, tk.Variable] = {} @@ -223,13 +298,17 @@ class PulseGateDesktop(tk.Tk): self.status_var = tk.StringVar(value="Bereit") self.title(APP_TITLE) - self.geometry("1180x760") - self.minsize(980, 640) + self.geometry("1240x800") + self.minsize(1000, 640) self.configure(bg=COLORS["bg"]) self._build_layout() self._refresh_all() + self.bind("", lambda _event: self._reload()) + self.bind("", lambda _event: self._add_server()) + self.bind("", lambda _event: self._save_current_server()) + def _build_layout(self) -> None: root = tk.Frame(self, bg=COLORS["bg"]) root.pack(fill="both", expand=True) @@ -238,12 +317,12 @@ class PulseGateDesktop(tk.Tk): self._build_content(root) def _build_sidebar(self, parent: tk.Frame) -> None: - sidebar = tk.Frame(parent, bg=COLORS["sidebar"], width=282) + sidebar = tk.Frame(parent, bg=COLORS["sidebar"], width=290) sidebar.pack(side="left", fill="y") sidebar.pack_propagate(False) brand = tk.Frame(sidebar, bg=COLORS["sidebar"]) - brand.pack(fill="x", padx=22, pady=(24, 22)) + brand.pack(fill="x", padx=22, pady=(24, 20)) mark = tk.Label( brand, @@ -263,9 +342,13 @@ class PulseGateDesktop(tk.Tk): self._label(sidebar, "Suche", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", padx=22) search = self._entry(sidebar, self.search_var) - search.pack(fill="x", padx=22, pady=(7, 22)) + search.pack(fill="x", padx=22, pady=(7, 14)) self.search_var.trace_add("write", lambda *_: self._refresh_server_cards()) + self._label(sidebar, "Gruppenfilter", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", padx=22) + self.filter_row = tk.Frame(sidebar, bg=COLORS["sidebar"]) + self.filter_row.pack(fill="x", padx=22, pady=(7, 20)) + nav = tk.Frame(sidebar, bg=COLORS["sidebar"]) nav.pack(fill="x", padx=22) for view, label in (("servers", "Server"), ("commands", "Commands"), ("settings", "Settings")): @@ -274,12 +357,14 @@ class PulseGateDesktop(tk.Tk): self.nav_buttons[view] = button stats = self._card(sidebar, bg=COLORS["surface"], padx=14, pady=12) - stats.pack(fill="x", padx=22, pady=(28, 0)) + stats.pack(fill="x", padx=22, pady=(28, 10)) self.server_count_label = self._label(stats, "", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD) self.server_count_label.pack(anchor="w") terminal_text = "Terminal bereit" if self.terminal else "Kein Terminal gefunden" self._label(stats, terminal_text, bg=COLORS["surface"], fg=COLORS["accent_2"], font=FONT_SMALL).pack(anchor="w", pady=(7, 0)) - self._label(stats, str(self.path), bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL, wraplength=205).pack(anchor="w", pady=(8, 0)) + self._label(stats, str(self.path), bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL, wraplength=210).pack(anchor="w", pady=(8, 0)) + + self._button(sidebar, "Über PulseGate", self._about, variant="ghost").pack(fill="x", padx=22, side="bottom", pady=(0, 16)) def _build_content(self, parent: tk.Frame) -> None: content = tk.Frame(parent, bg=COLORS["bg"]) @@ -309,7 +394,7 @@ class PulseGateDesktop(tk.Tk): self._build_settings_page(self.pages["settings"]) def _build_servers_page(self, page: tk.Frame) -> None: - left = tk.Frame(page, bg=COLORS["bg"], width=390) + left = tk.Frame(page, bg=COLORS["bg"], width=400) left.pack(side="left", fill="both", padx=(0, 18)) left.pack_propagate(False) @@ -317,11 +402,10 @@ class PulseGateDesktop(tk.Tk): scrollbar = tk.Scrollbar(left, orient="vertical", command=self.server_canvas.yview, bg=COLORS["bg"], troughcolor=COLORS["bg"]) self.server_cards = tk.Frame(self.server_canvas, bg=COLORS["bg"]) self.server_cards.bind("", lambda _event: self.server_canvas.configure(scrollregion=self.server_canvas.bbox("all"))) - self.server_canvas.create_window((0, 0), window=self.server_cards, anchor="nw", width=372) + self.server_canvas.create_window((0, 0), window=self.server_cards, anchor="nw", width=382) self.server_canvas.configure(yscrollcommand=scrollbar.set) self.server_canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") - self.server_canvas.bind_all("", self._on_mousewheel) editor_outer = self._card(page, bg=COLORS["surface"], padx=18, pady=18) editor_outer.pack(side="left", fill="both", expand=True) @@ -355,18 +439,7 @@ class PulseGateDesktop(tk.Tk): var = tk.StringVar() self.vars[key] = var if key == "auth": - widget = tk.OptionMenu(cell, var, "key", "password") - widget.configure( - bg=COLORS["field"], - fg=COLORS["text"], - activebackground=COLORS["surface_2"], - activeforeground=COLORS["text"], - highlightthickness=1, - highlightbackground=COLORS["line"], - relief="flat", - font=FONT, - ) - widget["menu"].configure(bg=COLORS["surface_2"], fg=COLORS["text"], activebackground=COLORS["accent"], activeforeground="#001a10") + widget = self._option_menu(cell, var, ("key", "password")) else: widget = self._entry(cell, var) widget.pack(fill="x", pady=(6, 0), ipady=3) @@ -385,18 +458,31 @@ class PulseGateDesktop(tk.Tk): activeforeground=COLORS["text"], selectcolor=COLORS["field"], font=FONT, - ).pack(anchor="w", pady=(12, 4)) + ).pack(anchor="w", pady=(12, 0)) + + hint = tk.Frame(editor_outer, bg=COLORS["surface"]) + hint.pack(fill="x", pady=(10, 14)) + self._label( + hint, + "Tipp: Doppelklick auf einen Server verbindet direkt · Rechtsklick öffnet das Kontextmenü · Ctrl+S speichert.", + bg=COLORS["surface"], + fg=COLORS["muted"], + font=FONT_SMALL, + wraplength=520, + ).pack(anchor="w") actions = tk.Frame(editor_outer, bg=COLORS["surface"]) - actions.pack(fill="x", pady=(16, 14)) + actions.pack(fill="x") self._button(actions, "Verbinden", self._connect).pack(side="left") self._button(actions, "SSH Befehl", self._show_command, variant="ghost").pack(side="left", padx=(8, 0)) + self._button(actions, "Kopieren", self._copy_command, variant="ghost").pack(side="left", padx=(8, 0)) self._button(actions, "Löschen", self._delete_current_server, variant="danger").pack(side="right") + self._button(actions, "Duplizieren", self._duplicate_current_server, variant="ghost").pack(side="right", padx=(0, 8)) self._button(actions, "Speichern", self._save_current_server).pack(side="right", padx=(0, 8)) self.command_text = tk.Text( editor_outer, - height=6, + height=5, bg=COLORS["field"], fg=COLORS["accent"], insertbackground=COLORS["text"], @@ -406,27 +492,44 @@ class PulseGateDesktop(tk.Tk): padx=12, pady=10, ) - self.command_text.pack(fill="both", expand=True) + self.command_text.pack(fill="both", expand=True, pady=(14, 0)) def _build_commands_page(self, page: tk.Frame) -> None: self.commands_host = tk.Frame(page, bg=COLORS["bg"]) self.commands_host.pack(fill="both", expand=True) + intro = self._card(self.commands_host, bg=COLORS["surface"], padx=16, pady=14) + intro.pack(fill="x", pady=(0, 12)) + self._label(intro, "Quick Commands", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(anchor="w") + self._label( + intro, + "Befehle können lokal auf diesem Rechner oder auf dem aktuell ausgewählten Server (via SSH) ausgeführt werden.", + bg=COLORS["surface"], + fg=COLORS["muted"], + font=FONT_SMALL, + wraplength=760, + ).pack(anchor="w", pady=(5, 0)) def _build_settings_page(self, page: tk.Frame) -> None: panel = self._card(page, bg=COLORS["surface"], padx=22, pady=22) panel.pack(anchor="nw", fill="x") - self._label(panel, "Terminal", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w") - self._label(panel, "Diese Werte werden beim Starten einer SSH-Verbindung genutzt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 18)) + self._label(panel, "Darstellung", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w") + self._label(panel, "Das Theme wird beim App-Start angewandt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 12)) + theme_label = self._label(panel, "Theme", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL) + theme_label.pack(anchor="w") self.settings_vars["theme"] = tk.StringVar() - self.settings_vars["term"] = tk.StringVar() - self.settings_vars["enable_kitty_fix"] = tk.BooleanVar() + self._option_menu(panel, self.settings_vars["theme"], tuple(THEMES.keys())).pack(fill="x", pady=(6, 18), ipady=3) - for label, key in (("Theme", "theme"), ("TERM Override", "term")): + self._label(panel, "Terminal", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w") + self._label(panel, "Diese Werte werden beim Starten einer SSH-Verbindung genutzt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 12)) + + self.settings_vars["term"] = tk.StringVar() + for label, key in (("TERM Override", "term"),): self._label(panel, label, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w") self._entry(panel, self.settings_vars[key]).pack(fill="x", pady=(6, 14), ipady=3) + self.settings_vars["enable_kitty_fix"] = tk.BooleanVar() tk.Checkbutton( panel, text="Kitty Fix global aktivieren", @@ -439,32 +542,71 @@ class PulseGateDesktop(tk.Tk): font=FONT, ).pack(anchor="w", pady=(0, 18)) - self._button(panel, "Settings speichern", self._save_settings).pack(anchor="e") + actions = tk.Frame(panel, bg=COLORS["surface"]) + actions.pack(fill="x") + self._button(actions, "Config-Ordner öffnen", self._open_config_folder, variant="ghost").pack(side="left") + self._button(actions, "Settings speichern", self._save_settings).pack(side="right") def _refresh_all(self) -> None: - self.server_count_label.configure(text=f"{len(self.config_data['servers'])} Server") + self._refresh_group_chips() self._refresh_server_cards() self._load_selected_server() self._refresh_commands() self._refresh_settings() self._show_view(self.current_view) + def _refresh_group_chips(self) -> None: + for child in self.filter_row.winfo_children(): + child.destroy() + + groups = ["Alle"] + sorted({server.get("group") or "Keine Gruppe" for server in self.config_data["servers"]}) + for group in groups: + selected = group == self.group_filter + chip = tk.Label( + self.filter_row, + text=group, + bg=COLORS["accent"] if selected else COLORS["surface_2"], + fg="#001a10" if selected else COLORS["text"], + font=FONT_SMALL, + padx=9, + pady=4, + cursor="hand2", + highlightthickness=1, + highlightbackground=COLORS["line"], + ) + chip.pack(side="left", padx=(0, 6)) + chip.bind("", lambda _event, value=group: self._set_group_filter(value)) + + def _set_group_filter(self, group: str) -> None: + self.group_filter = group + self._refresh_group_chips() + self._refresh_server_cards() + def _refresh_server_cards(self) -> None: for child in self.server_cards.winfo_children(): child.destroy() + self.reach_labels.clear() - query = self.search_var.get().strip().lower() + query = (self.search_var.get() or "").strip().lower() rendered = 0 for index, server in enumerate(self.config_data["servers"]): haystack = " ".join(str(server.get(key, "")) for key in ("name", "host", "user", "group", "auth")).lower() if query and query not in haystack: continue + group = server.get("group") or "Keine Gruppe" + if self.group_filter != "Alle" and group != self.group_filter: + continue self._server_card(self.server_cards, server, index).pack(fill="x", pady=(0, 10)) rendered += 1 + self._check_reachability(server, index) if rendered == 0: self._empty_state(self.server_cards, "Keine Server gefunden").pack(fill="x") + total = len(self.config_data["servers"]) + text = f"{rendered} / {total} Server" if rendered != total else f"{total} Server" + self.server_count_label.configure(text=text) + def _server_card(self, parent: tk.Frame, server: dict, index: int) -> tk.Frame: selected = index == self.selected_index bg = COLORS["surface_2"] if selected else COLORS["surface"] @@ -487,13 +629,63 @@ class PulseGateDesktop(tk.Tk): if server.get("kitty_fix"): self._chip(chips, "kitty", COLORS["muted"], bg=bg).pack(side="left", padx=(6, 0)) + status = self.reachability.get(index) + if status is None: + status_text, status_fg = "● prüfe ...", COLORS["muted"] + elif status: + status_text, status_fg = "● erreichbar", STATUS_OK + else: + status_text, status_fg = "● nicht erreichbar", COLORS["danger"] + status_label = self._label(inner, status_text, bg=bg, fg=status_fg, font=FONT_SMALL) + status_label.pack(anchor="w", pady=(8, 0)) + self.reach_labels[index] = status_label + for widget in (card, inner): widget.bind("", lambda _event, value=index: self._select_server(value)) + widget.bind("", lambda _event, value=index: self._connect_at(value)) + widget.bind("", lambda event, value=index: self._server_menu(event, value)) for child in inner.winfo_children(): child.bind("", lambda _event, value=index: self._select_server(value)) + child.bind("", lambda _event, value=index: self._connect_at(value)) + child.bind("", lambda event, value=index: self._server_menu(event, value)) return card + def _check_reachability(self, server: dict, index: int) -> None: + host = str(server.get("host") or "").strip() + if not host: + return + try: + port = int(server.get("port") or 22) + except (TypeError, ValueError): + return + + cache_key = (host, port) + if cache_key in self.checked_hosts: + self.reachability[index] = self.checked_hosts[cache_key] + self._update_reachability_indicator(index, self.checked_hosts[cache_key]) + return + + def worker() -> None: + try: + with socket.create_connection((host, port), timeout=CONNECT_TIMEOUT): + reachable = True + except OSError: + reachable = False + self.checked_hosts[cache_key] = reachable + self.reachability[index] = reachable + self.after(0, lambda: self._update_reachability_indicator(index, reachable)) + + threading.Thread(target=worker, daemon=True).start() + + def _update_reachability_indicator(self, index: int, reachable: bool) -> None: + label = self.reach_labels.get(index) + if label is not None and label.winfo_exists(): + label.configure( + text="● erreichbar" if reachable else "● nicht erreichbar", + fg=STATUS_OK if reachable else COLORS["danger"], + ) + def _refresh_commands(self) -> None: for child in self.commands_host.winfo_children(): child.destroy() @@ -509,6 +701,20 @@ class PulseGateDesktop(tk.Tk): self._label(card, command.get("name") or "Command", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(anchor="w") self._label(card, command.get("command") or "", bg=COLORS["surface"], fg=COLORS["accent"], font=UI_FONT_MONO).pack(anchor="w", pady=(7, 0)) + actions = tk.Frame(card, bg=COLORS["surface"]) + actions.pack(fill="x", pady=(12, 0)) + self._button( + actions, + "Lokal ausführen", + lambda value=command: self._run_quick_command(value, via_ssh=False), + variant="ghost", + ).pack(side="left") + self._button( + actions, + "Auf Server ausführen", + lambda value=command: self._run_quick_command(value, via_ssh=True), + ).pack(side="left", padx=(8, 0)) + def _refresh_settings(self) -> None: settings = self.config_data["settings"] terminal = settings["terminal"] @@ -609,6 +815,18 @@ class PulseGateDesktop(tk.Tk): self.current_view = "servers" self._refresh_all() + def _duplicate_current_server(self) -> None: + if not self.config_data["servers"]: + return + original = self.config_data["servers"][self.selected_index] + duplicate = dict(original) + duplicate["name"] = f"{original.get('name') or 'Server'} (Kopie)" + self.config_data["servers"].append(duplicate) + self.selected_index = len(self.config_data["servers"]) - 1 + save_config(self.path, self.config_data) + self.status_var.set("Server dupliziert") + self._refresh_all() + def _delete_current_server(self) -> None: if not self.config_data["servers"]: return @@ -630,7 +848,29 @@ class PulseGateDesktop(tk.Tk): }, } save_config(self.path, self.config_data) - self.status_var.set("Settings gespeichert") + self.status_var.set("Settings gespeichert (Theme ab Neustart)") + self.config_data = load_config(self.path) + self._refresh_settings() + + def _open_config_folder(self) -> None: + folder = self.path.parent + try: + if IS_WINDOWS: + os.startfile(str(folder)) + else: + subprocess.Popen(["xdg-open", str(folder)]) + except OSError as error: + messagebox.showerror(APP_TITLE, str(error)) + return + self.status_var.set(f"Config-Ordner geöffnet: {folder}") + + def _about(self) -> None: + messagebox.showinfo( + "Über PulseGate Desktop", + f"PulseGate Desktop {APP_VERSION}\n\n" + "SSH-Manager für Linux und Windows.\n\n" + f"{APP_URL}", + ) def _reload(self) -> None: self.config_data = load_config(self.path) @@ -651,6 +891,20 @@ class PulseGateDesktop(tk.Tk): self.command_text.delete("1.0", "end") self.command_text.insert("1.0", display_command(server)) + def _copy_command(self) -> None: + server = self._current_server() + if not server: + return + self.clipboard_clear() + self.clipboard_append(display_command(server)) + self.update() + self.status_var.set(f"SSH Befehl kopiert: {server.get('name')}") + + def _connect_at(self, index: int) -> None: + self.selected_index = index + self._load_selected_server() + self._connect() + def _connect(self) -> None: server = self._current_server() if not server: @@ -675,9 +929,46 @@ class PulseGateDesktop(tk.Tk): self.status_var.set(f"SSH gestartet: {server.get('name')}") self._show_command() - def _on_mousewheel(self, event: tk.Event) -> None: - if self.current_view == "servers": - self.server_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + def _run_quick_command(self, command: dict, via_ssh: bool = False) -> None: + if not self.terminal: + messagebox.showerror(APP_TITLE, "Kein unterstützter Terminal-Emulator gefunden.") + return + + raw = (command.get("command") or "").strip() + name = command.get("name") or "Quick Command" + if not raw: + messagebox.showwarning(APP_TITLE, f"'{name}' hat keinen Befehl.") + return + + try: + if via_ssh: + server = self._current_server() + if not server: + return + inner = remote_ssh_command(server, raw) + title = f"PulseGate - {server.get('name')}" + else: + inner = raw + title = f"PulseGate - {name}" + subprocess.Popen(open_in_terminal(self.terminal, title, inner)) + except OSError as error: + messagebox.showerror(APP_TITLE, str(error)) + return + + self.status_var.set(f"Befehl gestartet: {name}" + (" (via SSH)" if via_ssh else "")) + + def _server_menu(self, event: tk.Event, index: int) -> None: + self._select_server(index) + menu = tk.Menu(self, tearoff=0) + menu.add_command(label="Verbinden", command=self._connect) + menu.add_command(label="SSH Befehl kopieren", command=self._copy_command) + menu.add_separator() + menu.add_command(label="Duplizieren", command=self._duplicate_current_server) + menu.add_command(label="Löschen ...", command=self._delete_current_server) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() def _label(self, parent: tk.Widget, text: str, *, bg: str, fg: str, font: tuple, wraplength: int = 0) -> tk.Label: return tk.Label(parent, text=text, bg=bg, fg=fg, font=font, anchor="w", justify="left", wraplength=wraplength) @@ -696,10 +987,31 @@ class PulseGateDesktop(tk.Tk): font=FONT, ) + def _option_menu(self, parent: tk.Widget, variable: tk.StringVar, options: tuple[str, ...]) -> tk.OptionMenu: + widget = tk.OptionMenu(parent, variable, *options) + widget.configure( + bg=COLORS["field"], + fg=COLORS["text"], + activebackground=COLORS["surface_2"], + activeforeground=COLORS["text"], + highlightthickness=1, + highlightbackground=COLORS["line"], + relief="flat", + font=FONT, + ) + widget["menu"].configure( + bg=COLORS["surface_2"], + fg=COLORS["text"], + activebackground=COLORS["accent"], + activeforeground="#001a10", + font=FONT, + ) + return widget + def _button(self, parent: tk.Widget, text: str, command, *, variant: str = "primary") -> tk.Button: bg = COLORS["accent"] fg = "#001a10" - active_bg = "#42ffb5" + active_bg = COLORS["accent"] if variant == "ghost": bg = COLORS["surface_2"] fg = COLORS["text"] diff --git a/installer/installer.iss b/installer/installer.iss index 37a87a4..9402f30 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -2,7 +2,7 @@ ; Kompilieren: iscc installer\installer.iss (Inno Setup 6) #define MyAppName "PulseGate Desktop" -#define MyAppVersion "1.0.0" +#define MyAppVersion "1.1.0" #define MyAppPublisher "Pepe44DEV" #define MyAppURL "https://git.pepe44.dev/Pepe44DEV/PulseGate-GUI" #define MyAppExeName "PulseGate.exe"