Compare commits
26 Commits
707464e61e
...
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 | ||
|
|
5136484cd2 | ||
|
|
575faa8c68 | ||
|
|
8b49fbfd3a | ||
|
|
78a100112c |
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
|
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
|
## Client Setup
|
||||||
|
|
||||||
### Importing the config
|
### Importing the config
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -13,6 +14,16 @@ import (
|
|||||||
|
|
||||||
const version = "0.1.0"
|
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 (
|
var (
|
||||||
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
|
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
|
||||||
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
@@ -22,10 +33,18 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
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
|
currentScreen screens.Screen
|
||||||
previousScreen screens.Screen
|
previousScreen screens.Screen
|
||||||
quitting bool
|
quitting bool
|
||||||
initialized bool
|
initialized bool
|
||||||
|
errorScreen *screens.ErrorScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
@@ -37,6 +56,8 @@ func (m model) Init() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
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
|
// Handle initialization on first update after Init
|
||||||
if !m.initialized {
|
if !m.initialized {
|
||||||
@@ -45,86 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.currentScreen.Init()
|
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) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Only quit on 'q' or ctrl+c when on list screen
|
// 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 msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||||
if _, ok := m.currentScreen.(*screens.ListScreen); ok {
|
if _, ok := m.currentScreen.(*screens.ListScreen); ok {
|
||||||
m.quitting = true
|
m.quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
// For other screens, let them handle the key
|
|
||||||
}
|
}
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "?":
|
case "?":
|
||||||
// Switch to help screen
|
cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
|
||||||
m.previousScreen = m.currentScreen
|
return m, cmd
|
||||||
m.currentScreen = screens.NewHelpScreen(m.previousScreen)
|
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case "l":
|
case "l":
|
||||||
// Switch to list screen
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
m.currentScreen = screens.NewListScreen()
|
return m, cmd
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case "a":
|
case "a":
|
||||||
// Switch to add screen
|
cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
|
||||||
m.previousScreen = m.currentScreen
|
return m, cmd
|
||||||
m.currentScreen = screens.NewAddScreen()
|
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
}
|
}
|
||||||
case screens.ClientSelectedMsg:
|
case screens.ClientSelectedMsg:
|
||||||
// User selected a client - show detail screen
|
cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
|
||||||
m.previousScreen = m.currentScreen
|
return m, cmd
|
||||||
m.currentScreen = screens.NewDetailScreen(msg.Client.Client)
|
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case screens.ClientDeletedMsg:
|
case screens.ClientDeletedMsg:
|
||||||
// Client was deleted - show success message and return to list
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
m.currentScreen = screens.NewListScreen()
|
return m, cmd
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case screens.ClientCreatedMsg:
|
case screens.ClientCreatedMsg:
|
||||||
// Client was created - return to list screen
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
m.currentScreen = screens.NewListScreen()
|
return m, cmd
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case screens.RestoreCompletedMsg:
|
case screens.RestoreCompletedMsg:
|
||||||
// Restore completed - return to list screen to refresh clients
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
m.currentScreen = screens.NewListScreen()
|
return m, cmd
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
case screens.CloseDetailScreenMsg:
|
case screens.CloseDetailScreenMsg:
|
||||||
// Detail screen closed - go back to previous screen
|
|
||||||
if m.previousScreen != nil {
|
if m.previousScreen != nil {
|
||||||
m.currentScreen = m.previousScreen
|
cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||||
m.previousScreen = nil
|
return m, cmd
|
||||||
return m, m.currentScreen.Init()
|
|
||||||
}
|
}
|
||||||
m.currentScreen = screens.NewListScreen()
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
return m, m.currentScreen.Init()
|
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
|
// Pass messages to current screen
|
||||||
if m.currentScreen != nil {
|
if m.currentScreen != nil {
|
||||||
newScreen, cmd := m.currentScreen.Update(msg)
|
newScreen, cmd := m.currentScreen.Update(msg)
|
||||||
// If screen returns nil, go back to previous screen
|
|
||||||
if newScreen == nil {
|
if newScreen == nil {
|
||||||
if m.previousScreen != nil {
|
if m.previousScreen != nil {
|
||||||
m.currentScreen = m.previousScreen
|
transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||||
m.previousScreen = nil
|
return m, tea.Batch(cmd, transitionCmd)
|
||||||
}
|
}
|
||||||
} else if newScreen != m.currentScreen {
|
} else if newScreen != m.currentScreen {
|
||||||
// Screen is switching to a different screen
|
|
||||||
m.previousScreen = m.currentScreen
|
m.previousScreen = m.currentScreen
|
||||||
m.currentScreen = newScreen
|
transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
|
||||||
|
return m, tea.Batch(cmd, transitionCmd)
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.quitting {
|
if m.quitting {
|
||||||
return "\nGoodbye!\n"
|
return "\nGoodbye!\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If transitioning, render the transition animation
|
||||||
|
if m.transitionAnimating {
|
||||||
|
return renderTransition(m)
|
||||||
|
}
|
||||||
|
|
||||||
if m.currentScreen != nil && m.initialized {
|
if m.currentScreen != nil && m.initialized {
|
||||||
return m.currentScreen.View()
|
return m.currentScreen.View()
|
||||||
}
|
}
|
||||||
@@ -135,6 +184,7 @@ func (m model) View() string {
|
|||||||
|
|
||||||
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
|
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
p := tea.NewProgram(model{}, tea.WithAltScreen())
|
p := tea.NewProgram(model{}, tea.WithAltScreen())
|
||||||
@@ -143,3 +193,193 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package components
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -18,37 +19,12 @@ type DeleteConfirmModel struct {
|
|||||||
showErrorMessage bool
|
showErrorMessage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// Local styles for modal
|
||||||
var (
|
var (
|
||||||
deleteModalStyle = lipgloss.NewStyle().
|
modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
|
||||||
Border(lipgloss.RoundedBorder()).
|
modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
||||||
BorderForeground(lipgloss.Color("196")).
|
modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||||
Padding(1, 3).
|
modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDeleteConfirm creates a new deletion confirmation modal
|
// NewDeleteConfirm creates a new deletion confirmation modal
|
||||||
@@ -108,12 +84,12 @@ func (m *DeleteConfirmModel) View() string {
|
|||||||
matches := m.input.Value() == m.clientName
|
matches := m.input.Value() == m.clientName
|
||||||
|
|
||||||
// Build warning section
|
// Build warning section
|
||||||
warningText := deleteWarningStyle.Render(
|
warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
|
||||||
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
|
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build message section
|
// 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."),
|
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(
|
inputSection := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
"",
|
"",
|
||||||
deleteInputStyle.Render("Client name:"),
|
theme.StyleValue.Width(60).Render("Client name:"),
|
||||||
m.input.View(),
|
m.input.View(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build status section
|
// Build status section
|
||||||
var statusText string
|
var statusText string
|
||||||
if matches {
|
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 {
|
} 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() != "" {
|
} else if m.input.Value() != "" {
|
||||||
statusText = deleteHelpStyle.Render("Client name does not match yet...")
|
statusText = modalHelpStyle.Render("Client name does not match yet...")
|
||||||
} else {
|
} else {
|
||||||
statusText = deleteHelpStyle.Render("Type the client name to enable confirmation.")
|
statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build help section
|
// 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
|
// Build modal content
|
||||||
content := lipgloss.JoinVertical(
|
content := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
deleteTitleStyle.Render("🗑️ Delete Client"),
|
modalTitleStyle.Render("🗑️ Delete Client"),
|
||||||
"",
|
"",
|
||||||
warningText,
|
warningText,
|
||||||
"",
|
"",
|
||||||
@@ -155,7 +131,7 @@ func (m *DeleteConfirmModel) View() string {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Apply modal style
|
// Apply modal style
|
||||||
modal := deleteModalStyle.Render(content)
|
modal := modalBaseStyle.Padding(1, 3).Render(content)
|
||||||
|
|
||||||
// Center modal on screen
|
// Center modal on screen
|
||||||
modalWidth := lipgloss.Width(modal)
|
modalWidth := lipgloss.Width(modal)
|
||||||
@@ -174,7 +150,7 @@ func (m *DeleteConfirmModel) View() string {
|
|||||||
lipgloss.Left, lipgloss.Top,
|
lipgloss.Left, lipgloss.Top,
|
||||||
modal,
|
modal,
|
||||||
lipgloss.WithWhitespaceChars(" "),
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
|
lipgloss.WithWhitespaceForeground(theme.StyleBackground),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -29,13 +32,15 @@ type SearchModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
|
|
||||||
|
// Styles (using theme package)
|
||||||
var (
|
var (
|
||||||
searchBarStyle = lipgloss.NewStyle().
|
searchBarStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("255")).
|
Foreground(lipgloss.Color("255")).
|
||||||
Background(lipgloss.Color("235")).
|
Background(theme.StyleBackground).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("240"))
|
BorderForeground(theme.StyleBorder)
|
||||||
searchPromptStyle = lipgloss.NewStyle().
|
searchPromptStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("226")).
|
Foreground(lipgloss.Color("226")).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
@@ -87,6 +92,10 @@ func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
|
|||||||
m.input.Reset()
|
m.input.Reset()
|
||||||
m.matchCount = m.totalCount
|
m.matchCount = m.totalCount
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case "ctrl+u":
|
||||||
|
m.input.Reset()
|
||||||
|
m.matchCount = m.totalCount
|
||||||
|
return m, nil
|
||||||
case "tab":
|
case "tab":
|
||||||
m.cycleFilterType()
|
m.cycleFilterType()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -140,7 +149,7 @@ func (m *SearchModel) View() string {
|
|||||||
|
|
||||||
helpText := ""
|
helpText := ""
|
||||||
if m.active {
|
if m.active {
|
||||||
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
|
helpText = searchHelpStyle.Render(" | Tab: filter | Ctrl+U: clear | Esc: exit")
|
||||||
} else {
|
} else {
|
||||||
helpText = searchHelpStyle.Render(" | /: search")
|
helpText = searchHelpStyle.Render(" | /: search")
|
||||||
}
|
}
|
||||||
@@ -263,13 +272,13 @@ func (m *SearchModel) HighlightMatches(value string) string {
|
|||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
before := value[:index]
|
before := value[:index]
|
||||||
match := value[index+len(query)]
|
match := value[index : index+len(query)]
|
||||||
after := value[index+len(query):]
|
after := value[index+len(query):]
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(
|
return lipgloss.JoinHorizontal(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
before,
|
before,
|
||||||
matchStyle.Render(string(match)),
|
matchStyle.Render(match),
|
||||||
after,
|
after,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -295,7 +304,7 @@ func (m *SearchModel) renderCount(count int) string {
|
|||||||
Foreground(lipgloss.Color("196")).
|
Foreground(lipgloss.Color("196")).
|
||||||
Render("No matches")
|
Render("No matches")
|
||||||
}
|
}
|
||||||
return searchCountStyle.Render(string(rune('0' + count)))
|
return searchCountStyle.Render(fmt.Sprintf("%d", count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientData represents client data for filtering
|
// ClientData represents client data for filtering
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/calmcacil/wg-admin/internal/config"
|
"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/validation"
|
||||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
@@ -29,6 +30,10 @@ var (
|
|||||||
addHelpStyle = lipgloss.NewStyle().
|
addHelpStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("241")).
|
Foreground(lipgloss.Color("241")).
|
||||||
MarginTop(1)
|
MarginTop(1)
|
||||||
|
addLoadingStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("62")).
|
||||||
|
Bold(true).
|
||||||
|
MarginTop(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewAddScreen creates a new add screen
|
// NewAddScreen creates a new add screen
|
||||||
@@ -71,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{
|
return &AddScreen{
|
||||||
form: form,
|
form: form,
|
||||||
quitting: false,
|
quitting: false,
|
||||||
|
spinner: s,
|
||||||
|
isCreating: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the add screen
|
// Init initializes the add screen
|
||||||
func (s *AddScreen) Init() tea.Cmd {
|
func (s *AddScreen) Init() tea.Cmd {
|
||||||
|
if s.isCreating {
|
||||||
|
return s.spinner.Tick
|
||||||
|
}
|
||||||
return s.form.Init()
|
return s.form.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +105,19 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c", "esc":
|
case "q", "ctrl+c", "esc":
|
||||||
// Cancel and return to list
|
// Cancel and return to list (only if not creating)
|
||||||
|
if !s.isCreating {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If creating, update spinner instead of form
|
||||||
|
if s.isCreating {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.spinner, cmd = s.spinner.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
// Update the form
|
// Update the form
|
||||||
form, cmd := s.form.Update(msg)
|
form, cmd := s.form.Update(msg)
|
||||||
@@ -108,6 +132,8 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
dns := s.form.GetString("dns")
|
dns := s.form.GetString("dns")
|
||||||
usePSK := s.form.GetBool("use_psk")
|
usePSK := s.form.GetBool("use_psk")
|
||||||
|
|
||||||
|
// Set creating state and start spinner
|
||||||
|
s.isCreating = true
|
||||||
// Create the client
|
// Create the client
|
||||||
return s, s.createClient(name, dns, usePSK)
|
return s, s.createClient(name, dns, usePSK)
|
||||||
}
|
}
|
||||||
@@ -117,12 +143,31 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
|
|
||||||
// View renders the add screen
|
// View renders the add screen
|
||||||
func (s *AddScreen) View() string {
|
func (s *AddScreen) View() string {
|
||||||
|
// Breadcrumb: Clients > Add
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||||
|
{Label: "Clients", ID: "list"},
|
||||||
|
{Label: "Add", ID: "add"},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if s.quitting {
|
if s.quitting {
|
||||||
return ""
|
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(
|
content := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
|
breadcrumb,
|
||||||
|
"",
|
||||||
addTitleStyle.Render("Add New WireGuard Client"),
|
addTitleStyle.Render("Add New WireGuard Client"),
|
||||||
s.form.View(),
|
s.form.View(),
|
||||||
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
||||||
@@ -137,7 +182,7 @@ func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
|
|||||||
// Create the client via wireguard package
|
// Create the client via wireguard package
|
||||||
err := wireguard.CreateClient(name, dns, usePSK)
|
err := wireguard.CreateClient(name, dns, usePSK)
|
||||||
if err != nil {
|
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
|
// Return success message
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -25,33 +26,8 @@ type DetailScreen struct {
|
|||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
var (
|
var (
|
||||||
detailTitleStyle = lipgloss.NewStyle().
|
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||||
Foreground(lipgloss.Color("62")).
|
dimmedContentStyle = theme.StyleMuted
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDetailScreen creates a new detail screen for a client
|
// NewDetailScreen creates a new detail screen for a client
|
||||||
@@ -139,9 +115,7 @@ func (s *DetailScreen) View() string {
|
|||||||
if s.showConfig && s.configDisplay != nil {
|
if s.showConfig && s.configDisplay != nil {
|
||||||
// Render underlying content dimmed
|
// Render underlying content dimmed
|
||||||
content := s.renderContent()
|
content := s.renderContent()
|
||||||
dimmedContent := lipgloss.NewStyle().
|
dimmedContent := dimmedContentStyle.Render(content)
|
||||||
Foreground(lipgloss.Color("244")).
|
|
||||||
Render(content)
|
|
||||||
|
|
||||||
// Overlay config display modal
|
// Overlay config display modal
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
@@ -155,9 +129,7 @@ func (s *DetailScreen) View() string {
|
|||||||
if s.showConfirm && s.confirmModal != nil {
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
// Render underlying content dimmed
|
// Render underlying content dimmed
|
||||||
content := s.renderContent()
|
content := s.renderContent()
|
||||||
dimmedContent := lipgloss.NewStyle().
|
dimmedContent := dimmedContentStyle.Render(content)
|
||||||
Foreground(lipgloss.Color("244")).
|
|
||||||
Render(content)
|
|
||||||
|
|
||||||
// Overlay confirmation modal
|
// Overlay confirmation modal
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
@@ -172,23 +144,33 @@ func (s *DetailScreen) View() string {
|
|||||||
|
|
||||||
// renderContent renders the main detail screen content
|
// renderContent renders the main detail screen content
|
||||||
func (s *DetailScreen) renderContent() string {
|
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
|
statusText := s.status
|
||||||
if s.status == wireguard.StatusConnected {
|
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 {
|
} else {
|
||||||
statusText = detailDisconnectedStyle.Render("● " + s.status)
|
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build content
|
// Build content
|
||||||
content := lipgloss.JoinVertical(
|
content := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
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("Status", statusText),
|
||||||
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
|
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
|
||||||
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
|
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("Public Key", detailValueStyle.Render(s.client.PublicKey)),
|
||||||
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
|
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
|
||||||
if s.client.HasPSK {
|
if s.client.HasPSK {
|
||||||
@@ -197,15 +179,15 @@ func (s *DetailScreen) renderContent() string {
|
|||||||
return "Not configured"
|
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("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
|
||||||
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
|
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
|
||||||
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
|
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add help text
|
// Add help text with all keyboard shortcuts
|
||||||
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] View Config • [q/b] Back")
|
helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
|
||||||
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
@@ -214,7 +196,7 @@ func (s *DetailScreen) renderContent() string {
|
|||||||
// renderField renders a label-value pair
|
// renderField renders a label-value pair
|
||||||
func (s *DetailScreen) renderField(label string, value string) string {
|
func (s *DetailScreen) renderField(label string, value string) string {
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left,
|
return lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
detailLabelStyle.Render(label),
|
theme.StyleSubtitle.Width(18).Render(label),
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -242,7 +224,7 @@ func (s *DetailScreen) formatHandshake() string {
|
|||||||
func (s *DetailScreen) loadClientStatus() tea.Msg {
|
func (s *DetailScreen) loadClientStatus() tea.Msg {
|
||||||
peers, err := wireguard.GetAllPeers()
|
peers, err := wireguard.GetAllPeers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err: err}
|
return ErrMsg{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find peer by public key
|
// Find peer by public key
|
||||||
@@ -271,7 +253,7 @@ func (s *DetailScreen) loadConfig() tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
config, err := wireguard.GetClientConfigContent(s.client.Name)
|
config, err := wireguard.GetClientConfigContent(s.client.Name)
|
||||||
if err != nil {
|
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
|
// Create or update config display modal
|
||||||
@@ -290,7 +272,7 @@ func (s *DetailScreen) deleteClient() tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
err := wireguard.DeleteClient(s.client.Name)
|
err := wireguard.DeleteClient(s.client.Name)
|
||||||
if err != nil {
|
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{
|
return ClientDeletedMsg{
|
||||||
Name: s.client.Name,
|
Name: s.client.Name,
|
||||||
|
|||||||
242
internal/tui/screens/error.go
Normal file
242
internal/tui/screens/error.go
Normal 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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package screens
|
package screens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
"github.com/charmbracelet/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -37,54 +39,48 @@ func (s *HelpScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
|
|
||||||
// View renders the help screen
|
// View renders the help screen
|
||||||
func (s *HelpScreen) View() string {
|
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.NewStyle().
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("62")).
|
BorderForeground(theme.StyleBorder).
|
||||||
Padding(1, 2)
|
Padding(1, 2)
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().
|
keyStyle := theme.StyleHelpKey.Width(12)
|
||||||
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)
|
|
||||||
|
|
||||||
descStyle := lipgloss.NewStyle().
|
descStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("250"))
|
Foreground(lipgloss.Color("250"))
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
header := headerStyle.Render("Keyboard Shortcuts")
|
header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
|
||||||
|
|
||||||
// Shortcut groups
|
// 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("j / ↓") + descStyle.Render("Move down") + "\n" +
|
||||||
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
|
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
|
||||||
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
|
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
|
||||||
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
|
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
|
||||||
keyStyle.Render("Esc") + descStyle.Render("Go back")
|
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("a") + descStyle.Render("Add client") + "\n" +
|
||||||
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
|
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
|
||||||
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
|
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
|
||||||
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
|
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
|
||||||
keyStyle.Render("l") + descStyle.Render("List view")
|
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("Show this help") + "\n" +
|
||||||
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
||||||
keyStyle.Render("q") + descStyle.Render("Quit")
|
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
|
// Two-column layout
|
||||||
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
navigationGroup,
|
navigationGroup,
|
||||||
@@ -94,18 +90,17 @@ func (s *HelpScreen) View() string {
|
|||||||
|
|
||||||
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
otherGroup,
|
otherGroup,
|
||||||
|
"",
|
||||||
|
copyGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
footerStyle := lipgloss.NewStyle().
|
footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
|
||||||
Foreground(lipgloss.Color("241")).
|
|
||||||
MarginTop(1)
|
|
||||||
footer := footerStyle.Render("Press q or Esc to return")
|
|
||||||
|
|
||||||
// Combine all
|
// Combine all
|
||||||
return borderStyle.Render(
|
return breadcrumb + "\n\n" + borderStyle.Render(
|
||||||
lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
|
lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
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.)
|
// Screen represents a UI screen (list, add, detail, etc.)
|
||||||
type Screen interface {
|
type Screen interface {
|
||||||
Init() tea.Cmd
|
Init() tea.Cmd
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package screens
|
package screens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,6 +30,7 @@ type ListScreen struct {
|
|||||||
type ClientWithStatus struct {
|
type ClientWithStatus struct {
|
||||||
Client wireguard.Client
|
Client wireguard.Client
|
||||||
Status string
|
Status string
|
||||||
|
Quality string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewListScreen creates a new list screen
|
// NewListScreen creates a new list screen
|
||||||
@@ -136,16 +138,60 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
|
|
||||||
// View renders the list screen
|
// View renders the list screen
|
||||||
func (s *ListScreen) View() string {
|
func (s *ListScreen) View() string {
|
||||||
|
// Breadcrumb: Home
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||||
|
{Label: "Clients", ID: "list"},
|
||||||
|
})
|
||||||
if len(s.clients) == 0 {
|
if len(s.clients) == 0 {
|
||||||
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit."
|
// Empty state with helpful guidance
|
||||||
|
return 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
|
// Check if there are no matches
|
||||||
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
||||||
return s.search.View() + "\n" + lipgloss.NewStyle().
|
// Empty search results with helpful tips
|
||||||
|
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")).
|
Foreground(lipgloss.Color("241")).
|
||||||
Italic(true).
|
Render("Search tips:") + "\n" +
|
||||||
Render("No matching clients found. Try a different search term.")
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Check your spelling") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Try a shorter search term") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Search by name, IP, or status") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [esc] to clear search") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [r] to refresh client list")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time since last update
|
// Calculate time since last update
|
||||||
@@ -159,7 +205,13 @@ func (s *ListScreen) View() string {
|
|||||||
Foreground(lipgloss.Color("241")).
|
Foreground(lipgloss.Color("241")).
|
||||||
Render("Last updated: " + timeAgo)
|
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
|
// formatDuration returns a human-readable string for the duration
|
||||||
@@ -173,23 +225,40 @@ func formatDuration(d time.Duration) string {
|
|||||||
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadClients loads clients from wireguard config
|
||||||
|
|
||||||
// loadClients loads clients from wireguard config
|
// loadClients loads clients from wireguard config
|
||||||
func (s *ListScreen) loadClients() tea.Msg {
|
func (s *ListScreen) loadClients() tea.Msg {
|
||||||
clients, err := wireguard.ListClients()
|
clients, err := wireguard.ListClients()
|
||||||
if err != nil {
|
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))
|
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||||
for i, client := range clients {
|
for i, client := range clients {
|
||||||
status, err := wireguard.GetClientStatus(client.PublicKey)
|
status := wireguard.StatusDisconnected
|
||||||
if err != nil {
|
quality := ""
|
||||||
status = wireguard.StatusDisconnected
|
|
||||||
|
// Find matching peer status
|
||||||
|
for _, peerStatus := range peerStatuses {
|
||||||
|
if peerStatus.PublicKey == client.PublicKey {
|
||||||
|
status = peerStatus.Status
|
||||||
|
quality = peerStatus.Quality
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clientsWithStatus[i] = ClientWithStatus{
|
clientsWithStatus[i] = ClientWithStatus{
|
||||||
Client: client,
|
Client: client,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
Quality: quality,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +297,24 @@ func (s *ListScreen) applyFilter() {
|
|||||||
s.buildTable()
|
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
|
// buildTable creates and configures the table
|
||||||
func (s *ListScreen) buildTable() {
|
func (s *ListScreen) buildTable() {
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: 20},
|
{Title: "Name", Width: 20},
|
||||||
{Title: "IPv4", Width: 15},
|
{Title: "IPv4", Width: 15},
|
||||||
{Title: "IPv6", Width: 35},
|
{Title: "IPv6", Width: 35},
|
||||||
{Title: "Status", Width: 12},
|
{Title: "Status", Width: 14},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use filtered clients if search is active, otherwise use all clients
|
// Use filtered clients if search is active, otherwise use all clients
|
||||||
@@ -245,11 +325,31 @@ func (s *ListScreen) buildTable() {
|
|||||||
|
|
||||||
var rows []table.Row
|
var rows []table.Row
|
||||||
for _, cws := range displayClients {
|
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{
|
row := table.Row{
|
||||||
cws.Client.Name,
|
name,
|
||||||
cws.Client.IPv4,
|
ipv4,
|
||||||
cws.Client.IPv6,
|
ipv6,
|
||||||
cws.Status,
|
statusText,
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
}
|
}
|
||||||
@@ -356,7 +456,7 @@ type clientsLoadedMsg struct {
|
|||||||
clients []ClientWithStatus
|
clients []ClientWithStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// errMsg is sent when an error occurs
|
// ErrMsg is sent when an error occurs
|
||||||
type errMsg struct {
|
type ErrMsg struct {
|
||||||
err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -54,8 +56,8 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
case configLoadedMsg:
|
case configLoadedMsg:
|
||||||
s.configContent = msg.content
|
s.configContent = msg.content
|
||||||
s.generateQRCode()
|
s.generateQRCode()
|
||||||
case errMsg:
|
case ErrMsg:
|
||||||
s.errorMsg = msg.err.Error()
|
s.errorMsg = msg.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -63,20 +65,23 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
|
|
||||||
// View renders the QR screen
|
// View renders the QR screen
|
||||||
func (s *QRScreen) View() string {
|
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 != "" {
|
if s.errorMsg != "" {
|
||||||
return s.renderError()
|
return s.renderError()
|
||||||
}
|
}
|
||||||
if s.qrCode == "" {
|
if s.qrCode == "" {
|
||||||
return "Loading QR code..."
|
return "Loading QR code..."
|
||||||
}
|
}
|
||||||
return s.renderQR()
|
return breadcrumb + "\n" + s.renderQR()
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig loads the client configuration
|
// loadConfig loads the client configuration
|
||||||
func (s *QRScreen) loadConfig() tea.Msg {
|
func (s *QRScreen) loadConfig() tea.Msg {
|
||||||
content, err := wireguard.GetClientConfigContent(s.clientName)
|
content, err := wireguard.GetClientConfigContent(s.clientName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err: err}
|
return ErrMsg{Err: err}
|
||||||
}
|
}
|
||||||
return configLoadedMsg{content: content}
|
return configLoadedMsg{content: content}
|
||||||
}
|
}
|
||||||
@@ -98,39 +103,22 @@ func (s *QRScreen) generateQRCode() {
|
|||||||
|
|
||||||
// renderQR renders the QR code with styling
|
// renderQR renders the QR code with styling
|
||||||
func (s *QRScreen) renderQR() string {
|
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().
|
styleQR := lipgloss.NewStyle().
|
||||||
MarginLeft(2)
|
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"
|
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
|
// renderError renders an error message
|
||||||
func (s *QRScreen) renderError() string {
|
func (s *QRScreen) renderError() string {
|
||||||
styleError := lipgloss.NewStyle().
|
title := theme.StyleError.Bold(true).Render("Error")
|
||||||
Foreground(lipgloss.Color("196")).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
styleHelp := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("241")).
|
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
title := styleError.Render("Error")
|
|
||||||
message := s.errorMsg
|
message := s.errorMsg
|
||||||
help := "Press [q/Escape] to return"
|
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
|
// Messages
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/calmcacil/wg-admin/internal/backup"
|
"github.com/calmcacil/wg-admin/internal/backup"
|
||||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -22,31 +24,21 @@ type RestoreScreen struct {
|
|||||||
restoreError error
|
restoreError error
|
||||||
restoreSuccess bool
|
restoreSuccess bool
|
||||||
message string
|
message string
|
||||||
|
spinner spinner.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// No local styles - all use theme package
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRestoreScreen creates a new restore screen
|
// NewRestoreScreen creates a new restore screen
|
||||||
func NewRestoreScreen() *RestoreScreen {
|
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{
|
return &RestoreScreen{
|
||||||
showConfirm: false,
|
showConfirm: false,
|
||||||
|
spinner: s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +51,12 @@ func (s *RestoreScreen) Init() tea.Cmd {
|
|||||||
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
var cmd 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
|
// Handle confirmation modal
|
||||||
if s.showConfirm && s.confirmModal != nil {
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
_, cmd = s.confirmModal.Update(msg)
|
_, cmd = s.confirmModal.Update(msg)
|
||||||
@@ -69,7 +67,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
|||||||
// User confirmed restore
|
// User confirmed restore
|
||||||
s.isRestoring = true
|
s.isRestoring = true
|
||||||
s.showConfirm = false
|
s.showConfirm = false
|
||||||
return s, s.performRestore()
|
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
|
||||||
}
|
}
|
||||||
// User cancelled - close modal
|
// User cancelled - close modal
|
||||||
s.showConfirm = false
|
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 {
|
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||||
s.isRestoring = true
|
s.isRestoring = true
|
||||||
s.showConfirm = false
|
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
|
s.isRestoring = false
|
||||||
if msg.err != nil {
|
if msg.Err != nil {
|
||||||
s.restoreError = msg.err
|
s.restoreError = msg.Err
|
||||||
s.message = fmt.Sprintf("Restore failed: %v", msg.err)
|
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
|
||||||
} else {
|
} else {
|
||||||
s.restoreSuccess = true
|
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
|
// renderContent renders the main restore screen content
|
||||||
func (s *RestoreScreen) renderContent() string {
|
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
|
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")
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
|
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
|
||||||
@@ -175,23 +178,23 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isRestoring {
|
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()
|
return content.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.restoreSuccess {
|
if s.restoreSuccess {
|
||||||
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message))
|
content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
|
||||||
content.WriteString("\n\n")
|
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()
|
return content.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.restoreError != nil {
|
if s.restoreError != nil {
|
||||||
content.WriteString(restoreErrorStyle.Render("✗ " + s.message))
|
content.WriteString(theme.StyleError.Render("✗ " + s.message))
|
||||||
content.WriteString("\n\n")
|
content.WriteString("\n\n")
|
||||||
content.WriteString(s.table.View())
|
content.WriteString(s.table.View())
|
||||||
content.WriteString("\n\n")
|
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()
|
return content.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +204,7 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
|
|
||||||
// Show selected backup details
|
// Show selected backup details
|
||||||
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
|
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
|
||||||
content.WriteString(restoreInfoStyle.Render(
|
content.WriteString(theme.StyleMuted.Render(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"Selected: %s (%s) - %s\nSize: %s",
|
"Selected: %s (%s) - %s\nSize: %s",
|
||||||
s.selectedBackup.Operation,
|
s.selectedBackup.Operation,
|
||||||
@@ -213,7 +216,7 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
content.WriteString("\n")
|
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()
|
return content.String()
|
||||||
}
|
}
|
||||||
@@ -222,7 +225,7 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
func (s *RestoreScreen) loadBackups() tea.Msg {
|
func (s *RestoreScreen) loadBackups() tea.Msg {
|
||||||
backups, err := backup.ListBackups()
|
backups, err := backup.ListBackups()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err: err}
|
return ErrMsg{Err: err}
|
||||||
}
|
}
|
||||||
return backupsLoadedMsg{backups: backups}
|
return backupsLoadedMsg{backups: backups}
|
||||||
}
|
}
|
||||||
@@ -261,15 +264,8 @@ func (s *RestoreScreen) buildTable() {
|
|||||||
// setTableStyles applies styling to the table
|
// setTableStyles applies styling to the table
|
||||||
func (s *RestoreScreen) setTableStyles() {
|
func (s *RestoreScreen) setTableStyles() {
|
||||||
styles := table.DefaultStyles()
|
styles := table.DefaultStyles()
|
||||||
styles.Header = styles.Header.
|
styles.Header = theme.StyleTableHeader
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
styles.Selected = theme.StyleTableSelected
|
||||||
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)
|
s.table.SetStyles(styles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ var (
|
|||||||
StyleTitle lipgloss.Style
|
StyleTitle lipgloss.Style
|
||||||
StyleSubtitle lipgloss.Style
|
StyleSubtitle lipgloss.Style
|
||||||
StyleHelpKey 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
|
// DefaultTheme is the standard blue-based theme
|
||||||
@@ -176,8 +182,83 @@ func ApplyTheme(theme *Theme) {
|
|||||||
StyleHelpKey = lipgloss.NewStyle().
|
StyleHelpKey = lipgloss.NewStyle().
|
||||||
Foreground(theme.Scheme.Primary).
|
Foreground(theme.Scheme.Primary).
|
||||||
Bold(true)
|
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
|
// GetThemeNames returns a list of available theme names
|
||||||
func GetThemeNames() []string {
|
func GetThemeNames() []string {
|
||||||
names := make([]string, 0, len(ThemeRegistry))
|
names := make([]string, 0, len(ThemeRegistry))
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const (
|
|||||||
StatusConnected = "Connected"
|
StatusConnected = "Connected"
|
||||||
// StatusDisconnected indicates a peer is not connected
|
// StatusDisconnected indicates a peer is not connected
|
||||||
StatusDisconnected = "Disconnected"
|
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
|
// PeerStatus represents the status of a WireGuard peer
|
||||||
@@ -26,6 +35,7 @@ type PeerStatus struct {
|
|||||||
TransferRx string `json:"transfer_rx"`
|
TransferRx string `json:"transfer_rx"`
|
||||||
TransferTx string `json:"transfer_tx"`
|
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
|
// GetClientStatus checks if a specific client is connected
|
||||||
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
|
|||||||
return peers
|
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
|
// finalizePeerStatus determines the peer's status based on handshake time
|
||||||
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
||||||
peer.TransferRx = ""
|
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 != "" {
|
if handshake != "" {
|
||||||
peer.LatestHandshake = parseHandshake(handshake)
|
peer.LatestHandshake = parseHandshake(handshake)
|
||||||
|
timeSinceHandshake := time.Since(peer.LatestHandshake)
|
||||||
|
|
||||||
// Peer is considered connected if handshake is recent (within 5 minutes)
|
// Peer is considered connected if handshake is recent (within 5 minutes)
|
||||||
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
|
// 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.Status = StatusConnected
|
||||||
|
peer.Quality = CalculateQuality(timeSinceHandshake)
|
||||||
} else {
|
} else {
|
||||||
peer.Status = StatusDisconnected
|
peer.Status = StatusDisconnected
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user