commit dcafc1e7c102bf35acbf8394292e20facbd6a580 Author: Pepe44DEV Date: Sun May 3 21:26:10 2026 +0200 Project Initialisation added Project files diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c9b447 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# PulseGate Desktop + +PulseGate Desktop ist eine grafische Desktop-Variante des PulseGate SSH Managers. Die App verwaltet SSH-Server, nutzt dieselbe YAML-Konfiguration wie die bestehende TUI und startet Verbindungen in einem lokalen Terminal-Emulator. + +## Features + +- 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 +- Gemeinsame Konfiguration mit PulseGate TUI + +## Voraussetzungen + +- Linux Desktop-Umgebung +- Python 3 mit `tkinter` +- Python-Paket `PyYAML` +- `ssh` +- ein unterstützter Terminal-Emulator: + - `kitty` + - `alacritty` + - `konsole` + - `gnome-terminal` + - `xfce4-terminal` + - `xterm` + +Für die optionale Web-Variante wird zusätzlich Go benötigt. + +## Start + +Desktop-App starten: + +```bash +cd /home/pascal/Projekte/PulseGate-GUI +./pulsegate-desktop +``` + +Optional kann ein eigener Config-Pfad übergeben werden: + +```bash +./pulsegate-desktop /pfad/zur/config.yaml +``` + +## Konfiguration + +Standardmäßig liest und schreibt PulseGate Desktop diese Datei: + +```text +~/.config/pulsegate/config.yaml +``` + +Beispiel: + +```yaml +settings: + theme: neon-green + terminal: + term: xterm-256color + enable_kitty_fix: true + +servers: + - name: Unraid + host: 10.0.0.15 + user: root + port: 22 + group: Homelab + auth: password + key: "" + password_id: unraid-root + kitty_fix: true + +quick_commands: + - name: Disk Usage + command: df -h +``` + +## Web-Variante + +Im Projekt liegt zusätzlich noch eine einfache lokale Web-Variante: + +```bash +go run ./cmd/pulsegate-gui +``` + +Danach öffnen: + +```text +http://127.0.0.1:8090 +``` + +## Projektstruktur + +```text +. +├── cmd/pulsegate-gui/ # optionaler Go-Webserver +├── desktop/ # Python/Tk Desktop-App +├── internal/config/ # Go-Konfigurationslogik +├── internal/models/ # Go-Datenmodelle +├── web/ # optionale Web-Oberfläche +├── go.mod +├── pulsegate-desktop # Launcher für die Desktop-App +└── README.md +``` + +## Entwicklung + +Python-Syntax prüfen: + +```bash +python3 -m py_compile desktop/pulsegate_desktop.py +``` + +Go-Code prüfen: + +```bash +go test ./... +``` + +## Hinweis + +Die Desktop-App startet SSH in einem separaten lokalen Terminal-Fenster. Passwortabfragen und interaktive SSH-Sessions laufen dort direkt im Terminal. diff --git a/cmd/pulsegate-gui/main.go b/cmd/pulsegate-gui/main.go new file mode 100644 index 0000000..4aa4133 --- /dev/null +++ b/cmd/pulsegate-gui/main.go @@ -0,0 +1,254 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "pulsegate-gui/internal/config" + "pulsegate-gui/internal/models" +) + +type apiServer struct { + configPath string +} + +func main() { + configPath := config.DefaultPath() + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + if err := config.EnsureExists(configPath); err != nil { + log.Fatal(err) + } + + app := apiServer{configPath: configPath} + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/config", app.getConfig) + mux.HandleFunc("GET /api/capabilities", app.capabilities) + mux.HandleFunc("PUT /api/config", app.putConfig) + mux.HandleFunc("POST /api/ssh-command/", app.sshCommand) + mux.HandleFunc("POST /api/connect/", app.connectSSH) + + mux.Handle("/", http.FileServer(http.Dir(webDir()))) + + addr := "127.0.0.1:8090" + log.Printf("PulseGate GUI läuft auf http://%s", addr) + log.Printf("Config: %s", configPath) + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func (a apiServer) getConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.Load(a.configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, cfg) +} + +func (a apiServer) putConfig(w http.ResponseWriter, r *http.Request) { + var cfg models.AppConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + if err := config.Save(a.configPath, cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, cfg) +} + +func (a apiServer) capabilities(w http.ResponseWriter, r *http.Request) { + terminal, ok := detectTerminal() + writeJSON(w, http.StatusOK, map[string]any{ + "terminal_available": ok, + "terminal": terminal, + }) +} + +func (a apiServer) sshCommand(w http.ResponseWriter, r *http.Request) { + cfg, err := config.Load(a.configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + server, err := serverFromRequest(r, cfg, "/api/ssh-command/") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "command": strings.Join(sshArgs(server), " "), + }) +} + +func (a apiServer) connectSSH(w http.ResponseWriter, r *http.Request) { + cfg, err := config.Load(a.configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + server, err := serverFromRequest(r, cfg, "/api/connect/") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + command, args, err := terminalCommand(server) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + cmd := exec.Command(command, args...) + cmd.Env = buildSSHEnv(server, cfg.Settings) + + if err := cmd.Start(); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "started", + "command": strings.Join(sshArgs(server), " "), + }) +} + +func serverFromRequest(r *http.Request, cfg models.AppConfig, prefix string) (models.Server, error) { + rawIndex := strings.TrimPrefix(r.URL.Path, prefix) + index, err := strconv.Atoi(rawIndex) + if err != nil || index < 0 || index >= len(cfg.Servers) { + return models.Server{}, fmt.Errorf("ungültiger server index") + } + + return cfg.Servers[index], nil +} + +func sshArgs(server models.Server) []string { + args := []string{"ssh", "-p", strconv.Itoa(server.Port)} + if server.Key != "" && server.Auth == "key" { + args = append(args, "-i", expandHome(server.Key)) + } + args = append(args, server.User+"@"+server.Host) + + return args +} + +func terminalCommand(server models.Server) (string, []string, error) { + ssh := strings.Join(quoteArgs(sshArgs(server)), " ") + title := "PulseGate - " + server.Name + holdCommand := ssh + "; printf '\\nSSH beendet. Enter zum Schließen...'; read _" + + candidates := []struct { + name string + args []string + }{ + {"kitty", []string{"--title", title, "sh", "-lc", holdCommand}}, + {"konsole", []string{"--new-tab", "-p", "tabtitle=" + title, "-e", "sh", "-lc", holdCommand}}, + {"gnome-terminal", []string{"--title", title, "--", "sh", "-lc", holdCommand}}, + {"xfce4-terminal", []string{"--title", title, "--command", "sh -lc " + shellQuote(holdCommand)}}, + {"alacritty", []string{"--title", title, "-e", "sh", "-lc", holdCommand}}, + {"xterm", []string{"-T", title, "-e", "sh", "-lc", holdCommand}}, + } + + for _, candidate := range candidates { + if path, err := exec.LookPath(candidate.name); err == nil { + return path, candidate.args, nil + } + } + + return "", nil, fmt.Errorf("kein unterstützter Terminal-Emulator gefunden") +} + +func detectTerminal() (string, bool) { + for _, name := range []string{"kitty", "konsole", "gnome-terminal", "xfce4-terminal", "alacritty", "xterm"} { + if path, err := exec.LookPath(name); err == nil { + return path, true + } + } + + return "", false +} + +func buildSSHEnv(server models.Server, settings models.Settings) []string { + env := os.Environ() + + if settings.Terminal.EnableKittyFix && server.KittyFix { + termValue := settings.Terminal.Term + if termValue == "" { + termValue = "xterm-256color" + } + env = append(env, "TERM="+termValue) + } + + return env +} + +func expandHome(path string) string { + if strings.HasPrefix(path, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + + return path +} + +func quoteArgs(args []string) []string { + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = shellQuote(arg) + } + return quoted +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func webDir() string { + candidates := []string{ + "web", + filepath.Join("..", "..", "web"), + } + + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + } + + return "web" +} diff --git a/desktop/__pycache__/pulsegate_desktop.cpython-314.pyc b/desktop/__pycache__/pulsegate_desktop.cpython-314.pyc new file mode 100644 index 0000000..f0f54bd Binary files /dev/null and b/desktop/__pycache__/pulsegate_desktop.cpython-314.pyc differ diff --git a/desktop/pulsegate_desktop.py b/desktop/pulsegate_desktop.py new file mode 100644 index 0000000..5c1d6d9 --- /dev/null +++ b/desktop/pulsegate_desktop.py @@ -0,0 +1,725 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +import tkinter as tk +from pathlib import Path +from tkinter import messagebox + +import yaml + + +APP_TITLE = "PulseGate Desktop" + +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", +} + +FONT = ("Inter", 11) +FONT_SMALL = ("Inter", 9) +FONT_TITLE = ("Inter", 24, "bold") +FONT_CARD = ("Inter", 13, "bold") + + +def config_path() -> Path: + if len(sys.argv) > 1: + return Path(sys.argv[1]).expanduser() + + 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": "Uptime", "command": "uptime"}, + ], + } + + +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) + 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) + path.chmod(0o600) + + +def expand_home(value: str) -> str: + return str(Path(value).expanduser()) if value.startswith("~/") else value + + +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 detect_terminal() -> str | None: + for name in ("kitty", "alacritty", "konsole", "gnome-terminal", "xfce4-terminal", "xterm"): + path = shutil.which(name) + if path: + return path + return None + + +def terminal_command(terminal: str, server: dict) -> list[str]: + title = f"PulseGate - {server.get('name') or server.get('host')}" + command = " ".join(shlex.quote(arg) for arg in ssh_args(server)) + hold_command = f"{command}; printf '\\nSSH beendet. Enter zum Schliessen...'; read _" + name = Path(terminal).name + + if name == "kitty": + return [terminal, "--title", title, "sh", "-lc", hold_command] + if name == "alacritty": + return [terminal, "--title", title, "-e", "sh", "-lc", hold_command] + if name == "konsole": + return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", hold_command] + if name == "gnome-terminal": + return [terminal, "--title", title, "--", "sh", "-lc", hold_command] + if name == "xfce4-terminal": + return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(hold_command)}"] + if name == "xterm": + return [terminal, "-T", title, "-e", "sh", "-lc", hold_command] + + return [terminal, "-e", "sh", "-lc", hold_command] + + +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.terminal = detect_terminal() + + 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("1180x760") + self.minsize(980, 640) + self.configure(bg=COLORS["bg"]) + + self._build_layout() + self._refresh_all() + + 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=282) + 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)) + + mark = tk.Label( + brand, + text="PG", + bg=COLORS["accent"], + fg="#001a10", + font=("Inter", 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=("Inter", 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, 22)) + self.search_var.trace_add("write", lambda *_: self._refresh_server_cards()) + + 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, 0)) + 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)) + + 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 hinzufuegen", 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=390) + 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("", 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.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) + + 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=("Inter", 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 = 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") + 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 fuer 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, 4)) + + actions = tk.Frame(editor_outer, bg=COLORS["surface"]) + actions.pack(fill="x", pady=(16, 14)) + 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, "Loeschen", self._delete_current_server, variant="danger").pack(side="right") + self._button(actions, "Speichern", self._save_current_server).pack(side="right", padx=(0, 8)) + + self.command_text = tk.Text( + editor_outer, + height=6, + bg=COLORS["field"], + fg=COLORS["accent"], + insertbackground=COLORS["text"], + relief="flat", + wrap="word", + font=("JetBrains Mono", 10), + padx=12, + pady=10, + ) + self.command_text.pack(fill="both", expand=True) + + 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) + + 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=("Inter", 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.settings_vars["theme"] = tk.StringVar() + self.settings_vars["term"] = tk.StringVar() + self.settings_vars["enable_kitty_fix"] = tk.BooleanVar() + + for label, key in (("Theme", "theme"), ("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) + + 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)) + + self._button(panel, "Settings speichern", self._save_settings).pack(anchor="e") + + def _refresh_all(self) -> None: + self.server_count_label.configure(text=f"{len(self.config_data['servers'])} Server") + self._refresh_server_cards() + self._load_selected_server() + self._refresh_commands() + self._refresh_settings() + self._show_view(self.current_view) + + def _refresh_server_cards(self) -> None: + for child in self.server_cards.winfo_children(): + child.destroy() + + query = self.search_var.get().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 + self._server_card(self.server_cards, server, index).pack(fill="x", pady=(0, 10)) + rendered += 1 + + if rendered == 0: + self._empty_state(self.server_cards, "Keine Server gefunden").pack(fill="x") + + 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)) + + for widget in (card, inner): + widget.bind("", lambda _event, value=index: self._select_server(value)) + for child in inner.winfo_children(): + child.bind("", lambda _event, value=index: self._select_server(value)) + + return card + + 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=("JetBrains Mono", 10)).pack(anchor="w", pady=(7, 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 _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 loeschen?"): + 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 geloescht") + 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") + + 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 ausgewaehlt.") + 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", " ".join(shlex.quote(arg) for arg in ssh_args(server))) + + def _connect(self) -> None: + server = self._current_server() + if not server: + return + if not self.terminal: + messagebox.showerror(APP_TITLE, "Kein unterstuetzter Terminal-Emulator gefunden.") + return + + env = os.environ.copy() + 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 _on_mousewheel(self, event: tk.Event) -> None: + if self.current_view == "servers": + self.server_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + 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 _button(self, parent: tk.Widget, text: str, command, *, variant: str = "primary") -> tk.Button: + bg = COLORS["accent"] + fg = "#001a10" + active_bg = "#42ffb5" + 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=("Inter", 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=("Inter", 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() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c453589 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module pulsegate-gui + +go 1.26.2 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cdc919 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,110 @@ +package config + +import ( + "os" + "path/filepath" + + "pulsegate-gui/internal/models" + + "gopkg.in/yaml.v3" +) + +func DefaultPath() string { + configDir, err := os.UserConfigDir() + if err != nil { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "pulsegate", "config.yaml") + } + + return filepath.Join(configDir, "pulsegate", "config.yaml") +} + +func EnsureExists(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + + cfg := models.AppConfig{ + Settings: models.Settings{ + Theme: "neon-green", + Terminal: models.TerminalSettings{ + Term: "xterm-256color", + EnableKittyFix: true, + }, + }, + Servers: []models.Server{ + { + Name: "Example Server", + Host: "10.0.0.10", + User: "root", + Port: 22, + Group: "Homelab", + Auth: "key", + Key: "~/.ssh/id_ed25519", + KittyFix: true, + }, + }, + QuickCommands: []models.QuickCommand{ + {Name: "Disk Usage", Command: "df -h"}, + {Name: "RAM Usage", Command: "free -h"}, + {Name: "Uptime", Command: "uptime"}, + }, + } + + return Save(path, cfg) +} + +func Load(path string) (models.AppConfig, error) { + var cfg models.AppConfig + + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + + normalize(&cfg) + return cfg, nil +} + +func Save(path string, cfg models.AppConfig) error { + normalize(&cfg) + + data, err := yaml.Marshal(&cfg) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func normalize(cfg *models.AppConfig) { + if cfg.Settings.Theme == "" { + cfg.Settings.Theme = "neon-green" + } + + if cfg.Settings.Terminal.Term == "" { + cfg.Settings.Terminal.Term = "xterm-256color" + } + + for i := range cfg.Servers { + if cfg.Servers[i].Port == 0 { + cfg.Servers[i].Port = 22 + } + + if cfg.Servers[i].Auth == "" { + cfg.Servers[i].Auth = "key" + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..1593e6e --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,34 @@ +package models + +type TerminalSettings struct { + Term string `json:"term" yaml:"term"` + EnableKittyFix bool `json:"enable_kitty_fix" yaml:"enable_kitty_fix"` +} + +type Settings struct { + Theme string `json:"theme" yaml:"theme"` + Terminal TerminalSettings `json:"terminal" yaml:"terminal"` +} + +type Server struct { + Name string `json:"name" yaml:"name"` + Host string `json:"host" yaml:"host"` + User string `json:"user" yaml:"user"` + Port int `json:"port" yaml:"port"` + Group string `json:"group" yaml:"group"` + Auth string `json:"auth" yaml:"auth"` + Key string `json:"key" yaml:"key"` + PasswordID string `json:"password_id" yaml:"password_id"` + KittyFix bool `json:"kitty_fix" yaml:"kitty_fix"` +} + +type QuickCommand struct { + Name string `json:"name" yaml:"name"` + Command string `json:"command" yaml:"command"` +} + +type AppConfig struct { + Settings Settings `json:"settings" yaml:"settings"` + Servers []Server `json:"servers" yaml:"servers"` + QuickCommands []QuickCommand `json:"quick_commands" yaml:"quick_commands"` +} diff --git a/pulsegate-desktop b/pulsegate-desktop new file mode 100755 index 0000000..156ebb4 --- /dev/null +++ b/pulsegate-desktop @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$SCRIPT_DIR/desktop/pulsegate_desktop.py" "$@" diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7b99fdc --- /dev/null +++ b/web/app.js @@ -0,0 +1,317 @@ +const state = { + config: null, + selected: 0, + view: "servers", + query: "", + capabilities: null, +}; + +const els = { + navItems: document.querySelectorAll(".nav-item"), + views: { + servers: document.querySelector("#serversView"), + commands: document.querySelector("#commandsView"), + settings: document.querySelector("#settingsView"), + }, + viewTitle: document.querySelector("#viewTitle"), + terminalStatus: document.querySelector("#terminalStatus"), + searchInput: document.querySelector("#searchInput"), + serverGrid: document.querySelector("#serverGrid"), + serverForm: document.querySelector("#serverForm"), + formTitle: document.querySelector("#formTitle"), + deleteButton: document.querySelector("#deleteButton"), + connectButton: document.querySelector("#connectButton"), + addButton: document.querySelector("#addButton"), + reloadButton: document.querySelector("#reloadButton"), + sshButton: document.querySelector("#sshButton"), + sshCommand: document.querySelector("#sshCommand"), + commandsList: document.querySelector("#commandsList"), + settingsForm: document.querySelector("#settingsForm"), + toast: document.querySelector("#toast"), +}; + +async function loadConfig() { + const [configResponse, capabilitiesResponse] = await Promise.all([ + fetch("/api/config"), + fetch("/api/capabilities"), + ]); + + const response = configResponse; + if (!response.ok) throw new Error("Config konnte nicht geladen werden"); + state.config = await response.json(); + state.capabilities = capabilitiesResponse.ok ? await capabilitiesResponse.json() : null; + state.selected = Math.min(state.selected, Math.max(0, state.config.servers.length - 1)); + render(); +} + +async function saveConfig(message = "Gespeichert") { + const response = await fetch("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state.config), + }); + + if (!response.ok) throw new Error("Config konnte nicht gespeichert werden"); + state.config = await response.json(); + showToast(message); + render(); +} + +function render() { + renderNavigation(); + renderServers(); + renderServerForm(); + renderCommands(); + renderSettings(); +} + +function renderNavigation() { + els.navItems.forEach((item) => { + item.classList.toggle("active", item.dataset.view === state.view); + }); + + Object.entries(els.views).forEach(([name, node]) => { + node.classList.toggle("active", name === state.view); + }); + + const titles = { + servers: "Server", + commands: "Quick Commands", + settings: "Settings", + }; + els.viewTitle.textContent = titles[state.view]; + + if (state.capabilities?.terminal_available) { + els.terminalStatus.textContent = `Terminal: ${state.capabilities.terminal}`; + } else { + els.terminalStatus.textContent = "Kein unterstützter Terminal-Emulator gefunden"; + } +} + +function filteredServers() { + const query = state.query.trim().toLowerCase(); + const servers = state.config?.servers ?? []; + if (!query) return servers.map((server, index) => ({ server, index })); + + return servers + .map((server, index) => ({ server, index })) + .filter(({ server }) => { + return [server.name, server.host, server.user, server.group, server.auth] + .join(" ") + .toLowerCase() + .includes(query); + }); +} + +function renderServers() { + const items = filteredServers(); + els.serverGrid.innerHTML = ""; + + if (items.length === 0) { + els.serverGrid.innerHTML = `

Keine Server gefunden.

`; + return; + } + + for (const { server, index } of items) { + const card = document.createElement("article"); + card.className = "server-card"; + card.classList.toggle("active", index === state.selected); + card.innerHTML = ` +

${escapeHTML(server.name || "Unbenannt")}

+

${escapeHTML(server.user || "")}@${escapeHTML(server.host || "")}:${server.port || 22}

+
+ ${escapeHTML(server.group || "Keine Gruppe")} + ${escapeHTML(server.auth || "key")} + ${server.kitty_fix ? `kitty` : ""} +
+ `; + card.addEventListener("click", () => { + state.selected = index; + els.sshCommand.hidden = true; + render(); + }); + els.serverGrid.append(card); + } +} + +function renderServerForm() { + const server = state.config?.servers?.[state.selected]; + const disabled = !server; + els.serverForm.querySelectorAll("input, select, button").forEach((input) => { + input.disabled = disabled; + }); + els.connectButton.disabled = disabled || !state.capabilities?.terminal_available; + + if (!server) { + els.formTitle.textContent = "Kein Server ausgewählt"; + els.serverForm.reset(); + return; + } + + els.formTitle.textContent = server.name ? `${server.name} bearbeiten` : "Server bearbeiten"; + setFormValue("name", server.name); + setFormValue("host", server.host); + setFormValue("user", server.user); + setFormValue("port", server.port || 22); + setFormValue("group", server.group); + setFormValue("auth", server.auth || "key"); + setFormValue("key", server.key); + setFormValue("password_id", server.password_id); + els.serverForm.elements.kitty_fix.checked = Boolean(server.kitty_fix); +} + +function renderCommands() { + const commands = state.config?.quick_commands ?? []; + els.commandsList.innerHTML = ""; + + if (commands.length === 0) { + els.commandsList.innerHTML = `

Keine Quick Commands konfiguriert.

`; + return; + } + + for (const command of commands) { + const item = document.createElement("article"); + item.className = "command-item"; + item.innerHTML = ` +

${escapeHTML(command.name)}

+ ${escapeHTML(command.command)} + `; + els.commandsList.append(item); + } +} + +function renderSettings() { + const settings = state.config?.settings; + if (!settings) return; + + els.settingsForm.elements.theme.value = settings.theme || "neon-green"; + els.settingsForm.elements.term.value = settings.terminal?.term || "xterm-256color"; + els.settingsForm.elements.enable_kitty_fix.checked = Boolean(settings.terminal?.enable_kitty_fix); +} + +function addServer() { + state.config.servers.push({ + name: "Neuer Server", + host: "", + user: "root", + port: 22, + group: "Homelab", + auth: "key", + key: "", + password_id: "", + kitty_fix: true, + }); + state.selected = state.config.servers.length - 1; + state.view = "servers"; + render(); +} + +function collectServerForm() { + const form = els.serverForm.elements; + return { + name: form.name.value.trim(), + host: form.host.value.trim(), + user: form.user.value.trim(), + port: Number(form.port.value) || 22, + group: form.group.value.trim(), + auth: form.auth.value, + key: form.key.value.trim(), + password_id: form.password_id.value.trim(), + kitty_fix: form.kitty_fix.checked, + }; +} + +function setFormValue(name, value) { + els.serverForm.elements[name].value = value ?? ""; +} + +async function showSSHCommand() { + const response = await fetch(`/api/ssh-command/${state.selected}`, { method: "POST" }); + if (!response.ok) throw new Error("SSH Befehl konnte nicht erzeugt werden"); + + const data = await response.json(); + els.sshCommand.textContent = data.command; + els.sshCommand.hidden = false; +} + +async function connectSSH() { + const response = await fetch(`/api/connect/${state.selected}`, { method: "POST" }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "SSH Verbindung konnte nicht gestartet werden"); + } + + els.sshCommand.textContent = data.command; + els.sshCommand.hidden = false; + showToast("SSH Terminal gestartet"); +} + +function showToast(message) { + els.toast.textContent = message; + els.toast.hidden = false; + window.setTimeout(() => { + els.toast.hidden = true; + }, 2200); +} + +function escapeHTML(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +els.navItems.forEach((item) => { + item.addEventListener("click", () => { + state.view = item.dataset.view; + render(); + }); +}); + +els.searchInput.addEventListener("input", (event) => { + state.query = event.target.value; + renderServers(); +}); + +els.addButton.addEventListener("click", addServer); +els.reloadButton.addEventListener("click", () => loadConfig().then(() => showToast("Neu geladen")).catch((error) => showToast(error.message))); +els.sshButton.addEventListener("click", () => showSSHCommand().catch((error) => showToast(error.message))); +els.connectButton.addEventListener("click", () => connectSSH().catch((error) => showToast(error.message))); + +els.deleteButton.addEventListener("click", async () => { + if (!state.config.servers[state.selected]) return; + state.config.servers.splice(state.selected, 1); + state.selected = Math.max(0, state.selected - 1); + await saveConfig("Server gelöscht"); +}); + +els.serverForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const server = collectServerForm(); + + if (!server.name || !server.host || !server.user) { + showToast("Name, Host und User sind Pflichtfelder"); + return; + } + + state.config.servers[state.selected] = server; + await saveConfig("Server gespeichert"); +}); + +els.settingsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = els.settingsForm.elements; + state.config.settings = { + theme: form.theme.value.trim() || "neon-green", + terminal: { + term: form.term.value.trim() || "xterm-256color", + enable_kitty_fix: form.enable_kitty_fix.checked, + }, + }; + await saveConfig("Settings gespeichert"); +}); + +loadConfig().catch((error) => showToast(error.message)); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9a53d1b --- /dev/null +++ b/web/index.html @@ -0,0 +1,105 @@ + + + + + + PulseGate GUI + + + +
+ + +
+
+
+

Lokale Konfiguration

+

Server

+

Terminal wird geprüft...

+
+
+ + +
+
+ +
+
+
+
+

Server bearbeiten

+ +
+ +
+ + + + + + + + +
+ + + +
+ + + +
+ + +
+
+ +
+
+
+ +
+
+ + + + +
+
+
+
+ + + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..ece0195 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,346 @@ +:root { + color-scheme: dark; + --bg: #07110d; + --panel: #0d1b15; + --panel-2: #13251e; + --text: #d7ffe9; + --muted: #8aa99b; + --line: #1f4b38; + --accent: #00ff99; + --accent-2: #33ccff; + --danger: #ff6666; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #001a10; + padding: 10px 14px; + font-weight: 700; + cursor: pointer; +} + +button.ghost { + background: transparent; + color: var(--text); + border-color: var(--line); +} + +button.danger { + background: transparent; + color: var(--danger); + border-color: rgba(255, 102, 102, 0.45); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +input, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + background: #09150f; + color: var(--text); + padding: 10px 11px; + outline: none; +} + +input:focus, +select:focus { + border-color: var(--accent); +} + +label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 13px; +} + +.shell { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + border-right: 1px solid var(--line); + background: #06100c; + padding: 24px; +} + +.brand { + display: flex; + align-items: center; + gap: 13px; + margin-bottom: 28px; +} + +.mark { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 6px; + background: var(--accent); + color: #001a10; + font-weight: 900; +} + +.brand h1, +.brand p, +.topbar h2, +.topbar p, +.editor h3 { + margin: 0; +} + +.brand h1 { + font-size: 20px; +} + +.brand p, +.eyebrow { + color: var(--muted); +} + +.status-line { + color: var(--accent-2); + font-size: 13px; +} + +.search { + margin-bottom: 22px; +} + +.nav { + display: grid; + gap: 8px; +} + +.nav-item { + width: 100%; + background: transparent; + color: var(--text); + border-color: transparent; + text-align: left; +} + +.nav-item.active { + border-color: var(--line); + background: var(--panel); + color: var(--accent); +} + +.workspace { + min-width: 0; + padding: 24px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.topbar h2 { + font-size: 28px; +} + +.actions, +.form-actions, +.editor-header { + display: flex; + align-items: center; + gap: 10px; +} + +.view { + display: none; +} + +.view.active { + display: grid; + gap: 18px; +} + +#serversView.active { + grid-template-columns: minmax(280px, 430px) minmax(360px, 1fr); +} + +.server-grid { + display: grid; + align-content: start; + gap: 10px; +} + +.server-card, +.editor, +.settings-panel, +.command-item { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.server-card { + padding: 14px; + cursor: pointer; +} + +.server-card.active { + border-color: var(--accent); + background: var(--panel-2); +} + +.server-card h3 { + margin: 0 0 8px; + font-size: 17px; +} + +.server-meta { + margin: 0; + color: var(--muted); + overflow-wrap: anywhere; +} + +.pill-row { + display: flex; + gap: 7px; + margin-top: 10px; + flex-wrap: wrap; +} + +.pill { + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 8px; + color: var(--accent-2); + font-size: 12px; +} + +.editor, +.settings-panel { + padding: 18px; +} + +.editor-header { + justify-content: space-between; + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.check { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; +} + +.check input { + width: auto; +} + +.form-actions { + justify-content: flex-end; + margin-top: 18px; +} + +.command-box { + margin: 16px 0 0; + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px; + background: #050b08; + color: var(--accent); + white-space: pre-wrap; +} + +.command-list { + display: grid; + gap: 10px; +} + +.command-item { + padding: 15px; +} + +.command-item h3 { + margin: 0 0 8px; +} + +.command-item code { + color: var(--accent); +} + +.settings-panel { + max-width: 520px; + display: grid; + gap: 14px; +} + +.toast { + position: fixed; + right: 20px; + bottom: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + color: var(--text); + padding: 12px 14px; +} + +@media (max-width: 900px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + #serversView.active { + grid-template-columns: 1fr; + } + + .topbar, + .actions { + align-items: stretch; + flex-direction: column; + } + + .form-grid { + grid-template-columns: 1fr; + } +}