Compare commits

..

30 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
Calmcacil
23d1cae737 bd sync: 2026-01-12 22:54:34 2026-01-12 22:54:34 +01:00
Calmcacil
3f60ab8355 fix: add background dimming to config display modal
Config display modal now dims background content before showing,
following the same pattern as delete confirmation modal.
This provides visual consistency across all modals and makes
it clear to users when a modal is active.

Fixes: wg-admin-bfe
2026-01-12 22:44:11 +01:00
17 changed files with 1320 additions and 284 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":
@@ -137,16 +113,23 @@ func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
func (s *DetailScreen) View() string {
// Handle config display modal
if s.showConfig && s.configDisplay != nil {
return s.configDisplay.View()
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := dimmedContentStyle.Render(content)
// Overlay config display modal
return lipgloss.JoinVertical(
lipgloss.Left,
dimmedContent,
s.configDisplay.View(),
)
}
// Handle confirmation modal
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(
@@ -161,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 {
@@ -186,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
@@ -203,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,
)
}
@@ -231,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
@@ -260,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
@@ -279,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
}