Compare commits
12 Commits
17f4d52c8a
...
go-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c0da13a19 | ||
|
|
99b9dc17db | ||
|
|
6629598574 | ||
|
|
50321a8471 | ||
|
|
bf71a7a659 | ||
|
|
f154c7ff69 | ||
|
|
0476f1e227 | ||
|
|
1187ae0046 | ||
|
|
b7ddd54cf6 | ||
|
|
5b8b9b66f5 | ||
|
|
f0e26e4a0a | ||
|
|
dd62458515 |
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,6 +33,13 @@ 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
|
||||||
@@ -38,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 {
|
||||||
@@ -46,92 +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:
|
case screens.ErrMsg:
|
||||||
// An error occurred - show error screen
|
|
||||||
m.previousScreen = m.currentScreen
|
m.previousScreen = m.currentScreen
|
||||||
m.errorScreen = screens.NewErrorScreen(msg.Err)
|
m.errorScreen = screens.NewErrorScreen(msg.Err)
|
||||||
m.currentScreen = m.errorScreen
|
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
|
||||||
return m, m.currentScreen.Init()
|
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()
|
||||||
}
|
}
|
||||||
@@ -142,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())
|
||||||
@@ -150,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
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
|
|||||||
|
|
||||||
statusText := s.status
|
statusText := s.status
|
||||||
if s.status == wireguard.StatusConnected {
|
if s.status == wireguard.StatusConnected {
|
||||||
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status)
|
duration := time.Since(s.lastHandshake)
|
||||||
|
quality := wireguard.CalculateQuality(duration)
|
||||||
|
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
|
||||||
} else {
|
} else {
|
||||||
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
||||||
}
|
}
|
||||||
@@ -184,8 +186,8 @@ func (s *DetailScreen) renderContent() string {
|
|||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add help text
|
// Add help text with all keyboard shortcuts
|
||||||
helpText := theme.StyleHelpKey.MarginTop(1).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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package screens
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"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/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -41,55 +42,45 @@ func (s *HelpScreen) View() string {
|
|||||||
// Breadcrumb: Help
|
// Breadcrumb: Help
|
||||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
||||||
|
|
||||||
|
// Styles using theme
|
||||||
// Styles
|
|
||||||
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,
|
||||||
@@ -99,15 +90,14 @@ 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 breadcrumb + "\n\n" + borderStyle.Render(
|
return breadcrumb + "\n\n" + borderStyle.Render(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ type ListScreen struct {
|
|||||||
|
|
||||||
// ClientWithStatus wraps a client with its connection status
|
// ClientWithStatus wraps a client with its connection status
|
||||||
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
|
||||||
@@ -204,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 breadcrumb + "\n" + 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
|
||||||
@@ -218,6 +225,8 @@ 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()
|
||||||
@@ -225,16 +234,31 @@ func (s *ListScreen) loadClients() tea.Msg {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +298,11 @@ func (s *ListScreen) applyFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatStatusWithIcon formats the status with a colored circle icon
|
// formatStatusWithIcon formats the status with a colored circle icon
|
||||||
func (s *ListScreen) formatStatusWithIcon(status string) string {
|
func (s *ListScreen) formatStatusWithIcon(status string, quality string) string {
|
||||||
if status == wireguard.StatusConnected {
|
if 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("46")).Render("●") + " " + status
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||||
@@ -298,11 +325,30 @@ func (s *ListScreen) buildTable() {
|
|||||||
|
|
||||||
var rows []table.Row
|
var rows []table.Row
|
||||||
for _, cws := range displayClients {
|
for _, cws := range displayClients {
|
||||||
statusText := s.formatStatusWithIcon(cws.Status)
|
// Apply highlighting based on filter type
|
||||||
|
name := cws.Client.Name
|
||||||
|
ipv4 := cws.Client.IPv4
|
||||||
|
ipv6 := cws.Client.IPv6
|
||||||
|
status := cws.Status
|
||||||
|
|
||||||
|
if s.search.IsActive() {
|
||||||
|
switch s.search.GetFilterType() {
|
||||||
|
case components.FilterByName:
|
||||||
|
name = s.search.HighlightMatches(name)
|
||||||
|
case components.FilterByIPv4:
|
||||||
|
ipv4 = s.search.HighlightMatches(ipv4)
|
||||||
|
case components.FilterByIPv6:
|
||||||
|
ipv6 = s.search.HighlightMatches(ipv6)
|
||||||
|
case components.FilterByStatus:
|
||||||
|
status = s.search.HighlightMatches(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := s.formatStatusWithIcon(status, cws.Quality)
|
||||||
row := table.Row{
|
row := table.Row{
|
||||||
cws.Client.Name,
|
name,
|
||||||
cws.Client.IPv4,
|
ipv4,
|
||||||
cws.Client.IPv6,
|
ipv6,
|
||||||
statusText,
|
statusText,
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
|
|||||||
220
internal/tui/screens/list_go_append.txt
Normal file
220
internal/tui/screens/list_go_append.txt
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
lastUpdatedText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
Render("Last updated: " + timeAgo)
|
||||||
|
|
||||||
|
// Add keyboard shortcuts help
|
||||||
|
helpText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("63")).
|
||||||
|
MarginTop(1).
|
||||||
|
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
|
||||||
|
|
||||||
|
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration returns a human-readable string for the duration
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClients loads clients from wireguard config
|
||||||
|
func (s *ListScreen) loadClients() tea.Msg {
|
||||||
|
clients, err := wireguard.ListClients()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status for each client
|
||||||
|
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||||
|
for i, client := range clients {
|
||||||
|
status, err := wireguard.GetClientStatus(client.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
status = wireguard.StatusDisconnected
|
||||||
|
}
|
||||||
|
clientsWithStatus[i] = ClientWithStatus{
|
||||||
|
Client: client,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilter applies the current search filter to clients
|
||||||
|
func (s *ListScreen) applyFilter() {
|
||||||
|
// Convert clients to ClientData for filtering
|
||||||
|
clientData := make([]components.ClientData, len(s.clients))
|
||||||
|
for i, cws := range s.clients {
|
||||||
|
clientData[i] = components.ClientData{
|
||||||
|
Name: cws.Client.Name,
|
||||||
|
IPv4: cws.Client.IPv4,
|
||||||
|
IPv6: cws.Client.IPv6,
|
||||||
|
Status: cws.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter clients
|
||||||
|
filteredData := s.search.Filter(clientData)
|
||||||
|
|
||||||
|
// Convert back to ClientWithStatus
|
||||||
|
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||||
|
for i, cd := range filteredData {
|
||||||
|
// Find the matching client
|
||||||
|
for _, cws := range s.clients {
|
||||||
|
if cws.Client.Name == cd.Name {
|
||||||
|
s.filtered[i] = cws
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild table with filtered clients
|
||||||
|
s.buildTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatStatusWithIcon formats the status with a colored circle icon
|
||||||
|
func (s *ListScreen) formatStatusWithIcon(status string) string {
|
||||||
|
if status == wireguard.StatusConnected {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTable creates and configures the table
|
||||||
|
func (s *ListScreen) buildTable() {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: 20},
|
||||||
|
{Title: "IPv4", Width: 15},
|
||||||
|
{Title: "IPv6", Width: 35},
|
||||||
|
{Title: "Status", Width: 14},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use filtered clients if search is active, otherwise use all clients
|
||||||
|
displayClients := s.filtered
|
||||||
|
if !s.search.IsActive() {
|
||||||
|
displayClients = s.clients
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []table.Row
|
||||||
|
for _, cws := range displayClients {
|
||||||
|
statusText := s.formatStatusWithIcon(cws.Status)
|
||||||
|
row := table.Row{
|
||||||
|
cws.Client.Name,
|
||||||
|
cws.Client.IPv4,
|
||||||
|
cws.Client.IPv6,
|
||||||
|
statusText,
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows based on current sort settings
|
||||||
|
s.sortRows(rows)
|
||||||
|
|
||||||
|
// Determine table height
|
||||||
|
tableHeight := len(rows) + 2 // Header + rows
|
||||||
|
if tableHeight < 5 {
|
||||||
|
tableHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
s.table = table.New(
|
||||||
|
table.WithColumns(columns),
|
||||||
|
table.WithRows(rows),
|
||||||
|
table.WithFocused(true),
|
||||||
|
table.WithHeight(tableHeight),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply styles
|
||||||
|
s.setTableStyles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTableStyles applies styling to the table
|
||||||
|
func (s *ListScreen) setTableStyles() {
|
||||||
|
styles := table.DefaultStyles()
|
||||||
|
styles.Header = styles.Header.
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(true)
|
||||||
|
styles.Selected = styles.Selected.
|
||||||
|
Foreground(lipgloss.Color("229")).
|
||||||
|
Background(lipgloss.Color("57")).
|
||||||
|
Bold(false)
|
||||||
|
s.table.SetStyles(styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortRows sorts the rows based on the current sort settings
|
||||||
|
func (s *ListScreen) sortRows(rows []table.Row) {
|
||||||
|
colIndex := s.getColumnIndex(s.sortedBy)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
var valI, valJ string
|
||||||
|
if colIndex < len(rows[i]) {
|
||||||
|
valI = rows[i][colIndex]
|
||||||
|
}
|
||||||
|
if colIndex < len(rows[j]) {
|
||||||
|
valJ = rows[j][colIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ascending {
|
||||||
|
return strings.ToLower(valI) < strings.ToLower(valJ)
|
||||||
|
}
|
||||||
|
return strings.ToLower(valI) > strings.ToLower(valJ)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByColumn changes the sort column
|
||||||
|
func (s *ListScreen) sortByColumn(col string) {
|
||||||
|
sortedBy := "Name"
|
||||||
|
switch col {
|
||||||
|
case "1":
|
||||||
|
sortedBy = "Name"
|
||||||
|
case "2":
|
||||||
|
sortedBy = "IPv4"
|
||||||
|
case "3":
|
||||||
|
sortedBy = "IPv6"
|
||||||
|
case "4":
|
||||||
|
sortedBy = "Status"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direction if clicking same column
|
||||||
|
if s.sortedBy == sortedBy {
|
||||||
|
s.ascending = !s.ascending
|
||||||
|
} else {
|
||||||
|
s.sortedBy = sortedBy
|
||||||
|
s.ascending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.buildTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnIndex returns the index of a column by name
|
||||||
|
func (s *ListScreen) getColumnIndex(name string) int {
|
||||||
|
switch name {
|
||||||
|
case "Name":
|
||||||
|
return 0
|
||||||
|
case "IPv4":
|
||||||
|
return 1
|
||||||
|
case "IPv6":
|
||||||
|
return 2
|
||||||
|
case "Status":
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// clientsLoadedMsg is sent when clients are loaded
|
||||||
|
type clientsLoadedMsg struct {
|
||||||
|
clients []ClientWithStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMsg is sent when an error occurs
|
||||||
|
type ErrMsg struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -102,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,7 @@ 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/spinner"
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -26,27 +27,7 @@ type RestoreScreen struct {
|
|||||||
spinner spinner.Model
|
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)
|
|
||||||
restoreLoadingStyle = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("62")).
|
|
||||||
Bold(true)
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRestoreScreen creates a new restore screen
|
// NewRestoreScreen creates a new restore screen
|
||||||
func NewRestoreScreen() *RestoreScreen {
|
func NewRestoreScreen() *RestoreScreen {
|
||||||
@@ -184,12 +165,11 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
// Breadcrumb: Clients > Restore
|
// Breadcrumb: Clients > Restore
|
||||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
|
||||||
|
|
||||||
|
|
||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
|
||||||
content.WriteString(breadcrumb)
|
content.WriteString(breadcrumb)
|
||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration"))
|
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 == "" {
|
||||||
@@ -198,23 +178,23 @@ func (s *RestoreScreen) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isRestoring {
|
if s.isRestoring {
|
||||||
content.WriteString(restoreLoadingStyle.Render(s.spinner.View() + " 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,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,
|
||||||
@@ -236,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()
|
||||||
}
|
}
|
||||||
@@ -284,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,20 @@ var (
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
|
|
||||||
// Global styles that can be used throughout the application
|
// Global styles that can be used throughout the application
|
||||||
StylePrimary lipgloss.Style
|
StylePrimary lipgloss.Style
|
||||||
StyleSuccess lipgloss.Style
|
StyleSuccess lipgloss.Style
|
||||||
StyleWarning lipgloss.Style
|
StyleWarning lipgloss.Style
|
||||||
StyleError lipgloss.Style
|
StyleError lipgloss.Style
|
||||||
StyleMuted lipgloss.Style
|
StyleMuted lipgloss.Style
|
||||||
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
|
||||||
@@ -25,7 +34,8 @@ type PeerStatus struct {
|
|||||||
LatestHandshake time.Time `json:"latest_handshake"`
|
LatestHandshake time.Time `json:"latest_handshake"`
|
||||||
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