diff --git a/cmd/wg-tui/main.go b/cmd/wg-tui/main.go index 6df531f..b3d2b6d 100644 --- a/cmd/wg-tui/main.go +++ b/cmd/wg-tui/main.go @@ -26,6 +26,7 @@ type model struct { previousScreen screens.Screen quitting bool initialized bool + errorScreen *screens.ErrorScreen } func (m model) Init() tea.Cmd { diff --git a/internal/tui/screens/add.go b/internal/tui/screens/add.go index 892e1cf..7115e11 100644 --- a/internal/tui/screens/add.go +++ b/internal/tui/screens/add.go @@ -29,6 +29,10 @@ var ( addHelpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). MarginTop(1) + loadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("62")). + Bold(true). + MarginTop(1) ) // 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{ - form: form, - quitting: false, + form: form, + quitting: false, + spinner: s, + isCreating: false, } } // Init initializes the add screen func (s *AddScreen) Init() tea.Cmd { + if s.isCreating { + return s.spinner.Tick + } return s.form.Init() } @@ -90,11 +104,20 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c", "esc": - // Cancel and return to list - return nil, nil + // Cancel and return to list (only if not creating) + 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 form, cmd := s.form.Update(msg) 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") usePSK := s.form.GetBool("use_psk") + // Set creating state and start spinner + s.isCreating = true // Create the client return s, s.createClient(name, dns, usePSK) } @@ -121,6 +146,16 @@ func (s *AddScreen) View() string { 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( lipgloss.Left, addTitleStyle.Render("Add New WireGuard Client"), diff --git a/internal/tui/screens/detail.go b/internal/tui/screens/detail.go index 91c62c5..f38b69e 100644 --- a/internal/tui/screens/detail.go +++ b/internal/tui/screens/detail.go @@ -5,6 +5,7 @@ import ( "time" "github.com/calmcacil/wg-admin/internal/tui/components" + "github.com/calmcacil/wg-admin/internal/tui/theme" "github.com/calmcacil/wg-admin/internal/wireguard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -25,33 +26,11 @@ type DetailScreen struct { // Styles var ( - detailTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true) - detailSectionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - 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) + detailTitleStyle = lipgloss.NewStyle().Bold(true).MarginTop(0) + detailSectionStyle = lipgloss.NewStyle().Bold(true).MarginTop(1) + detailLabelStyle = lipgloss.NewStyle().Width(18) + detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + dimmedContentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) ) // NewDetailScreen creates a new detail screen for a client diff --git a/internal/tui/screens/error.go b/internal/tui/screens/error.go new file mode 100644 index 0000000..90975d0 --- /dev/null +++ b/internal/tui/screens/error.go @@ -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"}, + } +} diff --git a/internal/tui/screens/list.go b/internal/tui/screens/list.go index 5da740f..f551576 100644 --- a/internal/tui/screens/list.go +++ b/internal/tui/screens/list.go @@ -1,6 +1,7 @@ package screens import ( + "fmt" "sort" "strings" "time" @@ -135,17 +136,62 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { } // View renders the list screen + // Breadcrumb: Home + breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{ + {Label: "Clients", ID: "list"}, + }) + func (s *ListScreen) View() string { 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 if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" { - return s.search.View() + "\n" + lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - Italic(true). - Render("No matching clients found. Try a different search term.") + // Empty search results with helpful tips + return s.search.View() + "\n\n" + + lipgloss.NewStyle(). + 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 @@ -228,13 +274,21 @@ func (s *ListScreen) applyFilter() { 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 func (s *ListScreen) buildTable() { columns := []table.Column{ {Title: "Name", Width: 20}, {Title: "IPv4", Width: 15}, {Title: "IPv6", Width: 35}, - {Title: "Status", Width: 12}, + {Title: "Status", Width: 14}, } // Use filtered clients if search is active, otherwise use all clients @@ -245,11 +299,12 @@ func (s *ListScreen) buildTable() { var rows []table.Row for _, cws := range displayClients { + statusText := s.formatStatusWithIcon(cws.Status) row := table.Row{ cws.Client.Name, cws.Client.IPv4, cws.Client.IPv6, - cws.Status, + statusText, } rows = append(rows, row) } diff --git a/internal/tui/screens/restore.go b/internal/tui/screens/restore.go index 1cdce32..7919adf 100644 --- a/internal/tui/screens/restore.go +++ b/internal/tui/screens/restore.go @@ -6,6 +6,7 @@ import ( "github.com/calmcacil/wg-admin/internal/backup" "github.com/calmcacil/wg-admin/internal/tui/components" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -22,6 +23,7 @@ type RestoreScreen struct { restoreError error restoreSuccess bool message string + spinner spinner.Model } // Styles @@ -41,6 +43,9 @@ var ( restoreInfoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). MarginTop(1) + loadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("62")). + Bold(true) ) // NewRestoreScreen creates a new restore screen