Compare commits
22 Commits
5136484cd2
...
go-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c0da13a19 | ||
|
|
99b9dc17db | ||
|
|
6629598574 | ||
|
|
50321a8471 | ||
|
|
bf71a7a659 | ||
|
|
f154c7ff69 | ||
|
|
0476f1e227 | ||
|
|
1187ae0046 | ||
|
|
b7ddd54cf6 | ||
|
|
5b8b9b66f5 | ||
|
|
f0e26e4a0a | ||
|
|
dd62458515 | ||
|
|
17f4d52c8a | ||
|
|
4787f3b863 | ||
|
|
3631339f8b | ||
|
|
1c03a706d1 | ||
|
|
a3c2828ec2 | ||
|
|
d669adc094 | ||
|
|
ea36f03393 | ||
|
|
aadcfbf810 | ||
|
|
34951221d3 | ||
|
|
68939cdc08 |
File diff suppressed because one or more lines are too long
17
README.md
17
README.md
@@ -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
|
||||
|
||||
@@ -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,6 +33,13 @@ 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
|
||||
@@ -38,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 {
|
||||
@@ -46,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()
|
||||
}
|
||||
@@ -136,6 +184,7 @@ func (m model) View() string {
|
||||
|
||||
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(model{}, tea.WithAltScreen())
|
||||
@@ -144,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
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ var (
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginBottom(1)
|
||||
breadcrumbSeparatorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
Foreground(lipgloss.Color("240"))
|
||||
breadcrumbItemStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
breadcrumbCurrentStyle = lipgloss.NewStyle().
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -29,7 +30,7 @@ var (
|
||||
addHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
loadingStyle = lipgloss.NewStyle().
|
||||
addLoadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginTop(1)
|
||||
@@ -142,12 +143,19 @@ 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 loadingStyle.Render(
|
||||
return addLoadingStyle.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
addTitleStyle.Render("Add New WireGuard Client"),
|
||||
@@ -158,6 +166,8 @@ func (s *AddScreen) View() string {
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
breadcrumb,
|
||||
"",
|
||||
addTitleStyle.Render("Add New WireGuard Client"),
|
||||
s.form.View(),
|
||||
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
||||
@@ -172,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
|
||||
|
||||
@@ -26,11 +26,8 @@ type DetailScreen struct {
|
||||
|
||||
// Styles
|
||||
var (
|
||||
detailTitleStyle = lipgloss.NewStyle().Bold(true).MarginTop(0)
|
||||
detailSectionStyle = lipgloss.NewStyle().Bold(true).MarginTop(1)
|
||||
detailLabelStyle = lipgloss.NewStyle().Width(18)
|
||||
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||
dimmedContentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
dimmedContentStyle = theme.StyleMuted
|
||||
)
|
||||
|
||||
// NewDetailScreen creates a new detail screen for a client
|
||||
@@ -118,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(
|
||||
@@ -134,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(
|
||||
@@ -151,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 {
|
||||
@@ -176,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 • [q/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
|
||||
@@ -193,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,
|
||||
)
|
||||
}
|
||||
@@ -221,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
|
||||
@@ -250,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
|
||||
@@ -269,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,
|
||||
|
||||
@@ -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,54 +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("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,
|
||||
@@ -94,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,8 +28,9 @@ type ListScreen struct {
|
||||
|
||||
// ClientWithStatus wraps a client with its connection status
|
||||
type ClientWithStatus struct {
|
||||
Client wireguard.Client
|
||||
Status string
|
||||
Client wireguard.Client
|
||||
Status string
|
||||
Quality string
|
||||
}
|
||||
|
||||
// NewListScreen creates a new list screen
|
||||
@@ -137,9 +138,13 @@ 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 {
|
||||
// Empty state with helpful guidance
|
||||
return s.search.View() + "\n\n" +
|
||||
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true).
|
||||
@@ -164,7 +169,7 @@ func (s *ListScreen) View() string {
|
||||
// Check if there are no matches
|
||||
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
||||
// Empty search results with helpful tips
|
||||
return s.search.View() + "\n\n" +
|
||||
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true).
|
||||
@@ -200,7 +205,13 @@ func (s *ListScreen) View() string {
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Render("Last updated: " + timeAgo)
|
||||
|
||||
return s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText
|
||||
// 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
|
||||
@@ -214,23 +225,40 @@ func formatDuration(d time.Duration) string {
|
||||
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,
|
||||
Client: client,
|
||||
Status: status,
|
||||
Quality: quality,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,8 +298,11 @@ func (s *ListScreen) applyFilter() {
|
||||
}
|
||||
|
||||
// formatStatusWithIcon formats the status with a colored circle icon
|
||||
func (s *ListScreen) formatStatusWithIcon(status string) string {
|
||||
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
|
||||
@@ -294,11 +325,30 @@ func (s *ListScreen) buildTable() {
|
||||
|
||||
var rows []table.Row
|
||||
for _, cws := range displayClients {
|
||||
statusText := s.formatStatusWithIcon(cws.Status)
|
||||
// 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,
|
||||
name,
|
||||
ipv4,
|
||||
ipv6,
|
||||
statusText,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
|
||||
220
internal/tui/screens/list_go_append.txt
Normal file
220
internal/tui/screens/list_go_append.txt
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -26,32 +27,18 @@ type RestoreScreen struct {
|
||||
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)
|
||||
loadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true)
|
||||
)
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,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)
|
||||
@@ -74,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
|
||||
@@ -87,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,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 == "" {
|
||||
@@ -180,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()
|
||||
}
|
||||
|
||||
@@ -206,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,
|
||||
@@ -218,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()
|
||||
}
|
||||
@@ -227,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}
|
||||
}
|
||||
@@ -266,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,20 @@ var (
|
||||
once sync.Once
|
||||
|
||||
// Global styles that can be used throughout the application
|
||||
StylePrimary lipgloss.Style
|
||||
StyleSuccess lipgloss.Style
|
||||
StyleWarning lipgloss.Style
|
||||
StyleError lipgloss.Style
|
||||
StyleMuted lipgloss.Style
|
||||
StyleTitle lipgloss.Style
|
||||
StyleSubtitle lipgloss.Style
|
||||
StyleHelpKey lipgloss.Style
|
||||
StylePrimary lipgloss.Style
|
||||
StyleSuccess lipgloss.Style
|
||||
StyleWarning lipgloss.Style
|
||||
StyleError lipgloss.Style
|
||||
StyleMuted lipgloss.Style
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
@@ -25,7 +34,8 @@ type PeerStatus struct {
|
||||
LatestHandshake time.Time `json:"latest_handshake"`
|
||||
TransferRx string `json:"transfer_rx"`
|
||||
TransferTx string `json:"transfer_tx"`
|
||||
Status string `json:"status"` // "Connected" or "Disconnected"
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user