Compare commits

...

28 Commits

Author SHA1 Message Date
Calmcacil
9c0da13a19 bd sync: 2026-01-12 23:44:57 2026-01-12 23:44:57 +01:00
Calmcacil
99b9dc17db Enhance search with match highlighting, count display, Ctrl+U to clear 2026-01-12 23:43:24 +01:00
Calmcacil
6629598574 bd sync: 2026-01-12 23:42:48 2026-01-12 23:42:48 +01:00
Calmcacil
50321a8471 Add connection quality indicators based on handshake time 2026-01-12 23:42:38 +01:00
Calmcacil
bf71a7a659 bd sync: 2026-01-12 23:41:27 2026-01-12 23:41:27 +01:00
Calmcacil
f154c7ff69 Standardize TUI formatting and styling across all screens 2026-01-12 23:41:07 +01:00
Calmcacil
0476f1e227 bd sync: 2026-01-12 23:40:16 2026-01-12 23:40:16 +01:00
Calmcacil
1187ae0046 Add screen transition animations for polished UX 2026-01-12 23:40:01 +01:00
Calmcacil
b7ddd54cf6 bd sync: 2026-01-12 23:34:33 2026-01-12 23:34:33 +01:00
Calmcacil
5b8b9b66f5 Add keyboard shortcut discoverability hints on each screen 2026-01-12 23:34:14 +01:00
Calmcacil
f0e26e4a0a bd sync: 2026-01-12 23:24:02 2026-01-12 23:24:02 +01:00
Calmcacil
dd62458515 Add text selection and copy capability to terminal UI 2026-01-12 23:23:48 +01:00
Calmcacil
17f4d52c8a bd sync: 2026-01-12 23:21:29 2026-01-12 23:21:29 +01:00
Calmcacil
4787f3b863 bd sync: 2026-01-12 23:19:12 2026-01-12 23:19:12 +01:00
Calmcacil
3631339f8b Add loading spinners for async operations 2026-01-12 23:18:57 +01:00
Calmcacil
1c03a706d1 bd sync: 2026-01-12 23:12:04 2026-01-12 23:12:04 +01:00
Calmcacil
a3c2828ec2 Integrate theme system across all screens 2026-01-12 23:11:53 +01:00
Calmcacil
d669adc094 bd sync: 2026-01-12 23:07:02 2026-01-12 23:07:02 +01:00
Calmcacil
ea36f03393 bd sync: 2026-01-12 23:05:10 2026-01-12 23:05:10 +01:00
Calmcacil
aadcfbf810 Create dedicated error screen with user-friendly messages and recovery options 2026-01-12 23:04:58 +01:00
Calmcacil
34951221d3 bd sync: 2026-01-12 23:04:56 2026-01-12 23:04:56 +01:00
Calmcacil
68939cdc08 Reduce status refresh interval to 3 seconds and add last updated indicator 2026-01-12 23:04:48 +01:00
Calmcacil
5136484cd2 bd sync: 2026-01-12 23:03:09 2026-01-12 23:03:09 +01:00
Calmcacil
575faa8c68 Improve empty state messages with actionable guidance 2026-01-12 23:03:00 +01:00
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
Calmcacil
707464e61e Fix help screen documentation - incorrect key binding for viewing details 2026-01-12 23:00:16 +01:00
Calmcacil
153c001483 bd sync: 2026-01-12 22:57:05 2026-01-12 22:57:05 +01:00
17 changed files with 1310 additions and 288 deletions

File diff suppressed because one or more lines are too long

View File

@@ -223,6 +223,23 @@ Then run:
sudo ~/wireguard.sh load-clients
```
## Text Selection & Copying
To copy client configurations or other text from the terminal UI:
### Text Selection
- Hold **SHIFT key** while dragging your mouse with the left button
- This bypasses TUI mouse handling and enables your terminal's native text selection
- Then use your terminal's copy shortcut:
- **Linux/WSL**: Ctrl+Shift+C (or terminal-specific shortcut)
- **macOS**: Cmd+C
- **Windows**: Click right (or use terminal copy)
### Copy Buttons (when available)
- Some modals may have explicit copy buttons (e.g., "C" to copy config)
- These work when clipboard API is available (native Linux, macOS, WSL)
- Over SSH requires X11 forwarding (`ssh -X`) for clipboard access
## Client Setup
### Importing the config

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -13,6 +14,16 @@ import (
const version = "0.1.0"
// TickMsg is sent for transition animation frames
type TickMsg time.Time
// Transition settings
const (
transitionDuration = 200 * time.Millisecond
transitionFPS = 60
)
var (
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
@@ -22,10 +33,18 @@ var (
)
type model struct {
transitionFrom screens.Screen
transitionTo screens.Screen
transitionProgress float64
transitionAnimating bool
transitionStartTime time.Time
transitionType screens.TransitionType
transitionDuration time.Duration
currentScreen screens.Screen
previousScreen screens.Screen
quitting bool
initialized bool
errorScreen *screens.ErrorScreen
}
func (m model) Init() tea.Cmd {
@@ -37,6 +56,8 @@ func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle initialization on first update after Init
if !m.initialized {
@@ -45,86 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.currentScreen.Init()
}
// Handle transition animation updates
if m.transitionAnimating {
switch msg.(type) {
case TickMsg:
// Update transition progress
elapsed := time.Since(m.transitionStartTime)
m.transitionProgress = float64(elapsed) / float64(m.transitionDuration)
// Ease-out cubic function for smoother animation
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
if m.transitionProgress >= 1 || easeProgress >= 1 {
// Transition complete
m.transitionAnimating = false
m.currentScreen = m.transitionTo
m.previousScreen = m.transitionFrom
m.transitionFrom = nil
m.transitionTo = nil
return m, nil
}
// Continue animation
return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// During transition, don't process other messages
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
// Only quit on 'q' or ctrl+c when on list screen
// Other screens handle their own navigation keys
if msg.String() == "q" || msg.String() == "ctrl+c" {
if _, ok := m.currentScreen.(*screens.ListScreen); ok {
m.quitting = true
return m, tea.Quit
}
// For other screens, let them handle the key
}
switch msg.String() {
case "?":
// Switch to help screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewHelpScreen(m.previousScreen)
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
return m, cmd
case "l":
// Switch to list screen
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case "a":
// Switch to add screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewAddScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
return m, cmd
}
case screens.ClientSelectedMsg:
// User selected a client - show detail screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewDetailScreen(msg.Client.Client)
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
return m, cmd
case screens.ClientDeletedMsg:
// Client was deleted - show success message and return to list
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.ClientCreatedMsg:
// Client was created - return to list screen
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.RestoreCompletedMsg:
// Restore completed - return to list screen to refresh clients
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.CloseDetailScreenMsg:
// Detail screen closed - go back to previous screen
if m.previousScreen != nil {
m.currentScreen = m.previousScreen
m.previousScreen = nil
return m, m.currentScreen.Init()
cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
return m, cmd
}
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.ErrMsg:
m.previousScreen = m.currentScreen
m.errorScreen = screens.NewErrorScreen(msg.Err)
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
return m, cmd
}
// Pass messages to current screen
if m.currentScreen != nil {
newScreen, cmd := m.currentScreen.Update(msg)
// If screen returns nil, go back to previous screen
if newScreen == nil {
if m.previousScreen != nil {
m.currentScreen = m.previousScreen
m.previousScreen = nil
transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
return m, tea.Batch(cmd, transitionCmd)
}
} else if newScreen != m.currentScreen {
// Screen is switching to a different screen
m.previousScreen = m.currentScreen
m.currentScreen = newScreen
transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
return m, tea.Batch(cmd, transitionCmd)
}
return m, cmd
}
return m, nil
}
}
func (m model) View() string {
func (m model) View() string {
if m.quitting {
return "\nGoodbye!\n"
}
// If transitioning, render the transition animation
if m.transitionAnimating {
return renderTransition(m)
}
if m.currentScreen != nil && m.initialized {
return m.currentScreen.View()
}
@@ -135,6 +184,7 @@ func (m model) View() string {
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
}
}
func main() {
p := tea.NewProgram(model{}, tea.WithAltScreen())
@@ -143,3 +193,193 @@ func main() {
os.Exit(1)
}
}
// startTransition begins a screen transition animation
func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd {
// Initialize new screen
var initCmd tea.Cmd
if toScreen != nil {
initCmd = toScreen.Init()
}
// Set up transition
m.transitionFrom = m.currentScreen
m.transitionTo = toScreen
m.transitionType = ttype
m.transitionDuration = transitionDuration
m.transitionProgress = 0
m.transitionAnimating = true
m.transitionStartTime = time.Now()
// Return initialization command and tick command
tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
if initCmd != nil {
return tea.Batch(initCmd, tickCmd)
}
return tickCmd
}
func renderTransition(m model) string {
if m.transitionFrom == nil || m.transitionTo == nil {
return m.transitionTo.View()
}
fromView := m.transitionFrom.View()
toView := m.transitionTo.View()
// Apply easing to progress
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
switch m.transitionType {
case screens.TransitionFade:
return renderFadeTransition(fromView, toView, easeProgress)
case screens.TransitionSlideLeft:
return renderSlideTransition(fromView, toView, easeProgress, true)
case screens.TransitionSlideRight:
return renderSlideTransition(fromView, toView, easeProgress, false)
default:
return toView
}
}
func renderFadeTransition(fromView, toView string, progress float64) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
maxLines := max(len(fromLines), len(toLines))
for len(fromLines) < maxLines {
fromLines = append(fromLines, "")
}
for len(toLines) < maxLines {
toLines = append(toLines, "")
}
var result []string
for i := 0; i < maxLines; i++ {
threshold := progress * float64(maxLines)
if float64(i) < threshold {
result = append(result, toLines[i])
} else {
result = append(result, fromLines[i])
}
}
return joinLines(result)
}
func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
width := 80
for i := range fromLines {
fromLines[i] = padLine(fromLines[i], width)
}
for i := range toLines {
toLines[i] = padLine(toLines[i], width)
}
offset := int(float64(width) * progress)
var result []string
maxLines := max(len(fromLines), len(toLines))
for i := 0; i < maxLines; i++ {
var fromLine, toLine string
if i < len(fromLines) {
fromLine = fromLines[i]
} else {
fromLine = ""
}
if i < len(toLines) {
toLine = toLines[i]
} else {
toLine = ""
}
if slideLeft {
fromOffset := -offset
toOffset := width - offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
} else {
fromOffset := offset
toOffset := -width + offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
}
}
return joinLines(result)
}
func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string {
result := make([]byte, width)
for i := range result {
result[i] = ' '
}
if fromOffset >= -len(from) && fromOffset < width {
for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ {
if i+fromOffset >= 0 && i+fromOffset < width {
result[i+fromOffset] = from[i]
}
}
}
if toOffset >= -len(to) && toOffset < width {
for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ {
if i+toOffset >= 0 && i+toOffset < width {
result[i+toOffset] = to[i]
}
}
}
return string(result)
}
func splitLines(s string) []string {
lines := make([]string, 0)
current := ""
for _, c := range s {
if c == '\n' {
lines = append(lines, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
func joinLines(lines []string) string {
result := ""
for i, line := range lines {
if i > 0 {
result += "\n"
}
result += line
}
return result
}
func padLine(line string, width int) string {
if len(line) >= width {
return line[:width]
}
return fmt.Sprintf("%-*s", width, line)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -0,0 +1,54 @@
package components
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// BreadcrumbItem represents a single item in the breadcrumb trail
type BreadcrumbItem struct {
Label string
ID string // Optional identifier for the screen
}
// BreadcrumbStyle defines the appearance of breadcrumbs
var (
breadcrumbStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginBottom(1)
breadcrumbSeparatorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
breadcrumbItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
breadcrumbCurrentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
)
// RenderBreadcrumb renders a breadcrumb trail from a slice of items
func RenderBreadcrumb(items []BreadcrumbItem) string {
if len(items) == 0 {
return ""
}
var parts []string
for i, item := range items {
var text string
if i == len(items)-1 {
// Last item - current page
text = breadcrumbCurrentStyle.Render(item.Label)
} else {
// Non-last items - clickable/previous pages
text = breadcrumbItemStyle.Render(item.Label)
}
parts = append(parts, text)
// Add separator if not last item
if i < len(items)-1 {
parts = append(parts, breadcrumbSeparatorStyle.Render(" > "))
}
}
return breadcrumbStyle.Render(strings.Join(parts, ""))
}

View File

@@ -3,6 +3,7 @@ package components
import (
"fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -18,37 +19,12 @@ type DeleteConfirmModel struct {
showErrorMessage bool
}
// Styles
// Local styles for modal
var (
deleteModalStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 3).
Background(lipgloss.Color("235"))
deleteTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
deleteMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(60)
deleteWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true).
MarginTop(1)
deleteInputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(60)
deleteHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
deleteErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true).
MarginTop(1)
deleteSuccessStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true).
MarginTop(1)
modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
)
// NewDeleteConfirm creates a new deletion confirmation modal
@@ -108,12 +84,12 @@ func (m *DeleteConfirmModel) View() string {
matches := m.input.Value() == m.clientName
// Build warning section
warningText := deleteWarningStyle.Render(
warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
)
// Build message section
messageText := deleteMessageStyle.Render(
messageText := modalMessageStyle.Width(60).Render(
fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."),
)
@@ -121,29 +97,29 @@ func (m *DeleteConfirmModel) View() string {
inputSection := lipgloss.JoinVertical(
lipgloss.Left,
"",
deleteInputStyle.Render("Client name:"),
theme.StyleValue.Width(60).Render("Client name:"),
m.input.View(),
)
// Build status section
var statusText string
if matches {
statusText = deleteSuccessStyle.Render("✓ Client name matches. Press Enter to confirm deletion.")
statusText = theme.StyleSuccess.Bold(true).MarginTop(1).Render("✓ Client name matches. Press Enter to confirm deletion.")
} else if m.showErrorMessage {
statusText = deleteErrorStyle.Render("✗ Client name does not match. Please try again.")
statusText = theme.StyleError.Bold(true).MarginTop(1).Render("✗ Client name does not match. Please try again.")
} else if m.input.Value() != "" {
statusText = deleteHelpStyle.Render("Client name does not match yet...")
statusText = modalHelpStyle.Render("Client name does not match yet...")
} else {
statusText = deleteHelpStyle.Render("Type the client name to enable confirmation.")
statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
}
// Build help section
helpText := deleteHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)")
helpText := modalHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)")
// Build modal content
content := lipgloss.JoinVertical(
lipgloss.Left,
deleteTitleStyle.Render("🗑️ Delete Client"),
modalTitleStyle.Render("🗑️ Delete Client"),
"",
warningText,
"",
@@ -155,7 +131,7 @@ func (m *DeleteConfirmModel) View() string {
)
// Apply modal style
modal := deleteModalStyle.Render(content)
modal := modalBaseStyle.Padding(1, 3).Render(content)
// Center modal on screen
modalWidth := lipgloss.Width(modal)
@@ -174,7 +150,7 @@ func (m *DeleteConfirmModel) View() string {
lipgloss.Left, lipgloss.Top,
modal,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
lipgloss.WithWhitespaceForeground(theme.StyleBackground),
)
}

View File

@@ -1,6 +1,9 @@
package components
import (
"fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"strings"
"github.com/charmbracelet/bubbles/textinput"
@@ -29,13 +32,15 @@ type SearchModel struct {
}
// Styles
// Styles (using theme package)
var (
searchBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("235")).
Background(theme.StyleBackground).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
BorderForeground(theme.StyleBorder)
searchPromptStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
@@ -87,6 +92,10 @@ func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "ctrl+u":
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "tab":
m.cycleFilterType()
return m, nil
@@ -140,7 +149,7 @@ func (m *SearchModel) View() string {
helpText := ""
if m.active {
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
helpText = searchHelpStyle.Render(" | Tab: filter | Ctrl+U: clear | Esc: exit")
} else {
helpText = searchHelpStyle.Render(" | /: search")
}
@@ -263,13 +272,13 @@ func (m *SearchModel) HighlightMatches(value string) string {
Bold(true)
before := value[:index]
match := value[index+len(query)]
match := value[index : index+len(query)]
after := value[index+len(query):]
return lipgloss.JoinHorizontal(
lipgloss.Left,
before,
matchStyle.Render(string(match)),
matchStyle.Render(match),
after,
)
}
@@ -295,7 +304,7 @@ func (m *SearchModel) renderCount(count int) string {
Foreground(lipgloss.Color("196")).
Render("No matches")
}
return searchCountStyle.Render(string(rune('0' + count)))
return searchCountStyle.Render(fmt.Sprintf("%d", count))
}
// ClientData represents client data for filtering

View File

@@ -4,8 +4,10 @@ import (
"fmt"
"github.com/calmcacil/wg-admin/internal/config"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/validation"
"github.com/calmcacil/wg-admin/internal/wireguard"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -15,6 +17,8 @@ import (
type AddScreen struct {
form *huh.Form
quitting bool
spinner spinner.Model
isCreating bool
}
// Styles
@@ -26,6 +30,10 @@ var (
addHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
addLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginTop(1)
)
// NewAddScreen creates a new add screen
@@ -68,14 +76,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,
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()
}
@@ -87,10 +105,19 @@ 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
// 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)
@@ -105,6 +132,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)
}
@@ -114,12 +143,31 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the add screen
func (s *AddScreen) View() string {
// Breadcrumb: Clients > Add
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
{Label: "Add", ID: "add"},
})
if s.quitting {
return ""
}
if s.isCreating {
return addLoadingStyle.Render(
lipgloss.JoinVertical(
lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"),
s.spinner.View()+" Creating client configuration, please wait...",
),
)
}
content := lipgloss.JoinVertical(
lipgloss.Left,
breadcrumb,
"",
addTitleStyle.Render("Add New WireGuard Client"),
s.form.View(),
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
@@ -134,7 +182,7 @@ func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
// Create the client via wireguard package
err := wireguard.CreateClient(name, dns, usePSK)
if err != nil {
return errMsg{err: fmt.Errorf("failed to create client: %w", err)}
return ErrMsg{Err: fmt.Errorf("failed to create client: %w", err)}
}
// Return success message

View File

@@ -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,8 @@ 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)
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
dimmedContentStyle = theme.StyleMuted
)
// NewDetailScreen creates a new detail screen for a client
@@ -113,7 +89,7 @@ func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
s.transferTx = msg.transferTx
case tea.KeyMsg:
switch msg.String() {
case "b", "esc":
case "q", "b", "esc":
// Return to list screen - signal parent to switch screens
return nil, nil
case "d":
@@ -139,9 +115,7 @@ func (s *DetailScreen) View() string {
if s.showConfig && s.configDisplay != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
dimmedContent := dimmedContentStyle.Render(content)
// Overlay config display modal
return lipgloss.JoinVertical(
@@ -155,9 +129,7 @@ func (s *DetailScreen) View() string {
if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
dimmedContent := dimmedContentStyle.Render(content)
// Overlay confirmation modal
return lipgloss.JoinVertical(
@@ -172,23 +144,33 @@ func (s *DetailScreen) View() string {
// renderContent renders the main detail screen content
func (s *DetailScreen) renderContent() string {
// Breadcrumb: Clients > Client Name
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
{Label: s.client.Name, ID: "detail"},
})
statusText := s.status
if s.status == wireguard.StatusConnected {
statusText = detailConnectedStyle.Render(s.status)
duration := time.Since(s.lastHandshake)
quality := wireguard.CalculateQuality(duration)
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
} else {
statusText = detailDisconnectedStyle.Render(s.status)
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
}
// Build content
content := lipgloss.JoinVertical(
lipgloss.Left,
detailTitleStyle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
breadcrumb,
"",
theme.StyleTitle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
"",
s.renderField("Status", statusText),
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
"",
detailSectionStyle.Render("WireGuard Configuration"),
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("WireGuard Configuration"),
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
if s.client.HasPSK {
@@ -197,15 +179,15 @@ func (s *DetailScreen) renderContent() string {
return "Not configured"
}())),
"",
detailSectionStyle.Render("Connection Info"),
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("Connection Info"),
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
"",
)
// Add help text
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] View Config • [b] Back")
// Add help text with all keyboard shortcuts
helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
return content
@@ -214,7 +196,7 @@ func (s *DetailScreen) renderContent() string {
// renderField renders a label-value pair
func (s *DetailScreen) renderField(label string, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left,
detailLabelStyle.Render(label),
theme.StyleSubtitle.Width(18).Render(label),
value,
)
}
@@ -242,7 +224,7 @@ func (s *DetailScreen) formatHandshake() string {
func (s *DetailScreen) loadClientStatus() tea.Msg {
peers, err := wireguard.GetAllPeers()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
// Find peer by public key
@@ -271,7 +253,7 @@ func (s *DetailScreen) loadConfig() tea.Cmd {
return func() tea.Msg {
config, err := wireguard.GetClientConfigContent(s.client.Name)
if err != nil {
return errMsg{fmt.Errorf("failed to load client config: %w", err)}
return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
}
// Create or update config display modal
@@ -290,7 +272,7 @@ func (s *DetailScreen) deleteClient() tea.Cmd {
return func() tea.Msg {
err := wireguard.DeleteClient(s.client.Name)
if err != nil {
return errMsg{fmt.Errorf("failed to delete client: %w", err)}
return ErrMsg{fmt.Errorf("failed to delete client: %w", err)}
}
return ClientDeletedMsg{
Name: s.client.Name,

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,8 @@
package screens
import (
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -37,55 +39,48 @@ func (s *HelpScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the help screen
func (s *HelpScreen) View() string {
// Styles
// Breadcrumb: Help
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
// Styles using theme
borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
BorderForeground(theme.StyleBorder).
Padding(1, 2)
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
MarginTop(1).
MarginBottom(0)
keyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
Bold(true).
Width(12)
keyStyle := theme.StyleHelpKey.Width(12)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("250"))
// Header
header := headerStyle.Render("Keyboard Shortcuts")
header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
// Shortcut groups
navigationGroup := categoryStyle.Render("Navigation") + "\n" +
navigationGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Navigation") + "\n" +
keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" +
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
keyStyle.Render("Esc") + descStyle.Render("Go back")
actionsGroup := categoryStyle.Render("Actions") + "\n" +
actionsGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Actions") + "\n" +
keyStyle.Render("a") + descStyle.Render("Add client") + "\n" +
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
keyStyle.Render("D") + descStyle.Render("Client details") + "\n" +
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
keyStyle.Render("l") + descStyle.Render("List view")
otherGroup := categoryStyle.Render("Other") + "\n" +
otherGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Other") + "\n" +
keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" +
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
keyStyle.Render("q") + descStyle.Render("Quit")
copyGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Text Selection & Copy") + "\n" +
keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" +
keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" +
keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)")
// Two-column layout
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
navigationGroup,
@@ -95,18 +90,17 @@ func (s *HelpScreen) View() string {
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
otherGroup,
"",
copyGroup,
)
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
// Footer
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
footer := footerStyle.Render("Press q or Esc to return")
footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
// Combine all
return borderStyle.Render(
return breadcrumb + "\n\n" + borderStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
)
}

View File

@@ -4,6 +4,16 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// TransitionType defines the type of transition animation
type TransitionType int
const (
TransitionNone TransitionType = iota
TransitionFade
TransitionSlideLeft
TransitionSlideRight
)
// Screen represents a UI screen (list, add, detail, etc.)
type Screen interface {
Init() tea.Cmd

View File

@@ -1,8 +1,10 @@
package screens
import (
"fmt"
"sort"
"strings"
"time"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/wireguard"
@@ -11,7 +13,7 @@ import (
"github.com/charmbracelet/lipgloss"
)
const statusRefreshInterval = 10 // seconds
const statusRefreshInterval = 3 // seconds
// ListScreen displays a table of WireGuard clients
type ListScreen struct {
@@ -21,12 +23,14 @@ type ListScreen struct {
filtered []ClientWithStatus
sortedBy string // Column name being sorted by
ascending bool // Sort direction
lastUpdated time.Time
}
// ClientWithStatus wraps a client with its connection status
type ClientWithStatus struct {
Client wireguard.Client
Status string
Quality string
}
// NewListScreen creates a new list screen
@@ -43,9 +47,20 @@ func (s *ListScreen) Init() tea.Cmd {
return tea.Batch(
s.loadClients,
wireguard.Tick(statusRefreshInterval),
ticker(),
)
}
// ticker sends a message every second to update the time display
func ticker() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return timeTickMsg(t)
})
}
// timeTickMsg is sent every second to update the time display
type timeTickMsg time.Time
// Update handles messages for the list screen
func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
@@ -104,8 +119,11 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
}
case clientsLoadedMsg:
s.clients = msg.clients
s.lastUpdated = time.Now()
s.search.SetTotalCount(len(s.clients))
s.applyFilter()
case timeTickMsg:
// Trigger a re-render to update "Last updated" display
case wireguard.StatusTickMsg:
// Refresh status on periodic tick
return s, s.loadClients
@@ -120,38 +138,127 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the list screen
func (s *ListScreen) View() string {
// Breadcrumb: Home
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
})
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 breadcrumb + "\n" + 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().
// Empty search results with helpful tips
return breadcrumb + "\n" + 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")).
Italic(true).
Render("No matching clients found. Try a different search term.")
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")
}
return s.search.View() + "\n" + s.table.View()
// Calculate time since last update
timeAgo := "never"
if !s.lastUpdated.IsZero() {
duration := time.Since(s.lastUpdated)
timeAgo = formatDuration(duration)
}
lastUpdatedText := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo)
// Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
}
// formatDuration returns a human-readable string for the duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%d min ago", int(d.Minutes()))
}
return fmt.Sprintf("%d hr ago", int(d.Hours()))
}
// loadClients loads clients from wireguard config
// loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
// Get status for each client
// Get all peer statuses to retrieve quality information
peerStatuses, err := wireguard.GetAllPeers()
if err != nil {
return ErrMsg{Err: err}
}
// Match clients with their peer status
clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey)
if err != nil {
status = wireguard.StatusDisconnected
status := wireguard.StatusDisconnected
quality := ""
// Find matching peer status
for _, peerStatus := range peerStatuses {
if peerStatus.PublicKey == client.PublicKey {
status = peerStatus.Status
quality = peerStatus.Quality
break
}
}
clientsWithStatus[i] = ClientWithStatus{
Client: client,
Status: status,
Quality: quality,
}
}
@@ -190,13 +297,24 @@ func (s *ListScreen) applyFilter() {
s.buildTable()
}
// formatStatusWithIcon formats the status with a colored circle icon
func (s *ListScreen) formatStatusWithIcon(status string, quality string) string {
if status == wireguard.StatusConnected {
if quality != "" {
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status + " (" + quality + ")"
}
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
@@ -207,11 +325,31 @@ func (s *ListScreen) buildTable() {
var rows []table.Row
for _, cws := range displayClients {
// Apply highlighting based on filter type
name := cws.Client.Name
ipv4 := cws.Client.IPv4
ipv6 := cws.Client.IPv6
status := cws.Status
if s.search.IsActive() {
switch s.search.GetFilterType() {
case components.FilterByName:
name = s.search.HighlightMatches(name)
case components.FilterByIPv4:
ipv4 = s.search.HighlightMatches(ipv4)
case components.FilterByIPv6:
ipv6 = s.search.HighlightMatches(ipv6)
case components.FilterByStatus:
status = s.search.HighlightMatches(status)
}
}
statusText := s.formatStatusWithIcon(status, cws.Quality)
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
cws.Status,
name,
ipv4,
ipv6,
statusText,
}
rows = append(rows, row)
}
@@ -318,7 +456,7 @@ type clientsLoadedMsg struct {
clients []ClientWithStatus
}
// errMsg is sent when an error occurs
type errMsg struct {
err error
// ErrMsg is sent when an error occurs
type ErrMsg struct {
Err error
}

View File

@@ -0,0 +1,220 @@
lastUpdatedText := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo)
// Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
}
// formatDuration returns a human-readable string for the duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%d min ago", int(d.Minutes()))
}
return fmt.Sprintf("%d hr ago", int(d.Hours()))
}
// loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients()
if err != nil {
return ErrMsg{Err: err}
}
// Get status for each client
clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey)
if err != nil {
status = wireguard.StatusDisconnected
}
clientsWithStatus[i] = ClientWithStatus{
Client: client,
Status: status,
}
}
return clientsLoadedMsg{clients: clientsWithStatus}
}
// applyFilter applies the current search filter to clients
func (s *ListScreen) applyFilter() {
// Convert clients to ClientData for filtering
clientData := make([]components.ClientData, len(s.clients))
for i, cws := range s.clients {
clientData[i] = components.ClientData{
Name: cws.Client.Name,
IPv4: cws.Client.IPv4,
IPv6: cws.Client.IPv6,
Status: cws.Status,
}
}
// Filter clients
filteredData := s.search.Filter(clientData)
// Convert back to ClientWithStatus
s.filtered = make([]ClientWithStatus, len(filteredData))
for i, cd := range filteredData {
// Find the matching client
for _, cws := range s.clients {
if cws.Client.Name == cd.Name {
s.filtered[i] = cws
break
}
}
}
// Rebuild table with filtered clients
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: 14},
}
// Use filtered clients if search is active, otherwise use all clients
displayClients := s.filtered
if !s.search.IsActive() {
displayClients = s.clients
}
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,
statusText,
}
rows = append(rows, row)
}
// Sort rows based on current sort settings
s.sortRows(rows)
// Determine table height
tableHeight := len(rows) + 2 // Header + rows
if tableHeight < 5 {
tableHeight = 5
}
s.table = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
// Apply styles
s.setTableStyles()
}
// setTableStyles applies styling to the table
func (s *ListScreen) setTableStyles() {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.table.SetStyles(styles)
}
// sortRows sorts the rows based on the current sort settings
func (s *ListScreen) sortRows(rows []table.Row) {
colIndex := s.getColumnIndex(s.sortedBy)
sort.Slice(rows, func(i, j int) bool {
var valI, valJ string
if colIndex < len(rows[i]) {
valI = rows[i][colIndex]
}
if colIndex < len(rows[j]) {
valJ = rows[j][colIndex]
}
if s.ascending {
return strings.ToLower(valI) < strings.ToLower(valJ)
}
return strings.ToLower(valI) > strings.ToLower(valJ)
})
}
// sortByColumn changes the sort column
func (s *ListScreen) sortByColumn(col string) {
sortedBy := "Name"
switch col {
case "1":
sortedBy = "Name"
case "2":
sortedBy = "IPv4"
case "3":
sortedBy = "IPv6"
case "4":
sortedBy = "Status"
}
// Toggle direction if clicking same column
if s.sortedBy == sortedBy {
s.ascending = !s.ascending
} else {
s.sortedBy = sortedBy
s.ascending = true
}
s.buildTable()
}
// getColumnIndex returns the index of a column by name
func (s *ListScreen) getColumnIndex(name string) int {
switch name {
case "Name":
return 0
case "IPv4":
return 1
case "IPv6":
return 2
case "Status":
return 3
}
return 0
}
// Messages
// clientsLoadedMsg is sent when clients are loaded
type clientsLoadedMsg struct {
clients []ClientWithStatus
}
// ErrMsg is sent when an error occurs
type ErrMsg struct {
Err error
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"strings"
"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"
@@ -54,8 +56,8 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
case configLoadedMsg:
s.configContent = msg.content
s.generateQRCode()
case errMsg:
s.errorMsg = msg.err.Error()
case ErrMsg:
s.errorMsg = msg.Err.Error()
}
return s, nil
@@ -63,20 +65,23 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the QR screen
func (s *QRScreen) View() string {
// Breadcrumb: Clients > Client > QR Code
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: s.clientName, ID: "detail"}, {Label: "QR Code", ID: "qr"}})
if s.errorMsg != "" {
return s.renderError()
}
if s.qrCode == "" {
return "Loading QR code..."
}
return s.renderQR()
return breadcrumb + "\n" + s.renderQR()
}
// loadConfig loads the client configuration
func (s *QRScreen) loadConfig() tea.Msg {
content, err := wireguard.GetClientConfigContent(s.clientName)
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
return configLoadedMsg{content: content}
}
@@ -98,39 +103,22 @@ func (s *QRScreen) generateQRCode() {
// renderQR renders the QR code with styling
func (s *QRScreen) renderQR() string {
styleTitle := lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
styleHelp := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
styleQR := lipgloss.NewStyle().
MarginLeft(2)
title := styleTitle.Render(fmt.Sprintf("QR Code: %s", s.clientName))
title := theme.StyleTitle.MarginBottom(1).Render(fmt.Sprintf("QR Code: %s", s.clientName))
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + styleHelp.Render(help)
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
}
// renderError renders an error message
func (s *QRScreen) renderError() string {
styleError := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
styleHelp := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
title := styleError.Render("Error")
title := theme.StyleError.Bold(true).Render("Error")
message := s.errorMsg
help := "Press [q/Escape] to return"
return title + "\n\n" + message + "\n" + styleHelp.Render(help)
return title + "\n\n" + message + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
}
// Messages

View File

@@ -6,6 +6,8 @@ import (
"github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -22,31 +24,21 @@ type RestoreScreen struct {
restoreError error
restoreSuccess bool
message string
spinner spinner.Model
}
// Styles
var (
restoreTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
restoreHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
restoreSuccessStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
restoreErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
restoreInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// No local styles - all use theme package
// NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen {
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &RestoreScreen{
showConfirm: false,
spinner: s,
}
}
@@ -59,6 +51,12 @@ func (s *RestoreScreen) Init() tea.Cmd {
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// If restoring, only update spinner
if s.isRestoring && !s.showConfirm {
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
@@ -69,7 +67,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// User confirmed restore
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
// User cancelled - close modal
s.showConfirm = false
@@ -82,7 +80,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
}
@@ -125,14 +123,14 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
}
}
}
case restoreCompletedMsg:
case RestoreCompletedMsg:
s.isRestoring = false
if msg.err != nil {
s.restoreError = msg.err
s.message = fmt.Sprintf("Restore failed: %v", msg.err)
if msg.Err != nil {
s.restoreError = msg.Err
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
} else {
s.restoreSuccess = true
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.safetyBackupPath)
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
}
}
@@ -164,9 +162,14 @@ func (s *RestoreScreen) View() string {
// renderContent renders the main restore screen content
func (s *RestoreScreen) renderContent() string {
// Breadcrumb: Clients > Restore
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
var content strings.Builder
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration"))
content.WriteString(breadcrumb)
content.WriteString("\n")
content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration"))
content.WriteString("\n\n")
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
@@ -175,23 +178,23 @@ func (s *RestoreScreen) renderContent() string {
}
if s.isRestoring {
content.WriteString("Restoring from backup, please wait...")
content.WriteString(theme.StyleTitle.Render(s.spinner.View() + " Restoring from backup, please wait..."))
return content.String()
}
if s.restoreSuccess {
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message))
content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
content.WriteString("\n\n")
content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list."))
content.WriteString(theme.StyleMuted.Render("Press 'q' to return to client list."))
return content.String()
}
if s.restoreError != nil {
content.WriteString(restoreErrorStyle.Render("✗ " + s.message))
content.WriteString(theme.StyleError.Render("✗ " + s.message))
content.WriteString("\n\n")
content.WriteString(s.table.View())
content.WriteString("\n\n")
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String()
}
@@ -201,7 +204,7 @@ func (s *RestoreScreen) renderContent() string {
// Show selected backup details
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
content.WriteString(restoreInfoStyle.Render(
content.WriteString(theme.StyleMuted.Render(
fmt.Sprintf(
"Selected: %s (%s) - %s\nSize: %s",
s.selectedBackup.Operation,
@@ -213,7 +216,7 @@ func (s *RestoreScreen) renderContent() string {
content.WriteString("\n")
}
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String()
}
@@ -222,7 +225,7 @@ func (s *RestoreScreen) renderContent() string {
func (s *RestoreScreen) loadBackups() tea.Msg {
backups, err := backup.ListBackups()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
return backupsLoadedMsg{backups: backups}
}
@@ -261,15 +264,8 @@ func (s *RestoreScreen) buildTable() {
// setTableStyles applies styling to the table
func (s *RestoreScreen) setTableStyles() {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
styles.Header = theme.StyleTableHeader
styles.Selected = theme.StyleTableSelected
s.table.SetStyles(styles)
}

View File

@@ -38,6 +38,12 @@ var (
StyleTitle lipgloss.Style
StyleSubtitle lipgloss.Style
StyleHelpKey lipgloss.Style
StyleValue lipgloss.Style
StyleDimmed lipgloss.Style
StyleTableHeader lipgloss.Style
StyleTableSelected lipgloss.Style
StyleBorder lipgloss.Color
StyleBackground lipgloss.Color
)
// DefaultTheme is the standard blue-based theme
@@ -176,8 +182,83 @@ func ApplyTheme(theme *Theme) {
StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary).
Bold(true)
// Value style for content values
StyleValue = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Dimmed style for overlay content
StyleDimmed = lipgloss.NewStyle().
Foreground(theme.Scheme.Muted)
// Table header style
StyleTableHeader = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
// Table selected style
StyleTableSelected = lipgloss.NewStyle().
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
// Border color
StyleBorder = lipgloss.Color("240")
// Background color for modals
StyleBackground = lipgloss.Color("235")
}
// Modal styles
var (
// ModalBaseStyle is the base style for all modals
ModalBaseStyle = func() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 2).
Background(StyleBackground)
}
// ModalTitleStyle is the style for modal titles
ModalTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
// ModalMessageStyle is the style for modal messages
ModalMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(50)
// ModalHelpStyle is the style for modal help text
ModalHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
// ModalSelectedStyle is the style for selected modal options
ModalSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("57")).
Bold(true).
Underline(true)
// ModalUnselectedStyle is the style for unselected modal options
ModalUnselectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
)
// Status icon styles
var (
// StatusConnectedStyle is the style for connected status icons
StatusConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46"))
// StatusDisconnectedStyle is the style for disconnected status icons
StatusDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
)
// GetThemeNames returns a list of available theme names
func GetThemeNames() []string {
names := make([]string, 0, len(ThemeRegistry))

View File

@@ -15,6 +15,15 @@ const (
StatusConnected = "Connected"
// StatusDisconnected indicates a peer is not connected
StatusDisconnected = "Disconnected"
// QualityExcellent indicates handshake was very recent (< 30s)
QualityExcellent = "Excellent"
// QualityGood indicates handshake was recent (< 2m)
QualityGood = "Good"
// QualityFair indicates handshake was acceptable (< 5m)
QualityFair = "Fair"
// QualityPoor indicates handshake was old (> 5m)
QualityPoor = "Poor"
)
// PeerStatus represents the status of a WireGuard peer
@@ -26,6 +35,7 @@ type PeerStatus struct {
TransferRx string `json:"transfer_rx"`
TransferTx string `json:"transfer_tx"`
Status string `json:"status"` // "Connected" or "Disconnected"
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
}
// GetClientStatus checks if a specific client is connected
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
return peers
}
// CalculateQuality returns the connection quality based on handshake time
func CalculateQuality(timeSinceHandshake time.Duration) string {
if timeSinceHandshake < 30*time.Second {
return QualityExcellent
}
if timeSinceHandshake < 2*time.Minute {
return QualityGood
}
if timeSinceHandshake < 5*time.Minute {
return QualityFair
}
return QualityPoor
}
// finalizePeerStatus determines the peer's status based on handshake time
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
peer.TransferRx = ""
@@ -140,13 +164,16 @@ func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) Pee
}
}
// Determine status based on handshake
// Determine status and quality based on handshake
if handshake != "" {
peer.LatestHandshake = parseHandshake(handshake)
timeSinceHandshake := time.Since(peer.LatestHandshake)
// Peer is considered connected if handshake is recent (within 5 minutes)
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
if time.Since(peer.LatestHandshake) < 5*time.Minute {
if timeSinceHandshake < 5*time.Minute {
peer.Status = StatusConnected
peer.Quality = CalculateQuality(timeSinceHandshake)
} else {
peer.Status = StatusDisconnected
}