#!/usr/bin/env python3 # -*- coding: utf-8 -*- 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 import yaml APP_TITLE = "PulseGate Desktop" APP_VERSION = "1.2.0" APP_URL = "https://git.pepe44.dev/Pepe44DEV/PulseGate-GUI" IS_WINDOWS = sys.platform == "win32" CONNECT_TIMEOUT = 2.5 STATUS_OK = "#00e07a" THEMES = { "neon-green": { "bg": "#050d09", "sidebar": "#040b08", "surface": "#0c1a13", "surface_2": "#12241b", "surface_3": "#193127", "field": "#08130e", "line": "#1d4a37", "text": "#d9ffe9", "muted": "#86a998", "accent": "#00ff99", "accent_hi": "#5cffb9", "accent_2": "#3ecfff", "danger": "#ff7070", }, "ocean-blue": { "bg": "#061019", "sidebar": "#040b13", "surface": "#0d1d29", "surface_2": "#13293a", "surface_3": "#1a364c", "field": "#081420", "line": "#1c4a6e", "text": "#d8edff", "muted": "#86a7c2", "accent": "#3ddcff", "accent_hi": "#8fecff", "accent_2": "#6f9bff", "danger": "#ff7a7a", }, "sunset-orange": { "bg": "#130d06", "sidebar": "#0e0904", "surface": "#241911", "surface_2": "#312419", "surface_3": "#3f2f20", "field": "#181009", "line": "#6c4a1d", "text": "#ffead6", "muted": "#c3ab8b", "accent": "#ff9d3d", "accent_hi": "#ffbd7c", "accent_2": "#ff7a5c", "danger": "#ff7a7a", }, "cyber-purple": { "bg": "#0f0712", "sidebar": "#0a050c", "surface": "#1e0f24", "surface_2": "#2b1534", "surface_3": "#3a1d46", "field": "#150a19", "line": "#5c2172", "text": "#f2d9ff", "muted": "#b590c2", "accent": "#c95aff", "accent_hi": "#e0a0ff", "accent_2": "#6affcc", "danger": "#ff7a7a", }, } 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) FONT = UI_FONT FONT_SMALL = UI_FONT_SMALL FONT_TITLE = (UI_FONT[0], 24, "bold") FONT_CARD = (UI_FONT[0], 13, "bold") def config_path() -> Path: if len(sys.argv) > 1: return Path(sys.argv[1]).expanduser() if IS_WINDOWS: appdata = os.environ.get("APPDATA") if appdata: return Path(appdata) / "pulsegate" / "config.yaml" return Path.home() / "AppData" / "Roaming" / "pulsegate" / "config.yaml" config_home = os.environ.get("XDG_CONFIG_HOME") if config_home: return Path(config_home) / "pulsegate" / "config.yaml" return Path.home() / ".config" / "pulsegate" / "config.yaml" def default_config() -> dict: return { "settings": { "theme": "neon-green", "terminal": { "term": "xterm-256color", "enable_kitty_fix": True, }, }, "servers": [ { "name": "Example Server", "host": "10.0.0.10", "user": "root", "port": 22, "group": "Homelab", "auth": "key", "key": "~/.ssh/id_ed25519", "password_id": "", "kitty_fix": True, } ], "quick_commands": [ {"name": "Disk Usage", "command": "df -h"}, {"name": "RAM Usage", "command": "free -h"}, {"name": "Container Status", "command": "docker ps"}, ], } def normalize_config(config: dict) -> dict: config.setdefault("settings", {}) 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", []) for server in config["servers"]: server.setdefault("name", "") server.setdefault("host", "") server.setdefault("user", "root") server.setdefault("port", 22) server.setdefault("group", "Homelab") server.setdefault("auth", "key") server.setdefault("key", "") server.setdefault("password_id", "") server.setdefault("kitty_fix", True) return config def load_config(path: Path) -> dict: if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) save_config(path, default_config()) with path.open("r", encoding="utf-8") as handle: data = yaml.safe_load(handle) or {} return normalize_config(data) def save_config(path: Path, config: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: yaml.safe_dump(normalize_config(config), handle, sort_keys=False, allow_unicode=True) try: path.chmod(0o600) except OSError: pass def expand_home(value: str) -> str: if not (value.startswith("~/") or value.startswith("~\\")): return value if IS_WINDOWS: profile = os.environ.get("USERPROFILE") if profile: return profile + "\\" + value[2:].replace("/", "\\") return str(Path(value).expanduser()) def ssh_args(server: dict) -> list[str]: args = ["ssh", "-p", str(server.get("port") or 22)] if server.get("auth") == "key" and server.get("key"): args.extend(["-i", expand_home(str(server["key"]))]) args.append(f"{server.get('user', 'root')}@{server.get('host', '')}") return args def display_command(server: dict) -> str: args = ssh_args(server) if IS_WINDOWS: return subprocess.list2cmdline(args) 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"): path = shutil.which(name) if path: return path return None for name in ("kitty", "alacritty", "konsole", "gnome-terminal", "xfce4-terminal", "xterm"): path = shutil.which(name) if path: return path return None def open_in_terminal(terminal: str, title: str, command: str, hold: bool = True, done_message: str = "Fertig.") -> list[str]: if IS_WINDOWS: stem = Path(terminal).stem.lower() if stem in ("powershell", "pwsh"): 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", 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", inner] if name == "alacritty": return [terminal, "--title", title, "-e", "sh", "-lc", inner] if name == "konsole": return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", inner] if name == "gnome-terminal": return [terminal, "--title", title, "--", "sh", "-lc", inner] if name == "xfce4-terminal": return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(inner)}"] if name == "xterm": return [terminal, "-T", title, "-e", "sh", "-lc", inner] 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): def __init__(self, path: Path) -> None: super().__init__() self.path = path 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] = {} self.nav_buttons: dict[str, tk.Button] = {} self.pages: dict[str, tk.Frame] = {} self.search_var = tk.StringVar() self.status_var = tk.StringVar(value="Bereit") self.title(APP_TITLE) 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) self._build_sidebar(root) self._build_content(root) def _build_sidebar(self, parent: tk.Frame) -> None: 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, 20)) mark = tk.Label( brand, text="PG", bg=COLORS["accent"], fg="#001a10", font=(UI_FONT[0], 14, "bold"), width=4, height=2, ) mark.pack(side="left") brand_text = tk.Frame(brand, bg=COLORS["sidebar"]) brand_text.pack(side="left", padx=12) tk.Label(brand_text, text="PulseGate", bg=COLORS["sidebar"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w") tk.Label(brand_text, text="Desktop SSH", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w") 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, 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")): button = self._nav_button(nav, label, lambda value=view: self._show_view(value)) button.pack(fill="x", pady=4) self.nav_buttons[view] = button stats = self._card(sidebar, bg=COLORS["surface"], padx=14, pady=12) stats.pack(fill="x", padx=22, pady=(28, 10)) head = tk.Frame(stats, bg=COLORS["surface"]) head.pack(fill="x") self._label(head, "PulseGate", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(side="left") self._label(head, "v" + APP_VERSION, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(side="right", anchor="e", pady=(3, 0)) terminal_text = "Terminal bereit" if self.terminal else "Kein Terminal gefunden" terminal_fg = STATUS_OK if self.terminal else COLORS["danger"] self._label(stats, f"● {terminal_text}", bg=COLORS["surface"], fg=terminal_fg, font=FONT_SMALL).pack(anchor="w", pady=(8, 0)) self.server_count_label = self._label(stats, "0 Server", bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 10, "bold")) self.server_count_label.pack(anchor="w", pady=(7, 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"]) content.pack(side="left", fill="both", expand=True, padx=24, pady=22) topbar = tk.Frame(content, bg=COLORS["bg"]) topbar.pack(fill="x", pady=(0, 18)) title_box = tk.Frame(topbar, bg=COLORS["bg"]) title_box.pack(side="left", fill="x", expand=True) tk.Label(title_box, textvariable=self.status_var, bg=COLORS["bg"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w") self.title_label = tk.Label(title_box, text="Server", bg=COLORS["bg"], fg=COLORS["text"], font=FONT_TITLE) self.title_label.pack(anchor="w") self._button(topbar, "Neu laden", self._reload, variant="ghost").pack(side="right", padx=(8, 0)) self._button(topbar, "Server hinzufügen", self._add_server).pack(side="right") self.page_host = tk.Frame(content, bg=COLORS["bg"]) self.page_host.pack(fill="both", expand=True) self.pages["servers"] = tk.Frame(self.page_host, bg=COLORS["bg"]) self.pages["commands"] = tk.Frame(self.page_host, bg=COLORS["bg"]) self.pages["settings"] = tk.Frame(self.page_host, bg=COLORS["bg"]) self._build_servers_page(self.pages["servers"]) self._build_commands_page(self.pages["commands"]) self._build_settings_page(self.pages["settings"]) def _build_servers_page(self, page: tk.Frame) -> None: left = tk.Frame(page, bg=COLORS["bg"], width=400) left.pack(side="left", fill="both", padx=(0, 18)) left.pack_propagate(False) self.server_canvas = tk.Canvas(left, bg=COLORS["bg"], highlightthickness=0, bd=0) scrollbar = tk.Scrollbar( left, orient="vertical", command=self.server_canvas.yview, bg=COLORS["surface_3"], activebackground=COLORS["accent"], troughcolor=COLORS["bg"], relief="flat", bd=0, width=6, ) 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=382) self.server_canvas.configure(yscrollcommand=scrollbar.set) self.server_canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") editor_outer = self._card(page, bg=COLORS["surface"], padx=18, pady=18) editor_outer.pack(side="left", fill="both", expand=True) header = tk.Frame(editor_outer, bg=COLORS["surface"]) header.pack(fill="x", pady=(0, 16)) self.form_title = self._label(header, "Server bearbeiten", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")) self.form_title.pack(side="left") self.auth_chip = self._chip(header, "key", COLORS["accent_2"]) self.auth_chip.pack(side="right") form = tk.Frame(editor_outer, bg=COLORS["surface"]) form.pack(fill="x") fields = [ ("name", "Name"), ("host", "Host"), ("user", "User"), ("port", "Port"), ("group", "Group"), ("auth", "Auth"), ("key", "Key Path"), ("password_id", "Password ID"), ] for index, (key, label) in enumerate(fields): row = index // 2 col = index % 2 cell = tk.Frame(form, bg=COLORS["surface"]) cell.grid(row=row, column=col, sticky="ew", padx=(0 if col == 0 else 10, 10 if col == 0 else 0), pady=8) self._label(cell, label, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w") var = tk.StringVar() self.vars[key] = var if key == "auth": widget = self._option_menu(cell, var, ("key", "password")) else: widget = self._entry(cell, var) widget.pack(fill="x", pady=(6, 0), ipady=3) form.columnconfigure(0, weight=1) form.columnconfigure(1, weight=1) self.vars["kitty_fix"] = tk.BooleanVar(value=True) tk.Checkbutton( editor_outer, text="Kitty Fix für diesen Server nutzen", variable=self.vars["kitty_fix"], bg=COLORS["surface"], fg=COLORS["text"], activebackground=COLORS["surface"], activeforeground=COLORS["text"], selectcolor=COLORS["field"], font=FONT, ).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") 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=5, bg=COLORS["field"], fg=COLORS["accent"], insertbackground=COLORS["text"], relief="flat", wrap="word", font=UI_FONT_MONO, padx=12, pady=10, ) 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, "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._option_menu(panel, self.settings_vars["theme"], tuple(THEMES.keys())).pack(fill="x", pady=(6, 18), ipady=3) 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", variable=self.settings_vars["enable_kitty_fix"], bg=COLORS["surface"], fg=COLORS["text"], activebackground=COLORS["surface"], activeforeground=COLORS["text"], selectcolor=COLORS["field"], font=FONT, ).pack(anchor="w", pady=(0, 18)) 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._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=(UI_FONT[0], 9, "bold"), padx=10, pady=5, cursor="hand2", highlightthickness=1, highlightbackground=COLORS["line"] if not selected else COLORS["accent_hi"], ) chip.pack(side="left", padx=(0, 6)) chip.bind("", lambda _event, value=group: self._set_group_filter(value)) def _enter(_event, label=chip, is_selected=selected) -> None: if not is_selected: label.configure(bg=COLORS["surface_3"], highlightbackground=COLORS["accent_hi"]) def _leave(_event, label=chip, is_selected=selected) -> None: if not is_selected: label.configure(bg=COLORS["surface_2"], highlightbackground=COLORS["line"]) chip.bind("", _enter) chip.bind("", _leave) 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() 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"] border = COLORS["accent"] if selected else COLORS["line"] card = tk.Frame(parent, bg=border, bd=0) inner = tk.Frame(card, bg=bg, padx=14, pady=12) inner.pack(fill="both", expand=True, padx=1, pady=1) head = tk.Frame(inner, bg=bg) head.pack(fill="x", pady=(0, 6)) name = server.get("name") or "Unbenannt" self._label(head, name, bg=bg, fg=COLORS["text"], font=FONT_CARD).pack(side="left") status = self.reachability.get(index) if status is None: status_text, status_fg = "…", COLORS["muted"] elif status: status_text, status_fg = "● erreichbar", STATUS_OK else: status_text, status_fg = "● offline", COLORS["danger"] status_label = self._label(head, status_text, bg=bg, fg=status_fg, font=FONT_SMALL) status_label.pack(side="right", anchor="e", pady=(2, 0)) self.reach_labels[index] = status_label target = f"{server.get('user')}@{server.get('host')}:{server.get('port') or 22}" self._label(inner, target, bg=bg, fg=COLORS["muted"], font=UI_FONT_MONO).pack(anchor="w", pady=(0, 10)) chips = tk.Frame(inner, bg=bg) chips.pack(fill="x") self._chip(chips, server.get("group") or "Keine Gruppe", COLORS["accent_2"], bg=bg).pack(side="left") self._chip(chips, server.get("auth") or "key", COLORS["accent"], bg=bg).pack(side="left", padx=(6, 0)) if server.get("kitty_fix"): self._chip(chips, "kitty", COLORS["muted"], bg=bg).pack(side="left", padx=(6, 0)) connect_hint = tk.Label(inner, text="Verbinden ›", bg=bg, fg=COLORS["accent_hi"], font=FONT_SMALL) connect_hint.place(relx=1.0, rely=0.0, anchor="ne", x=-14, y=6) def _card_enter(_event) -> None: card.configure(bg=COLORS["accent_hi"]) connect_hint.lift() def _card_leave(_event) -> None: card.configure(bg=border) connect_hint.lower() 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)) widget.bind("", _card_enter) widget.bind("", _card_leave) 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)) child.bind("", _card_enter) child.bind("", _card_leave) 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() commands = self.config_data["quick_commands"] if not commands: self._empty_state(self.commands_host, "Keine Quick Commands konfiguriert").pack(fill="x") return for command in commands: card = self._card(self.commands_host, bg=COLORS["surface"], padx=16, pady=14) card.pack(fill="x", pady=(0, 10)) head = tk.Frame(card, bg=COLORS["surface"]) head.pack(fill="x") self._label(head, "»", bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 14, "bold")).pack(side="left", padx=(0, 8)) self._label(head, command.get("name") or "Command", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(side="left") self._label(card, command.get("command") or "", bg=COLORS["surface"], fg=COLORS["accent"], font=UI_FONT_MONO).pack(anchor="w", pady=(9, 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"] self.settings_vars["theme"].set(settings.get("theme", "neon-green")) self.settings_vars["term"].set(terminal.get("term", "xterm-256color")) self.settings_vars["enable_kitty_fix"].set(bool(terminal.get("enable_kitty_fix", True))) def _show_view(self, view: str) -> None: self.current_view = view for page in self.pages.values(): page.pack_forget() self.pages[view].pack(fill="both", expand=True) titles = {"servers": "Server", "commands": "Quick Commands", "settings": "Settings"} self.title_label.configure(text=titles[view]) for name, button in self.nav_buttons.items(): active = name == view button.configure( bg=COLORS["surface_2"] if active else COLORS["sidebar"], fg=COLORS["accent"] if active else COLORS["text"], highlightbackground=COLORS["line"] if active else COLORS["sidebar"], ) def _select_server(self, index: int) -> None: self.selected_index = index self._refresh_server_cards() self._load_selected_server() def _load_selected_server(self) -> None: if not self.config_data["servers"]: self.form_title.configure(text="Kein Server") for variable in self.vars.values(): variable.set(False if isinstance(variable, tk.BooleanVar) else "") return self.selected_index = min(self.selected_index, len(self.config_data["servers"]) - 1) server = self.config_data["servers"][self.selected_index] self.form_title.configure(text=f"{server.get('name') or 'Server'} bearbeiten") self.auth_chip.configure(text=server.get("auth") or "key") for key, variable in self.vars.items(): if key == "kitty_fix": variable.set(bool(server.get(key, True))) else: variable.set(str(server.get(key, ""))) def _collect_server(self) -> dict | None: name = self.vars["name"].get().strip() host = self.vars["host"].get().strip() user = self.vars["user"].get().strip() if not name or not host or not user: messagebox.showerror(APP_TITLE, "Name, Host und User sind Pflichtfelder.") return None try: port = int(self.vars["port"].get() or "22") except ValueError: messagebox.showerror(APP_TITLE, "Port muss eine Zahl sein.") return None return { "name": name, "host": host, "user": user, "port": port, "group": self.vars["group"].get().strip(), "auth": self.vars["auth"].get().strip() or "key", "key": self.vars["key"].get().strip(), "password_id": self.vars["password_id"].get().strip(), "kitty_fix": bool(self.vars["kitty_fix"].get()), } def _save_current_server(self) -> None: server = self._collect_server() if server is None: return self.config_data["servers"][self.selected_index] = server save_config(self.path, self.config_data) self.status_var.set("Server gespeichert") self._refresh_all() def _add_server(self) -> None: self.config_data["servers"].append( { "name": "Neuer Server", "host": "", "user": "root", "port": 22, "group": "Homelab", "auth": "key", "key": "", "password_id": "", "kitty_fix": True, } ) self.selected_index = len(self.config_data["servers"]) - 1 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 server = self.config_data["servers"][self.selected_index] if not messagebox.askyesno(APP_TITLE, f"{server.get('name')} wirklich löschen?"): return del self.config_data["servers"][self.selected_index] self.selected_index = max(0, self.selected_index - 1) save_config(self.path, self.config_data) self.status_var.set("Server gelöscht") self._refresh_all() def _save_settings(self) -> None: self.config_data["settings"] = { "theme": self.settings_vars["theme"].get().strip() or "neon-green", "terminal": { "term": self.settings_vars["term"].get().strip() or "xterm-256color", "enable_kitty_fix": bool(self.settings_vars["enable_kitty_fix"].get()), }, } save_config(self.path, self.config_data) 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) self.selected_index = min(self.selected_index, max(0, len(self.config_data["servers"]) - 1)) self.status_var.set("Neu geladen") self._refresh_all() def _current_server(self) -> dict | None: if not self.config_data["servers"]: messagebox.showerror(APP_TITLE, "Kein Server ausgewählt.") return None return self.config_data["servers"][self.selected_index] def _show_command(self) -> None: server = self._current_server() if not server: return 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: return if not self.terminal: messagebox.showerror(APP_TITLE, "Kein unterstützter Terminal-Emulator gefunden.") return env = os.environ.copy() if not IS_WINDOWS: settings = self.config_data["settings"] terminal_settings = settings.get("terminal", {}) if terminal_settings.get("enable_kitty_fix", True) and server.get("kitty_fix", True): env["TERM"] = terminal_settings.get("term") or "xterm-256color" try: subprocess.Popen(terminal_command(self.terminal, server), env=env) except OSError as error: messagebox.showerror(APP_TITLE, str(error)) return self.status_var.set(f"SSH gestartet: {server.get('name')}") self._show_command() 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) def _entry(self, parent: tk.Widget, variable: tk.StringVar) -> tk.Entry: return tk.Entry( parent, textvariable=variable, bg=COLORS["field"], fg=COLORS["text"], insertbackground=COLORS["text"], relief="flat", highlightthickness=1, highlightbackground=COLORS["line"], highlightcolor=COLORS["accent"], 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: if variant == "primary": bg = COLORS["accent"] fg = "#001a10" hover_bg = COLORS["accent_hi"] hover_fg = "#001a10" elif variant == "ghost": bg = COLORS["surface_2"] fg = COLORS["text"] hover_bg = COLORS["surface_3"] hover_fg = COLORS["text"] else: bg = "#3a1717" fg = COLORS["danger"] hover_bg = "#522020" hover_fg = "#ffd0d0" button = tk.Button( parent, text=text, command=command, bg=bg, fg=fg, activebackground=hover_bg, activeforeground=hover_fg, relief="flat", bd=0, padx=14, pady=9, font=(UI_FONT[0], 10, "bold"), cursor="hand2", highlightthickness=0, ) button.bind("", lambda _event: button.configure(bg=hover_bg, fg=hover_fg)) button.bind("", lambda _event: button.configure(bg=bg, fg=fg)) return button def _nav_button(self, parent: tk.Widget, text: str, command) -> tk.Button: button = tk.Button( parent, text=text, command=command, bg=COLORS["sidebar"], fg=COLORS["text"], activebackground=COLORS["surface_2"], activeforeground=COLORS["accent"], relief="flat", bd=0, padx=12, pady=10, anchor="w", font=(UI_FONT[0], 10, "bold"), cursor="hand2", highlightthickness=1, highlightbackground=COLORS["sidebar"], ) def _enter(_event) -> None: if not self._view_active(text): button.configure(bg=COLORS["surface_2"]) def _leave(_event) -> None: if self._view_active(text): button.configure(bg=COLORS["surface_2"], highlightbackground=COLORS["line"]) else: button.configure(bg=COLORS["sidebar"], highlightbackground=COLORS["sidebar"]) button.bind("", _enter) button.bind("", _leave) return button def _view_active(self, name: str) -> bool: mapping = {"Server": "servers", "Commands": "commands", "Settings": "settings"} return mapping.get(name) == self.current_view def _card(self, parent: tk.Widget, *, bg: str, padx: int, pady: int) -> tk.Frame: return tk.Frame(parent, bg=bg, padx=padx, pady=pady, highlightthickness=1, highlightbackground=COLORS["line"]) def _chip(self, parent: tk.Widget, text: str, fg: str, *, bg: str | None = None) -> tk.Label: return tk.Label( parent, text=text, bg=bg or parent.cget("bg"), fg=fg, font=(UI_FONT[0], 9, "bold"), padx=7, pady=3, highlightthickness=1, highlightbackground=COLORS["surface_3"], ) def _empty_state(self, parent: tk.Widget, text: str, symbol: str = "◦") -> tk.Frame: card = self._card(parent, bg=COLORS["surface"], padx=16, pady=18) self._label(card, symbol, bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 18, "bold")).pack(side="left", padx=(0, 12)) self._label(card, text, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT).pack(side="left") return card if __name__ == "__main__": app = PulseGateDesktop(config_path()) app.mainloop()