1307 lines
27 KiB
Go
1307 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"pulsegate/internal/config"
|
|
"pulsegate/internal/models"
|
|
"pulsegate/internal/secret"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
type viewMode string
|
|
|
|
const (
|
|
ViewServers viewMode = "servers"
|
|
ViewSettings viewMode = "settings"
|
|
ViewHelp viewMode = "help"
|
|
ViewAddServer viewMode = "add_server"
|
|
ViewEditServer viewMode = "edit_server"
|
|
ViewDeleteConfirm viewMode = "delete_confirm"
|
|
ViewCommands viewMode = "commands"
|
|
ViewCommandOutput viewMode = "command_output"
|
|
)
|
|
|
|
type model struct {
|
|
cfg models.AppConfig
|
|
servers []models.Server
|
|
selected int
|
|
view viewMode
|
|
width int
|
|
height int
|
|
err error
|
|
|
|
addInputs []textinput.Model
|
|
addFocus int
|
|
editIndex int
|
|
commandSelected int
|
|
commandOutput string
|
|
commandTitle string
|
|
commandError string
|
|
serverStatus map[int]string
|
|
settingsSelected int
|
|
settingsEditingTerm bool
|
|
settingsTermInput textinput.Model
|
|
}
|
|
|
|
var (
|
|
green = lipgloss.Color("#00ff99")
|
|
cyan = lipgloss.Color("#33ccff")
|
|
gray = lipgloss.Color("#777777")
|
|
text = lipgloss.Color("#d7ffe9")
|
|
dimText = lipgloss.Color("#8aa99b")
|
|
panelBg = lipgloss.Color("#07110d")
|
|
border = lipgloss.Color("#00aa66")
|
|
warn = lipgloss.Color("#ffaa00")
|
|
|
|
baseStyle = lipgloss.NewStyle().
|
|
Foreground(text)
|
|
|
|
headerStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(green).
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(border).
|
|
Padding(1, 2)
|
|
|
|
panelStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(border).
|
|
Padding(1, 2)
|
|
|
|
selectedStyle = lipgloss.NewStyle().
|
|
Foreground(green).
|
|
Bold(true)
|
|
|
|
normalStyle = lipgloss.NewStyle().
|
|
Foreground(text)
|
|
|
|
mutedStyle = lipgloss.NewStyle().
|
|
Foreground(gray)
|
|
|
|
helpStyle = lipgloss.NewStyle().
|
|
Foreground(dimText)
|
|
|
|
badgeStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#001a10")).
|
|
Background(green).
|
|
Bold(true).
|
|
Padding(0, 1)
|
|
)
|
|
|
|
func initialModel() model {
|
|
configPath := getConfigPath()
|
|
|
|
if err := ensureConfigExists(configPath); err != nil {
|
|
return model{err: err}
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
return model{err: err}
|
|
}
|
|
|
|
m := model{
|
|
cfg: cfg,
|
|
servers: cfg.Servers,
|
|
selected: 0,
|
|
view: ViewServers,
|
|
serverStatus: make(map[int]string),
|
|
}
|
|
|
|
m.initAddInputs()
|
|
m.initSettingsInput()
|
|
return m
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return checkAllServerStatus(m.servers)
|
|
}
|
|
|
|
func (m *model) initAddInputs() {
|
|
labels := []string{
|
|
"Name",
|
|
"Host",
|
|
"User",
|
|
"Port",
|
|
"Group",
|
|
"Auth (key/password)",
|
|
"Key Path",
|
|
"Password ID",
|
|
}
|
|
|
|
m.addInputs = make([]textinput.Model, len(labels))
|
|
|
|
for i, label := range labels {
|
|
input := textinput.New()
|
|
input.Placeholder = label
|
|
input.CharLimit = 120
|
|
input.Width = 40
|
|
|
|
switch label {
|
|
case "Port":
|
|
input.SetValue("22")
|
|
case "Auth (key/password)":
|
|
input.SetValue("key")
|
|
case "Group":
|
|
input.SetValue("Homelab")
|
|
}
|
|
|
|
if i == 0 {
|
|
input.Focus()
|
|
}
|
|
|
|
m.addInputs[i] = input
|
|
}
|
|
}
|
|
|
|
func (m *model) initSettingsInput() {
|
|
input := textinput.New()
|
|
input.Placeholder = "TERM Override"
|
|
input.CharLimit = 80
|
|
input.Width = 32
|
|
input.SetValue(m.cfg.Settings.Terminal.Term)
|
|
|
|
m.settingsTermInput = input
|
|
}
|
|
|
|
func (m *model) saveNewServer() error {
|
|
port, err := strconv.Atoi(m.addInputs[3].Value())
|
|
if err != nil {
|
|
port = 22
|
|
}
|
|
|
|
server := models.Server{
|
|
Name: m.addInputs[0].Value(),
|
|
Host: m.addInputs[1].Value(),
|
|
User: m.addInputs[2].Value(),
|
|
Port: port,
|
|
Group: m.addInputs[4].Value(),
|
|
Auth: m.addInputs[5].Value(),
|
|
Key: m.addInputs[6].Value(),
|
|
PasswordID: m.addInputs[7].Value(),
|
|
KittyFix: true,
|
|
}
|
|
|
|
if server.Name == "" || server.Host == "" || server.User == "" {
|
|
return fmt.Errorf("name, host und user sind pflichtfelder")
|
|
}
|
|
|
|
if server.Auth == "" {
|
|
server.Auth = "key"
|
|
}
|
|
|
|
if server.Auth == "password" && server.PasswordID == "" {
|
|
server.PasswordID = strings.ToLower(server.Name) + "-" + server.User
|
|
}
|
|
|
|
m.cfg.Servers = append(m.cfg.Servers, server)
|
|
m.servers = m.cfg.Servers
|
|
|
|
err = config.SaveConfig(getConfigPath(), m.cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.selected = len(m.servers) - 1
|
|
m.initAddInputs()
|
|
m.view = ViewServers
|
|
|
|
return nil
|
|
}
|
|
|
|
type quickCommandResultMsg struct {
|
|
Title string
|
|
Output string
|
|
Error string
|
|
}
|
|
|
|
type serverStatusMsg struct {
|
|
Index int
|
|
Status string
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if m.err != nil {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
case quickCommandResultMsg:
|
|
m.commandTitle = msg.Title
|
|
m.commandOutput = msg.Output
|
|
m.commandError = msg.Error
|
|
m.view = ViewCommandOutput
|
|
return m, nil
|
|
|
|
case serverStatusMsg:
|
|
if m.serverStatus == nil {
|
|
m.serverStatus = make(map[int]string)
|
|
}
|
|
|
|
m.serverStatus[msg.Index] = msg.Status
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
|
|
// Command Output offen lassen, bis du ihn wegdrückst
|
|
if m.view == ViewCommandOutput {
|
|
switch msg.String() {
|
|
case "q", "esc", "enter":
|
|
m.view = ViewCommands
|
|
return m, nil
|
|
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
// Add/Edit Formular
|
|
if m.view == ViewAddServer || m.view == ViewEditServer {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "esc", "q":
|
|
m.view = ViewServers
|
|
return m, nil
|
|
|
|
case "tab", "down":
|
|
m.addInputs[m.addFocus].Blur()
|
|
m.addFocus = (m.addFocus + 1) % len(m.addInputs)
|
|
m.addInputs[m.addFocus].Focus()
|
|
return m, nil
|
|
|
|
case "r":
|
|
return m, checkAllServerStatus(m.servers)
|
|
|
|
case "shift+tab", "up":
|
|
m.addInputs[m.addFocus].Blur()
|
|
m.addFocus--
|
|
if m.addFocus < 0 {
|
|
m.addFocus = len(m.addInputs) - 1
|
|
}
|
|
m.addInputs[m.addFocus].Focus()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
if m.addFocus < len(m.addInputs)-1 {
|
|
m.addInputs[m.addFocus].Blur()
|
|
m.addFocus++
|
|
m.addInputs[m.addFocus].Focus()
|
|
return m, nil
|
|
}
|
|
|
|
var err error
|
|
if m.view == ViewAddServer {
|
|
err = m.saveNewServer()
|
|
} else {
|
|
err = m.saveEditedServer()
|
|
}
|
|
|
|
if err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.addInputs[m.addFocus], cmd = m.addInputs[m.addFocus].Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// Delete Confirm
|
|
if m.view == ViewDeleteConfirm {
|
|
switch msg.String() {
|
|
case "y", "Y", "j":
|
|
err := m.deleteSelectedServer()
|
|
if err != nil {
|
|
m.err = err
|
|
}
|
|
return m, nil
|
|
|
|
case "n", "N", "esc", "q":
|
|
m.view = ViewServers
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if m.view == ViewSettings {
|
|
if m.settingsEditingTerm {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "esc":
|
|
m.settingsEditingTerm = false
|
|
m.settingsTermInput.Blur()
|
|
m.settingsTermInput.SetValue(m.cfg.Settings.Terminal.Term)
|
|
return m, nil
|
|
|
|
case "enter":
|
|
m.cfg.Settings.Terminal.Term = strings.TrimSpace(m.settingsTermInput.Value())
|
|
if err := m.saveSettings(); err != nil {
|
|
m.err = err
|
|
}
|
|
m.settingsEditingTerm = false
|
|
m.settingsTermInput.Blur()
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.settingsTermInput, cmd = m.settingsTermInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "q", "esc":
|
|
m.view = ViewServers
|
|
return m, nil
|
|
|
|
case "up", "k":
|
|
if m.settingsSelected > 0 {
|
|
m.settingsSelected--
|
|
}
|
|
return m, nil
|
|
|
|
case "down", "j":
|
|
if m.settingsSelected < settingsOptionCount()-1 {
|
|
m.settingsSelected++
|
|
}
|
|
return m, nil
|
|
|
|
case "enter", " ":
|
|
switch m.settingsSelected {
|
|
case 0:
|
|
m.cfg.Settings.Terminal.EnableKittyFix = !m.cfg.Settings.Terminal.EnableKittyFix
|
|
if err := m.saveSettings(); err != nil {
|
|
m.err = err
|
|
}
|
|
case 1:
|
|
m.settingsEditingTerm = true
|
|
m.settingsTermInput.SetValue(m.cfg.Settings.Terminal.Term)
|
|
m.settingsTermInput.Focus()
|
|
case 2:
|
|
m.cfg.Settings.Theme = nextTheme(m.cfg.Settings.Theme)
|
|
if err := m.saveSettings(); err != nil {
|
|
m.err = err
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Normale Navigation
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "q":
|
|
if m.view != ViewServers {
|
|
m.view = ViewServers
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
|
|
case "tab":
|
|
m.view = nextView(m.view)
|
|
|
|
case "a":
|
|
m.view = ViewAddServer
|
|
m.addFocus = 0
|
|
m.initAddInputs()
|
|
return m, nil
|
|
|
|
case "e":
|
|
if m.view == ViewServers && len(m.servers) > 0 {
|
|
m.editIndex = m.selected
|
|
m.view = ViewEditServer
|
|
m.loadServerIntoForm(m.servers[m.selected])
|
|
}
|
|
return m, nil
|
|
|
|
case "d":
|
|
if m.view == ViewServers && len(m.servers) > 0 {
|
|
m.view = ViewDeleteConfirm
|
|
}
|
|
return m, nil
|
|
|
|
case "c":
|
|
if len(m.servers) > 0 {
|
|
m.view = ViewCommands
|
|
m.commandSelected = 0
|
|
}
|
|
return m, nil
|
|
|
|
case "s":
|
|
m.view = ViewSettings
|
|
m.settingsEditingTerm = false
|
|
m.settingsTermInput.Blur()
|
|
return m, nil
|
|
|
|
case "h":
|
|
m.view = ViewHelp
|
|
return m, nil
|
|
|
|
case "up", "k":
|
|
if m.view == ViewServers && m.selected > 0 {
|
|
m.selected--
|
|
}
|
|
if m.view == ViewCommands && m.commandSelected > 0 {
|
|
m.commandSelected--
|
|
}
|
|
|
|
case "down", "j":
|
|
if m.view == ViewServers && m.selected < len(m.servers)-1 {
|
|
m.selected++
|
|
}
|
|
if m.view == ViewCommands && m.commandSelected < len(m.cfg.QuickCommands)-1 {
|
|
m.commandSelected++
|
|
}
|
|
|
|
case "enter":
|
|
if m.view == ViewServers && len(m.servers) > 0 {
|
|
server := m.servers[m.selected]
|
|
return m, connectSSH(server, m.cfg.Settings)
|
|
}
|
|
|
|
if m.view == ViewCommands && len(m.servers) > 0 && len(m.cfg.QuickCommands) > 0 {
|
|
server := m.servers[m.selected]
|
|
quick := m.cfg.QuickCommands[m.commandSelected]
|
|
return m, runQuickCommand(server, m.cfg.Settings, quick)
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func nextView(v viewMode) viewMode {
|
|
switch v {
|
|
case ViewServers:
|
|
return ViewSettings
|
|
case ViewSettings:
|
|
return ViewHelp
|
|
default:
|
|
return ViewServers
|
|
}
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if m.err != nil {
|
|
return "Fehler: " + m.err.Error()
|
|
}
|
|
|
|
if m.width == 0 {
|
|
m.width = 100
|
|
}
|
|
if m.height == 0 {
|
|
m.height = 30
|
|
}
|
|
|
|
header := m.renderHeader()
|
|
|
|
var right string
|
|
switch m.view {
|
|
case ViewServers:
|
|
right = m.renderServerList()
|
|
case ViewSettings:
|
|
right = m.renderSettingsContent()
|
|
case ViewHelp:
|
|
right = m.renderHelpContent()
|
|
case ViewAddServer:
|
|
right = m.renderAddServerContent()
|
|
case ViewEditServer:
|
|
right = m.renderEditServerContent()
|
|
case ViewDeleteConfirm:
|
|
right = m.renderDeleteConfirmContent()
|
|
case ViewCommands:
|
|
right = m.renderCommandsContent()
|
|
case ViewCommandOutput:
|
|
right = m.renderCommandOutputContent()
|
|
default:
|
|
right = m.renderServerList()
|
|
}
|
|
|
|
body := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
m.renderNavigation(),
|
|
" ",
|
|
right,
|
|
)
|
|
|
|
footer := helpStyle.Render("↑/↓ Auswahl Enter Verbinden a Hinzufügen e Editieren d Löschen c Commands r Status refresh Tab Ansicht q Zurück/Beenden")
|
|
|
|
content := lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
header,
|
|
body,
|
|
footer,
|
|
)
|
|
|
|
return baseStyle.Render(content)
|
|
}
|
|
|
|
func (m model) renderHeader() string {
|
|
title := " PulseGate"
|
|
subtitle := fmt.Sprintf(
|
|
"Homelab SSH Control Center • Server: %d • Theme: %s • TERM: %s",
|
|
len(m.servers),
|
|
m.cfg.Settings.Theme,
|
|
m.cfg.Settings.Terminal.Term,
|
|
)
|
|
|
|
return headerStyle.
|
|
Width(max(m.width-6, 60)).
|
|
Render(title + "\n" + mutedStyle.Render(subtitle))
|
|
}
|
|
|
|
/*func (m model) renderServerView() string {
|
|
left := m.renderNavigation()
|
|
right := m.renderServerList()
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
|
}*/
|
|
|
|
func (m model) renderNavigation() string {
|
|
items := []struct {
|
|
icon string
|
|
name string
|
|
view viewMode
|
|
}{
|
|
{"", "Server", ViewServers},
|
|
{"", "Add Server", ViewAddServer},
|
|
{"", "Settings", ViewSettings},
|
|
{"", "Help", ViewHelp},
|
|
{"", "Commands", ViewCommands},
|
|
}
|
|
|
|
var lines []string
|
|
lines = append(lines, badgeStyle.Render(" NAVIGATION "))
|
|
lines = append(lines, "")
|
|
|
|
for _, item := range items {
|
|
line := fmt.Sprintf("%s %s", item.icon, item.name)
|
|
if m.view == item.view {
|
|
lines = append(lines, selectedStyle.Render("> "+line))
|
|
} else {
|
|
lines = append(lines, normalStyle.Render(" "+line))
|
|
}
|
|
}
|
|
|
|
return panelStyle.
|
|
Width(24).
|
|
Height(max(m.height-9, 12)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m model) renderServerList() string {
|
|
var lines []string
|
|
lines = append(lines, badgeStyle.Render(" SERVERS "))
|
|
lines = append(lines, "")
|
|
|
|
for i, server := range m.servers {
|
|
authIcon := ""
|
|
status := m.serverStatus[i]
|
|
statusRendered := statusStyle(status).Render(statusIcon(status))
|
|
if server.Auth == "key" {
|
|
authIcon = ""
|
|
}
|
|
|
|
kitty := ""
|
|
if server.KittyFix {
|
|
kitty = " kitty"
|
|
}
|
|
|
|
statusText := ""
|
|
if status == "online" {
|
|
statusText = " ssh"
|
|
} else if status == "offline" {
|
|
statusText = " off"
|
|
}
|
|
|
|
line := fmt.Sprintf(
|
|
"%s%s %s %s %s@%s:%d [%s]%s",
|
|
statusRendered,
|
|
statusText,
|
|
authIcon,
|
|
server.Name,
|
|
server.User,
|
|
server.Host,
|
|
server.Port,
|
|
server.Group,
|
|
kitty,
|
|
)
|
|
|
|
if i == m.selected {
|
|
lines = append(lines, selectedStyle.Render("> "+line))
|
|
} else {
|
|
lines = append(lines, normalStyle.Render(" "+line))
|
|
}
|
|
}
|
|
|
|
if len(m.servers) == 0 {
|
|
lines = append(lines, mutedStyle.Render("Keine Server in config.yaml gefunden."))
|
|
}
|
|
|
|
width := max(m.width-36, 50)
|
|
|
|
return panelStyle.
|
|
Width(width).
|
|
Height(max(m.height-9, 12)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m model) renderSettingsContent() string {
|
|
kittyValue := "aus"
|
|
if m.cfg.Settings.Terminal.EnableKittyFix {
|
|
kittyValue = "an"
|
|
}
|
|
|
|
termValue := m.cfg.Settings.Terminal.Term
|
|
if termValue == "" {
|
|
termValue = "xterm-256color"
|
|
}
|
|
|
|
themeValue := m.cfg.Settings.Theme
|
|
if themeValue == "" {
|
|
themeValue = "neon-green"
|
|
}
|
|
|
|
options := []string{
|
|
fmt.Sprintf("Kitty Fix global: %s", kittyValue),
|
|
fmt.Sprintf("TERM Override: %s", termValue),
|
|
fmt.Sprintf("Theme: %s", themeValue),
|
|
}
|
|
|
|
lines := []string{
|
|
badgeStyle.Render(" SETTINGS "),
|
|
"",
|
|
selectedStyle.Render("Terminal"),
|
|
}
|
|
|
|
for i, option := range options {
|
|
if i == 1 && m.settingsEditingTerm {
|
|
option = "TERM Override: " + m.settingsTermInput.View()
|
|
}
|
|
|
|
if i == m.settingsSelected {
|
|
lines = append(lines, selectedStyle.Render("> "+option))
|
|
} else {
|
|
lines = append(lines, normalStyle.Render(" "+option))
|
|
}
|
|
}
|
|
|
|
lines = append(lines,
|
|
"",
|
|
selectedStyle.Render("Hinweis"),
|
|
" Wenn Kitty Fix aktiv ist, startet SSH mit dem gewählten TERM-Wert.",
|
|
" Das verhindert auf Ubuntu/Debian oft:",
|
|
" 'Error opening terminal: xterm-kitty'",
|
|
"",
|
|
mutedStyle.Render("↑/↓ Auswahl Enter/Space ändern Esc/q zurück"),
|
|
)
|
|
|
|
if m.settingsEditingTerm {
|
|
lines = append(lines, mutedStyle.Render("TERM bearbeiten: Enter speichern Esc abbrechen"))
|
|
}
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-6, 70)).
|
|
Height(max(m.height-9, 14)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func settingsOptionCount() int {
|
|
return 3
|
|
}
|
|
|
|
func nextTheme(current string) string {
|
|
themes := []string{"neon-green", "cyan", "plain"}
|
|
|
|
for i, theme := range themes {
|
|
if current == theme {
|
|
return themes[(i+1)%len(themes)]
|
|
}
|
|
}
|
|
|
|
return themes[0]
|
|
}
|
|
|
|
func (m *model) saveSettings() error {
|
|
if m.cfg.Settings.Terminal.Term == "" {
|
|
m.cfg.Settings.Terminal.Term = "xterm-256color"
|
|
m.settingsTermInput.SetValue(m.cfg.Settings.Terminal.Term)
|
|
}
|
|
|
|
if m.cfg.Settings.Theme == "" {
|
|
m.cfg.Settings.Theme = "neon-green"
|
|
}
|
|
|
|
return config.SaveConfig(getConfigPath(), m.cfg)
|
|
}
|
|
|
|
func (m model) renderHelpContent() string {
|
|
lines := []string{
|
|
badgeStyle.Render(" HELP "),
|
|
"",
|
|
"Enter Ausgewählten Server verbinden",
|
|
"↑/↓ / j/k Server auswählen",
|
|
"Tab Ansicht wechseln",
|
|
"s Settings öffnen",
|
|
"h Hilfe öffnen",
|
|
"q Zurück oder Beenden",
|
|
"",
|
|
selectedStyle.Render("Kitty Fix"),
|
|
"Wenn du per Kitty auf Ubuntu SSH nutzt und nano/clear Fehler machen:",
|
|
"TERM=xterm-256color hilft meistens.",
|
|
}
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-6, 70)).
|
|
Height(max(m.height-9, 14)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func connectSSH(server models.Server, settings models.Settings) tea.Cmd {
|
|
switch server.Auth {
|
|
case "key":
|
|
args := []string{
|
|
"-p", strconv.Itoa(server.Port),
|
|
}
|
|
|
|
if server.Key != "" {
|
|
args = append(args, "-i", expandHome(server.Key))
|
|
}
|
|
|
|
args = append(args, server.User+"@"+server.Host)
|
|
|
|
cmd := exec.Command("ssh", args...)
|
|
cmd.Env = buildSSHEnv(server, settings)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
return err
|
|
})
|
|
|
|
case "password":
|
|
password, err := secret.GetPassword(server.PasswordID)
|
|
|
|
if err != nil {
|
|
fmt.Printf("\nPasswort für %s@%s: ", server.User, server.Host)
|
|
|
|
pw, inputErr := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Println()
|
|
|
|
if inputErr != nil {
|
|
return func() tea.Msg {
|
|
return inputErr
|
|
}
|
|
}
|
|
|
|
password = string(pw)
|
|
|
|
saveErr := secret.SavePassword(server.PasswordID, password)
|
|
if saveErr != nil {
|
|
fmt.Println("Fehler beim Speichern:", saveErr)
|
|
} else {
|
|
fmt.Println("Passwort gespeichert.")
|
|
}
|
|
}
|
|
|
|
args := []string{
|
|
"-e",
|
|
"ssh",
|
|
"-p", strconv.Itoa(server.Port),
|
|
server.User + "@" + server.Host,
|
|
}
|
|
|
|
cmd := exec.Command("sshpass", args...)
|
|
cmd.Env = append(buildSSHEnv(server, settings), "SSHPASS="+password)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
return err
|
|
})
|
|
|
|
default:
|
|
return func() tea.Msg {
|
|
return fmt.Errorf("unbekannte auth methode: %s", server.Auth)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 len(path) >= 2 && path[:2] == "~/" {
|
|
home, _ := os.UserHomeDir()
|
|
return home + path[1:]
|
|
}
|
|
return path
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (m *model) loadServerIntoForm(server models.Server) {
|
|
m.initAddInputs()
|
|
|
|
m.addInputs[0].SetValue(server.Name)
|
|
m.addInputs[1].SetValue(server.Host)
|
|
m.addInputs[2].SetValue(server.User)
|
|
m.addInputs[3].SetValue(strconv.Itoa(server.Port))
|
|
m.addInputs[4].SetValue(server.Group)
|
|
m.addInputs[5].SetValue(server.Auth)
|
|
m.addInputs[6].SetValue(server.Key)
|
|
m.addInputs[7].SetValue(server.PasswordID)
|
|
|
|
m.addFocus = 0
|
|
m.addInputs[0].Focus()
|
|
}
|
|
|
|
func (m *model) saveEditedServer() error {
|
|
if m.editIndex < 0 || m.editIndex >= len(m.servers) {
|
|
return fmt.Errorf("ungültiger server index")
|
|
}
|
|
|
|
port, err := strconv.Atoi(m.addInputs[3].Value())
|
|
if err != nil {
|
|
port = 22
|
|
}
|
|
|
|
server := models.Server{
|
|
Name: m.addInputs[0].Value(),
|
|
Host: m.addInputs[1].Value(),
|
|
User: m.addInputs[2].Value(),
|
|
Port: port,
|
|
Group: m.addInputs[4].Value(),
|
|
Auth: m.addInputs[5].Value(),
|
|
Key: m.addInputs[6].Value(),
|
|
PasswordID: m.addInputs[7].Value(),
|
|
KittyFix: true,
|
|
}
|
|
|
|
if server.Name == "" || server.Host == "" || server.User == "" {
|
|
return fmt.Errorf("name, host und user sind pflichtfelder")
|
|
}
|
|
|
|
if server.Auth == "" {
|
|
server.Auth = "key"
|
|
}
|
|
|
|
if server.Auth == "password" && server.PasswordID == "" {
|
|
server.PasswordID = strings.ToLower(server.Name) + "-" + server.User
|
|
}
|
|
|
|
m.cfg.Servers[m.editIndex] = server
|
|
m.servers = m.cfg.Servers
|
|
|
|
err = config.SaveConfig(getConfigPath(), m.cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.selected = m.editIndex
|
|
m.view = ViewServers
|
|
m.initAddInputs()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *model) deleteSelectedServer() error {
|
|
if m.selected < 0 || m.selected >= len(m.servers) {
|
|
return fmt.Errorf("kein gültiger server ausgewählt")
|
|
}
|
|
|
|
m.cfg.Servers = append(
|
|
m.cfg.Servers[:m.selected],
|
|
m.cfg.Servers[m.selected+1:]...,
|
|
)
|
|
|
|
m.servers = m.cfg.Servers
|
|
|
|
if m.selected >= len(m.servers) && m.selected > 0 {
|
|
m.selected--
|
|
}
|
|
|
|
err := config.SaveConfig(getConfigPath(), m.cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.view = ViewServers
|
|
return nil
|
|
}
|
|
|
|
func (m model) renderEditServerContent() string {
|
|
return m.renderServerFormContent(" EDIT SERVER ", "Server bearbeiten")
|
|
}
|
|
|
|
func (m model) renderAddServerContent() string {
|
|
return m.renderServerFormContent(" ADD SERVER ", "Neuen SSH-Server hinzufügen")
|
|
}
|
|
|
|
func (m model) renderServerFormContent(badge string, title string) string {
|
|
lines := []string{
|
|
badgeStyle.Render(badge),
|
|
"",
|
|
title,
|
|
"",
|
|
}
|
|
|
|
labels := []string{
|
|
"Name",
|
|
"Host",
|
|
"User",
|
|
"Port",
|
|
"Group",
|
|
"Auth",
|
|
"Key Path",
|
|
"Password ID",
|
|
}
|
|
|
|
for i, input := range m.addInputs {
|
|
label := labels[i]
|
|
|
|
if i == m.addFocus {
|
|
lines = append(lines, selectedStyle.Render("> "+label))
|
|
} else {
|
|
lines = append(lines, normalStyle.Render(" "+label))
|
|
}
|
|
|
|
lines = append(lines, " "+input.View())
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
lines = append(lines, mutedStyle.Render("Enter: nächstes Feld / speichern Tab: weiter Esc/q: abbrechen"))
|
|
lines = append(lines, mutedStyle.Render("Auth: key oder password"))
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-36, 60)).
|
|
Height(max(m.height-9, 18)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m model) renderCommandOutputContent() string {
|
|
lines := []string{
|
|
badgeStyle.Render(" COMMAND OUTPUT "),
|
|
"",
|
|
selectedStyle.Render(m.commandTitle),
|
|
"",
|
|
}
|
|
|
|
if m.commandError != "" {
|
|
lines = append(lines, warnStyle().Render("Fehler: "+m.commandError))
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
output := strings.TrimSpace(m.commandOutput)
|
|
if output == "" {
|
|
output = "Keine Ausgabe."
|
|
}
|
|
|
|
lines = append(lines, output)
|
|
lines = append(lines, "")
|
|
lines = append(lines, mutedStyle.Render("q / Esc / Enter: zurück zu Quick Commands"))
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-36, 60)).
|
|
Height(max(m.height-9, 14)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m model) renderCommandsContent() string {
|
|
lines := []string{
|
|
badgeStyle.Render(" QUICK COMMANDS "),
|
|
"",
|
|
}
|
|
|
|
if len(m.servers) == 0 {
|
|
lines = append(lines, mutedStyle.Render("Kein Server ausgewählt."))
|
|
} else {
|
|
server := m.servers[m.selected]
|
|
lines = append(lines, fmt.Sprintf("Server: %s %s@%s:%d", server.Name, server.User, server.Host, server.Port))
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
if len(m.cfg.QuickCommands) == 0 {
|
|
lines = append(lines, mutedStyle.Render("Keine quick_commands in config.yaml gefunden."))
|
|
} else {
|
|
for i, cmd := range m.cfg.QuickCommands {
|
|
line := fmt.Sprintf("%s → %s", cmd.Name, cmd.Command)
|
|
|
|
if i == m.commandSelected {
|
|
lines = append(lines, selectedStyle.Render("> "+line))
|
|
} else {
|
|
lines = append(lines, normalStyle.Render(" "+line))
|
|
}
|
|
}
|
|
}
|
|
|
|
lines = append(lines, "")
|
|
lines = append(lines, mutedStyle.Render("Enter: Command ausführen ↑/↓ Auswahl q: zurück"))
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-36, 60)).
|
|
Height(max(m.height-9, 14)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func runQuickCommand(server models.Server, settings models.Settings, quick models.QuickCommand) tea.Cmd {
|
|
return func() tea.Msg {
|
|
title := fmt.Sprintf("%s auf %s", quick.Name, server.Name)
|
|
|
|
args := []string{
|
|
"-p", strconv.Itoa(server.Port),
|
|
}
|
|
|
|
if settings.Terminal.EnableKittyFix && server.KittyFix {
|
|
args = append(args, "-t")
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
|
|
if server.Auth == "key" {
|
|
if server.Key != "" {
|
|
args = append(args, "-i", expandHome(server.Key))
|
|
}
|
|
|
|
args = append(args, server.User+"@"+server.Host, quick.Command)
|
|
|
|
cmd = exec.Command("ssh", args...)
|
|
cmd.Env = buildSSHEnv(server, settings)
|
|
} else if server.Auth == "password" {
|
|
password, err := secret.GetPassword(server.PasswordID)
|
|
if err != nil {
|
|
return quickCommandResultMsg{
|
|
Title: title,
|
|
Output: "",
|
|
Error: fmt.Sprintf("kein passwort gespeichert für %s", server.PasswordID),
|
|
}
|
|
}
|
|
|
|
sshArgs := []string{
|
|
"-e",
|
|
"ssh",
|
|
"-p", strconv.Itoa(server.Port),
|
|
server.User + "@" + server.Host,
|
|
quick.Command,
|
|
}
|
|
|
|
cmd = exec.Command("sshpass", sshArgs...)
|
|
cmd.Env = append(buildSSHEnv(server, settings), "SSHPASS="+password)
|
|
} else {
|
|
return quickCommandResultMsg{
|
|
Title: title,
|
|
Output: "",
|
|
Error: fmt.Sprintf("unbekannte auth methode: %s", server.Auth),
|
|
}
|
|
}
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
errorText := ""
|
|
if err != nil {
|
|
errorText = err.Error()
|
|
}
|
|
|
|
return quickCommandResultMsg{
|
|
Title: title,
|
|
Output: string(output),
|
|
Error: errorText,
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkAllServerStatus(servers []models.Server) tea.Cmd {
|
|
cmds := make([]tea.Cmd, 0, len(servers))
|
|
|
|
for i, server := range servers {
|
|
cmds = append(cmds, checkServerStatus(i, server))
|
|
}
|
|
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func checkServerStatus(index int, server models.Server) tea.Cmd {
|
|
return func() tea.Msg {
|
|
port := server.Port
|
|
if port == 0 {
|
|
port = 22
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", server.Host, port)
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond)
|
|
if err != nil {
|
|
return serverStatusMsg{
|
|
Index: index,
|
|
Status: "offline",
|
|
}
|
|
}
|
|
|
|
_ = conn.Close()
|
|
|
|
return serverStatusMsg{
|
|
Index: index,
|
|
Status: "online",
|
|
}
|
|
}
|
|
}
|
|
|
|
func statusIcon(status string) string {
|
|
switch status {
|
|
case "online":
|
|
return "●"
|
|
case "offline":
|
|
return "○"
|
|
default:
|
|
return "◌"
|
|
}
|
|
}
|
|
|
|
func statusStyle(status string) lipgloss.Style {
|
|
switch status {
|
|
case "online":
|
|
return lipgloss.NewStyle().Foreground(green).Bold(true)
|
|
case "offline":
|
|
return lipgloss.NewStyle().Foreground(gray)
|
|
default:
|
|
return lipgloss.NewStyle().Foreground(dimText)
|
|
}
|
|
}
|
|
|
|
func (m model) renderDeleteConfirmContent() string {
|
|
if len(m.servers) == 0 {
|
|
return panelStyle.Render("Kein Server zum Löschen vorhanden.")
|
|
}
|
|
|
|
server := m.servers[m.selected]
|
|
|
|
lines := []string{
|
|
badgeStyle.Render(" DELETE SERVER "),
|
|
"",
|
|
warnStyle().Render("Diesen Server wirklich löschen?"),
|
|
"",
|
|
fmt.Sprintf("Name: %s", server.Name),
|
|
fmt.Sprintf("Host: %s@%s:%d", server.User, server.Host, server.Port),
|
|
fmt.Sprintf("Group: %s", server.Group),
|
|
"",
|
|
selectedStyle.Render("y / j = löschen"),
|
|
mutedStyle.Render("n / q / Esc = abbrechen"),
|
|
}
|
|
|
|
return panelStyle.
|
|
Width(max(m.width-36, 60)).
|
|
Height(max(m.height-9, 14)).
|
|
Render(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func warnStyle() lipgloss.Style {
|
|
return lipgloss.NewStyle().
|
|
Foreground(warn).
|
|
Bold(true)
|
|
}
|
|
|
|
func getConfigPath() string {
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
home, _ := os.UserHomeDir()
|
|
return home + "/.config/pulsegate/config.yaml"
|
|
}
|
|
|
|
return configDir + "/pulsegate/config.yaml"
|
|
}
|
|
|
|
func ensureConfigExists(path string) error {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return nil
|
|
}
|
|
|
|
dir := strings.TrimSuffix(path, "/config.yaml")
|
|
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
|
|
example := `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
|
|
kitty_fix: true
|
|
|
|
quick_commands:
|
|
- name: Disk Usage
|
|
command: df -h
|
|
|
|
- name: RAM Usage
|
|
command: free -h
|
|
|
|
- name: Uptime
|
|
command: uptime
|
|
`
|
|
|
|
return os.WriteFile(path, []byte(example), 0600)
|
|
}
|
|
|
|
func main() {
|
|
p := tea.NewProgram(
|
|
initialModel(),
|
|
tea.WithAltScreen(),
|
|
)
|
|
|
|
if _, err := p.Run(); err != nil {
|
|
fmt.Println("Fehler:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|