From dcafc1e7c102bf35acbf8394292e20facbd6a580 Mon Sep 17 00:00:00 2001 From: Pepe44DEV Date: Sun, 3 May 2026 21:26:10 +0200 Subject: [PATCH] Project Initialisation added Project files --- README.md | 124 +++ cmd/pulsegate-gui/main.go | 254 ++++++ .../pulsegate_desktop.cpython-314.pyc | Bin 0 -> 53914 bytes desktop/pulsegate_desktop.py | 725 ++++++++++++++++++ go.mod | 5 + go.sum | 4 + internal/config/config.go | 110 +++ internal/models/models.go | 34 + pulsegate-desktop | 5 + web/app.js | 317 ++++++++ web/index.html | 105 +++ web/styles.css | 346 +++++++++ 12 files changed, 2029 insertions(+) create mode 100644 README.md create mode 100644 cmd/pulsegate-gui/main.go create mode 100644 desktop/__pycache__/pulsegate_desktop.cpython-314.pyc create mode 100644 desktop/pulsegate_desktop.py create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/models/models.go create mode 100755 pulsegate-desktop create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css 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 0000000000000000000000000000000000000000..f0f54bd62b1c672e6c6dc4b47d90051f94c892af GIT binary patch literal 53914 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 UEbQ$*jXrIDz>9rYq`-jz0PVXzkpKVy literal 0 HcmV?d00001 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; + } +}