Compare commits

..

2 Commits

Author SHA1 Message Date
Calmcacil
8b49fbfd3a bd sync: 2026-01-12 23:02:08 2026-01-12 23:02:08 +01:00
Calmcacil
78a100112c Fix q key behavior in client details view 2026-01-12 23:01:59 +01:00
7 changed files with 356 additions and 39 deletions

File diff suppressed because one or more lines are too long

View File

@@ -26,6 +26,7 @@ type model struct {
previousScreen screens.Screen previousScreen screens.Screen
quitting bool quitting bool
initialized bool initialized bool
errorScreen *screens.ErrorScreen
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {

View File

@@ -29,6 +29,10 @@ var (
addHelpStyle = lipgloss.NewStyle(). addHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
MarginTop(1) MarginTop(1)
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginTop(1)
) )
// NewAddScreen creates a new add screen // NewAddScreen creates a new add screen
@@ -71,14 +75,24 @@ func NewAddScreen() *AddScreen {
), ),
) )
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &AddScreen{ return &AddScreen{
form: form, form: form,
quitting: false, quitting: false,
spinner: s,
isCreating: false,
} }
} }
// Init initializes the add screen // Init initializes the add screen
func (s *AddScreen) Init() tea.Cmd { func (s *AddScreen) Init() tea.Cmd {
if s.isCreating {
return s.spinner.Tick
}
return s.form.Init() return s.form.Init()
} }
@@ -90,11 +104,20 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "q", "ctrl+c", "esc": case "q", "ctrl+c", "esc":
// Cancel and return to list // Cancel and return to list (only if not creating)
return nil, nil if !s.isCreating {
return nil, nil
}
} }
} }
// If creating, update spinner instead of form
if s.isCreating {
var cmd tea.Cmd
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// Update the form // Update the form
form, cmd := s.form.Update(msg) form, cmd := s.form.Update(msg)
if f, ok := form.(*huh.Form); ok { if f, ok := form.(*huh.Form); ok {
@@ -108,6 +131,8 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
dns := s.form.GetString("dns") dns := s.form.GetString("dns")
usePSK := s.form.GetBool("use_psk") usePSK := s.form.GetBool("use_psk")
// Set creating state and start spinner
s.isCreating = true
// Create the client // Create the client
return s, s.createClient(name, dns, usePSK) return s, s.createClient(name, dns, usePSK)
} }
@@ -121,6 +146,16 @@ func (s *AddScreen) View() string {
return "" return ""
} }
if s.isCreating {
return loadingStyle.Render(
lipgloss.JoinVertical(
lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"),
s.spinner.View()+" Creating client configuration, please wait...",
),
)
}
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"), addTitleStyle.Render("Add New WireGuard Client"),

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/calmcacil/wg-admin/internal/wireguard" "github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -25,33 +26,11 @@ type DetailScreen struct {
// Styles // Styles
var ( var (
detailTitleStyle = lipgloss.NewStyle(). detailTitleStyle = lipgloss.NewStyle().Bold(true).MarginTop(0)
Foreground(lipgloss.Color("62")). detailSectionStyle = lipgloss.NewStyle().Bold(true).MarginTop(1)
Bold(true) detailLabelStyle = lipgloss.NewStyle().Width(18)
detailSectionStyle = lipgloss.NewStyle(). detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
Foreground(lipgloss.Color("241")). dimmedContentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
Bold(true).
MarginTop(1)
detailLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Width(18)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
detailConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
detailDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
detailWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
detailHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
detailErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
MarginTop(1)
) )
// NewDetailScreen creates a new detail screen for a client // NewDetailScreen creates a new detail screen for a client

View File

@@ -0,0 +1,242 @@
package screens
import (
"strings"
"github.com/calmcacil/wg-admin/internal/tui/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ErrorScreen displays user-friendly error messages with recovery options
type ErrorScreen struct {
err error
friendly string
actions []ErrorAction
quitting bool
}
// ErrorAction represents a recovery action
type ErrorAction struct {
Key string
Label string
Description string
}
// NewErrorScreen creates a new error screen with mapped error information
func NewErrorScreen(err error) *ErrorScreen {
screen := &ErrorScreen{
err: err,
friendly: err.Error(), // Fallback to raw error
actions: []ErrorAction{{Key: "enter", Label: "OK", Description: "Dismiss and return"}},
quitting: false,
}
// Map error to user-friendly message and recovery options
screen.mapError(err)
return screen
}
// Init initializes the error screen
func (s *ErrorScreen) Init() tea.Cmd {
return nil
}
// Update handles messages for the error screen
func (s *ErrorScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
// Quit application
s.quitting = true
return s, tea.Quit
case "enter", "esc":
// Dismiss error screen
return nil, nil
}
}
return s, nil
}
// View renders the error screen
func (s *ErrorScreen) View() string {
if s.quitting {
return ""
}
titleStyle := theme.StyleTitle.Copy().MarginBottom(1)
errorStyle := theme.StyleError.Copy().Bold(true)
msgStyle := theme.StyleSubtitle.Copy().MarginTop(1).MarginBottom(2)
actionTitleStyle := theme.StylePrimary.Copy().Bold(true).MarginTop(1)
actionStyle := theme.StyleMuted.Copy().MarginLeft(2)
// Build the error display
content := lipgloss.JoinVertical(
lipgloss.Left,
titleStyle.Render("Error Occurred"),
errorStyle.Render("⚠ "+s.friendly),
msgStyle.Render("Technical Details: "+s.err.Error()),
)
// Add recovery actions
if len(s.actions) > 1 {
content += "\n" + actionTitleStyle.Render("Recovery Options:")
for _, action := range s.actions {
key := theme.StyleHelpKey.Render("[" + action.Key + "]")
content += "\n" + actionStyle.Render(
lipgloss.JoinHorizontal(lipgloss.Left,
key+" ",
action.Label+" - "+action.Description,
),
)
}
}
content += "\n\n" + theme.StyleMuted.Render("Press Enter to dismiss • Press q to quit")
return lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.GetCurrentTheme().Scheme.Error).
Render(content)
}
// mapError converts technical errors to user-friendly messages and recovery options
func (s *ErrorScreen) mapError(err error) {
if err == nil {
return
}
errStr := strings.ToLower(err.Error())
// Permission errors
if strings.Contains(errStr, "permission") ||
strings.Contains(errStr, "denied") ||
strings.Contains(errStr, "operation not permitted") {
s.friendly = "Permission Denied"
s.actions = []ErrorAction{
{Key: "r", Label: "Run with sudo", Description: "Restart with elevated privileges"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// File not found errors
if strings.Contains(errStr, "no such file") ||
strings.Contains(errStr, "file not found") ||
strings.Contains(errStr, "does not exist") {
s.friendly = "Configuration File Missing"
s.actions = []ErrorAction{
{Key: "r", Label: "Restore", Description: "Restore from backup (if available)"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Client already exists errors
if strings.Contains(errStr, "already exists") ||
strings.Contains(errStr, "duplicate") {
s.friendly = "Client Already Exists"
s.actions = []ErrorAction{
{Key: "n", Label: "New Name", Description: "Try a different client name"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// IP address exhaustion
if strings.Contains(errStr, "no available") ||
strings.Contains(errStr, "exhausted") ||
strings.Contains(errStr, "out of") {
s.friendly = "No Available IP Addresses"
s.actions = []ErrorAction{
{Key: "d", Label: "Delete Client", Description: "Remove an unused client to free IPs"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Config file parsing errors
if strings.Contains(errStr, "parse") ||
strings.Contains(errStr, "invalid") ||
strings.Contains(errStr, "malformed") {
s.friendly = "Invalid Configuration"
s.actions = []ErrorAction{
{Key: "r", Label: "Restore", Description: "Restore from backup"},
{Key: "m", Label: "Manual Fix", Description: "Edit configuration manually"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// WireGuard command failures
if strings.Contains(errStr, "wg genkey") ||
strings.Contains(errStr, "wg pubkey") ||
strings.Contains(errStr, "wg genpsk") ||
strings.Contains(errStr, "wg set") {
s.friendly = "WireGuard Command Failed"
s.actions = []ErrorAction{
{Key: "i", Label: "Install WG", Description: "Ensure WireGuard is installed"},
{Key: "s", Label: "Check Service", Description: "Verify WireGuard service is running"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Network errors
if strings.Contains(errStr, "network") ||
strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout") {
s.friendly = "Network Error"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
{Key: "c", Label: "Check Connection", Description: "Verify network connectivity"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Config directory not found
if strings.Contains(errStr, "config directory") ||
strings.Contains(errStr, "wireguard") {
s.friendly = "WireGuard Not Configured"
s.actions = []ErrorAction{
{Key: "i", Label: "Install WireGuard", Description: "Set up WireGuard on this server"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// DNS validation errors
if strings.Contains(errStr, "dns") ||
strings.Contains(errStr, "invalid address") {
s.friendly = "Invalid DNS Configuration"
s.actions = []ErrorAction{
{Key: "e", Label: "Edit DNS", Description: "Update DNS server settings"},
{Key: "d", Label: "Use Default", Description: "Use default DNS (8.8.8.8, 8.8.4.4)"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Backup/restore errors
if strings.Contains(errStr, "backup") ||
strings.Contains(errStr, "restore") {
s.friendly = "Backup Operation Failed"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt backup/restore again"},
{Key: "c", Label: "Check Space", Description: "Verify disk space is available"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Default: generic error
s.friendly = "An Error Occurred"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
}

View File

@@ -1,6 +1,7 @@
package screens package screens
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -135,17 +136,62 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
} }
// View renders the list screen // View renders the list screen
// Breadcrumb: Home
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
})
func (s *ListScreen) View() string { func (s *ListScreen) View() string {
if len(s.clients) == 0 { if len(s.clients) == 0 {
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit." // Empty state with helpful guidance
return s.search.View() + "\n\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
Render("No clients yet") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Let's get started! Here are your options:") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [a] to add your first client") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [R] to restore from backup") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [r] to refresh the client list") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [q] to quit")
} }
// Check if there are no matches // Check if there are no matches
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" { if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
return s.search.View() + "\n" + lipgloss.NewStyle(). // Empty search results with helpful tips
Foreground(lipgloss.Color("241")). return s.search.View() + "\n\n" +
Italic(true). lipgloss.NewStyle().
Render("No matching clients found. Try a different search term.") Foreground(lipgloss.Color("226")).
Bold(true).
Render("No matching clients found") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Search tips:") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Check your spelling") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Try a shorter search term") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Search by name, IP, or status") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [esc] to clear search") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [r] to refresh client list")
} }
// Calculate time since last update // Calculate time since last update
@@ -228,13 +274,21 @@ func (s *ListScreen) applyFilter() {
s.buildTable() s.buildTable()
} }
// formatStatusWithIcon formats the status with a colored circle icon
func (s *ListScreen) formatStatusWithIcon(status string) string {
if status == wireguard.StatusConnected {
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
}
// buildTable creates and configures the table // buildTable creates and configures the table
func (s *ListScreen) buildTable() { func (s *ListScreen) buildTable() {
columns := []table.Column{ columns := []table.Column{
{Title: "Name", Width: 20}, {Title: "Name", Width: 20},
{Title: "IPv4", Width: 15}, {Title: "IPv4", Width: 15},
{Title: "IPv6", Width: 35}, {Title: "IPv6", Width: 35},
{Title: "Status", Width: 12}, {Title: "Status", Width: 14},
} }
// Use filtered clients if search is active, otherwise use all clients // Use filtered clients if search is active, otherwise use all clients
@@ -245,11 +299,12 @@ func (s *ListScreen) buildTable() {
var rows []table.Row var rows []table.Row
for _, cws := range displayClients { for _, cws := range displayClients {
statusText := s.formatStatusWithIcon(cws.Status)
row := table.Row{ row := table.Row{
cws.Client.Name, cws.Client.Name,
cws.Client.IPv4, cws.Client.IPv4,
cws.Client.IPv6, cws.Client.IPv6,
cws.Status, statusText,
} }
rows = append(rows, row) rows = append(rows, row)
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/calmcacil/wg-admin/internal/backup" "github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -22,6 +23,7 @@ type RestoreScreen struct {
restoreError error restoreError error
restoreSuccess bool restoreSuccess bool
message string message string
spinner spinner.Model
} }
// Styles // Styles
@@ -41,6 +43,9 @@ var (
restoreInfoStyle = lipgloss.NewStyle(). restoreInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
MarginTop(1) MarginTop(1)
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
) )
// NewRestoreScreen creates a new restore screen // NewRestoreScreen creates a new restore screen