Files
PulseGate-GUI/desktop/pulsegate_desktop.py
T
Pepe44DEV 743b8d7a5e
Build Windows / build (push) Has been cancelled
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
2026-09-06 22:57:39 +00:00

1084 lines
42 KiB
Python

#!/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.1.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": "#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)
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("<F5>", lambda _event: self._reload())
self.bind("<Control-n>", lambda _event: self._add_server())
self.bind("<Control-s>", 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))
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=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["bg"], troughcolor=COLORS["bg"])
self.server_cards = tk.Frame(self.server_canvas, bg=COLORS["bg"])
self.server_cards.bind("<Configure>", 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=FONT_SMALL,
padx=9,
pady=4,
cursor="hand2",
highlightthickness=1,
highlightbackground=COLORS["line"],
)
chip.pack(side="left", padx=(0, 6))
chip.bind("<Button-1>", 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() 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)
target = f"{server.get('user')}@{server.get('host')}:{server.get('port') or 22}"
name = server.get("name") or "Unbenannt"
group = server.get("group") or "Keine Gruppe"
self._label(inner, name, bg=bg, fg=COLORS["text"], font=FONT_CARD).pack(anchor="w")
self._label(inner, target, bg=bg, fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(5, 8))
chips = tk.Frame(inner, bg=bg)
chips.pack(fill="x")
self._chip(chips, group, 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))
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("<Button-1>", lambda _event, value=index: self._select_server(value))
widget.bind("<Double-Button-1>", lambda _event, value=index: self._connect_at(value))
widget.bind("<Button-3>", lambda event, value=index: self._server_menu(event, value))
for child in inner.winfo_children():
child.bind("<Button-1>", lambda _event, value=index: self._select_server(value))
child.bind("<Double-Button-1>", lambda _event, value=index: self._connect_at(value))
child.bind("<Button-3>", 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()
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))
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"]
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:
bg = COLORS["accent"]
fg = "#001a10"
active_bg = COLORS["accent"]
if variant == "ghost":
bg = COLORS["surface_2"]
fg = COLORS["text"]
active_bg = COLORS["surface_3"]
elif variant == "danger":
bg = "#351414"
fg = COLORS["danger"]
active_bg = "#4a1d1d"
return tk.Button(
parent,
text=text,
command=command,
bg=bg,
fg=fg,
activebackground=active_bg,
activeforeground=fg,
relief="flat",
bd=0,
padx=14,
pady=9,
font=(UI_FONT[0], 10, "bold"),
cursor="hand2",
)
def _nav_button(self, parent: tk.Widget, text: str, command) -> tk.Button:
return 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 _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=FONT_SMALL,
padx=7,
pady=3,
highlightthickness=1,
highlightbackground=COLORS["line"],
)
def _empty_state(self, parent: tk.Widget, text: str) -> tk.Frame:
card = self._card(parent, bg=COLORS["surface"], padx=16, pady=18)
self._label(card, text, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT).pack(anchor="w")
return card
if __name__ == "__main__":
app = PulseGateDesktop(config_path())
app.mainloop()