Project Initialisation
added Project files
This commit is contained in:
124
README.md
Normal file
124
README.md
Normal 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
254
cmd/pulsegate-gui/main.go
Normal 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"
|
||||||
|
}
|
||||||
BIN
desktop/__pycache__/pulsegate_desktop.cpython-314.pyc
Normal file
BIN
desktop/__pycache__/pulsegate_desktop.cpython-314.pyc
Normal file
Binary file not shown.
725
desktop/pulsegate_desktop.py
Normal file
725
desktop/pulsegate_desktop.py
Normal 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
5
go.mod
Normal 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
4
go.sum
Normal 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
110
internal/config/config.go
Normal 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
34
internal/models/models.go
Normal 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
5
pulsegate-desktop
Executable 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
317
web/app.js
Normal 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("&", "&")
|
||||||
|
.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));
|
||||||
105
web/index.html
Normal file
105
web/index.html
Normal 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
346
web/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user