Project Initialisation

added Project files
This commit is contained in:
2026-05-03 21:26:10 +02:00
commit dcafc1e7c1
12 changed files with 2029 additions and 0 deletions

124
README.md Normal file
View File

@@ -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.

254
cmd/pulsegate-gui/main.go Normal file
View File

@@ -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"
}

Binary file not shown.

View File

@@ -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("<Configure>", lambda _event: self.server_canvas.configure(scrollregion=self.server_canvas.bbox("all")))
self.server_canvas.create_window((0, 0), window=self.server_cards, anchor="nw", width=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("<MouseWheel>", 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("<Button-1>", lambda _event, value=index: self._select_server(value))
for child in inner.winfo_children():
child.bind("<Button-1>", 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()

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module pulsegate-gui
go 1.26.2
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

110
internal/config/config.go Normal file
View File

@@ -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"
}
}
}

34
internal/models/models.go Normal file
View File

@@ -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"`
}

5
pulsegate-desktop Executable file
View File

@@ -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" "$@"

317
web/app.js Normal file
View File

@@ -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 = `<p class="server-meta">Keine Server gefunden.</p>`;
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 = `
<h3>${escapeHTML(server.name || "Unbenannt")}</h3>
<p class="server-meta">${escapeHTML(server.user || "")}@${escapeHTML(server.host || "")}:${server.port || 22}</p>
<div class="pill-row">
<span class="pill">${escapeHTML(server.group || "Keine Gruppe")}</span>
<span class="pill">${escapeHTML(server.auth || "key")}</span>
${server.kitty_fix ? `<span class="pill">kitty</span>` : ""}
</div>
`;
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 = `<p class="server-meta">Keine Quick Commands konfiguriert.</p>`;
return;
}
for (const command of commands) {
const item = document.createElement("article");
item.className = "command-item";
item.innerHTML = `
<h3>${escapeHTML(command.name)}</h3>
<code>${escapeHTML(command.command)}</code>
`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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));

105
web/index.html Normal file
View File

@@ -0,0 +1,105 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PulseGate GUI</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<aside class="sidebar">
<div class="brand">
<div class="mark">PG</div>
<div>
<h1>PulseGate</h1>
<p>SSH Manager</p>
</div>
</div>
<label class="search">
<span>Suche</span>
<input id="searchInput" type="search" placeholder="Server, Host, Gruppe">
</label>
<nav class="nav">
<button class="nav-item active" data-view="servers">Server</button>
<button class="nav-item" data-view="commands">Commands</button>
<button class="nav-item" data-view="settings">Settings</button>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow" id="configPath">Lokale Konfiguration</p>
<h2 id="viewTitle">Server</h2>
<p class="status-line" id="terminalStatus">Terminal wird geprüft...</p>
</div>
<div class="actions">
<button id="reloadButton" class="ghost">Neu laden</button>
<button id="addButton">Server hinzufügen</button>
</div>
</header>
<section id="serversView" class="view active">
<div id="serverGrid" class="server-grid"></div>
<form id="serverForm" class="editor">
<div class="editor-header">
<h3 id="formTitle">Server bearbeiten</h3>
<button type="button" id="deleteButton" class="danger">Löschen</button>
</div>
<div class="form-grid">
<label>Name<input name="name" required></label>
<label>Host<input name="host" required></label>
<label>User<input name="user" required></label>
<label>Port<input name="port" type="number" min="1" max="65535" value="22"></label>
<label>Group<input name="group"></label>
<label>Auth
<select name="auth">
<option value="key">key</option>
<option value="password">password</option>
</select>
</label>
<label>Key Path<input name="key" placeholder="~/.ssh/id_ed25519"></label>
<label>Password ID<input name="password_id"></label>
</div>
<label class="check">
<input name="kitty_fix" type="checkbox">
<span>Kitty Fix für diesen Server nutzen</span>
</label>
<div class="form-actions">
<button type="button" id="connectButton">Verbinden</button>
<button type="button" id="sshButton" class="ghost">SSH Befehl</button>
<button type="submit">Speichern</button>
</div>
<pre id="sshCommand" class="command-box" hidden></pre>
</form>
</section>
<section id="commandsView" class="view">
<div id="commandsList" class="command-list"></div>
</section>
<section id="settingsView" class="view">
<form id="settingsForm" class="settings-panel">
<label>Theme<input name="theme"></label>
<label>TERM Override<input name="term"></label>
<label class="check">
<input name="enable_kitty_fix" type="checkbox">
<span>Kitty Fix global aktivieren</span>
</label>
<button type="submit">Settings speichern</button>
</form>
</section>
</section>
</main>
<div id="toast" class="toast" hidden></div>
<script src="/app.js"></script>
</body>
</html>

346
web/styles.css Normal file
View File

@@ -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;
}
}