Compare commits

...

2 Commits

Author SHA1 Message Date
Calmcacil
34951221d3 bd sync: 2026-01-12 23:04:56 2026-01-12 23:04:56 +01:00
Calmcacil
68939cdc08 Reduce status refresh interval to 3 seconds and add last updated indicator 2026-01-12 23:04:48 +01:00
7 changed files with 57 additions and 42 deletions

View File

@@ -27,7 +27,7 @@
{"id":"wg-admin-gp4","title":"Create Go TUI epic","description":"Epic: Convert wg-client-manager bash script to a modern, responsive Go TUI application using Bubble Tea framework. Provides better UX with interactive forms, real-time status updates, and intuitive keyboard navigation.","status":"closed","priority":1,"issue_type":"feature","owner":"Calmcacil@Raion","created_at":"2026-01-12T17:03:30.286393088+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T18:06:58.972856627+01:00","closed_at":"2026-01-12T18:06:58.972856627+01:00","close_reason":"All Go TUI tasks completed across 6 phases:\n\nPhase 1 (Foundation): Project init, config system, TUI skeleton\nPhase 2 (Client List): Parsing, table, real-time status \nPhase 3 (Add Client): Form, key generation, config files\nPhase 4 (Detail/Delete): Detail view, delete functionality, QR codes\nPhase 5 (UX): Search, help screen, color themes\nPhase 6 (Backup/Restore): Backup operations, restore functionality\n\nImplementation details:\n- Total packages added: bubbletea, lipgloss, bubbles, huh, qrterminal\n- Files created: 25+ Go source files\n- All features implemented: CRUD clients, status checking, QR codes, themes, search, backup/restore\n- Build successful with ~6MB binary\n- All tasks closed and synced to remote\n\nApplication ready for testing and deployment."}
{"id":"wg-admin-gw9","title":"Add search and filter clients","description":"Implement client search functionality with keyboard shortcut (/). Allow filtering by client name, IP address, or status. Highlight matching results in real-time as user types.","status":"closed","priority":2,"issue_type":"task","owner":"Calmcacil@Raion","created_at":"2026-01-12T17:03:30.285733479+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T18:06:07.246176563+01:00","closed_at":"2026-01-12T18:06:07.246176563+01:00","close_reason":"Search component implemented in internal/tui/components/search.go. Real-time filtering by name, IP, or status with '/' activation. Filter types cycle with Tab. Match count display. Integrated with list screen to dynamically update client table.","dependencies":[{"issue_id":"wg-admin-gw9","depends_on_id":"wg-admin-xum","type":"blocks","created_at":"2026-01-12T17:04:36.200521151+01:00","created_by":"Calmcacil"}]}
{"id":"wg-admin-hd4","title":"Add keyboard shortcuts help","description":"Create help screen displaying all keyboard shortcuts. Show on '?' key press or in status bar. Include shortcuts for navigation (j/k, arrows), actions (a=add, d=delete, q=quit), and help (?).","status":"closed","priority":2,"issue_type":"task","owner":"Calmcacil@Raion","created_at":"2026-01-12T17:03:30.283054325+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T17:48:32.156575557+01:00","closed_at":"2026-01-12T17:48:32.156575557+01:00","close_reason":"Help screen implemented with keyboard shortcuts table organized by category. Shows on ? key or status bar. Navigation, actions, and other shortcuts documented with lipgloss styling.","dependencies":[{"issue_id":"wg-admin-hd4","depends_on_id":"wg-admin-xum","type":"blocks","created_at":"2026-01-12T17:04:53.117669255+01:00","created_by":"Calmcacil"}]}
{"id":"wg-admin-he6","title":"Reduce status refresh interval from 10 to 3 seconds for better real-time awareness","description":"Currently connection status refreshes every 10 seconds which is too slow for real-time awareness. Reduce interval to 3 seconds and add 'Last updated: X ago' indicator so users know when data was last refreshed.","status":"in_progress","priority":1,"issue_type":"bug","owner":"Calmcacil@Raion","created_at":"2026-01-12T21:40:04.503637878+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T22:58:59.044822026+01:00"}
{"id":"wg-admin-he6","title":"Reduce status refresh interval from 10 to 3 seconds for better real-time awareness","description":"Currently connection status refreshes every 10 seconds which is too slow for real-time awareness. Reduce interval to 3 seconds and add 'Last updated: X ago' indicator so users know when data was last refreshed.","status":"closed","priority":1,"issue_type":"bug","owner":"Calmcacil@Raion","created_at":"2026-01-12T21:40:04.503637878+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T23:04:52.82485488+01:00","closed_at":"2026-01-12T23:04:52.82485488+01:00","close_reason":"Completed: Reduced refresh interval from 10 to 3 seconds and added 'Last updated' indicator showing time since last refresh"}
{"id":"wg-admin-hln","title":"Improve empty state messages with actionable guidance and call-to-action","description":"When no clients exist or search returns no results, provide clear guidance on next steps. Add tips like 'Press [a] to add your first client', 'Check spelling', 'Try shorter search term'. Make empty states welcoming and helpful.","status":"closed","priority":1,"issue_type":"feature","owner":"Calmcacil@Raion","created_at":"2026-01-12T21:40:04.504693809+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T23:03:04.93789438+01:00","closed_at":"2026-01-12T23:03:04.93789438+01:00","close_reason":"Implemented improved empty state messages with actionable guidance including tips on adding clients, checking spelling, and shorter search terms"}
{"id":"wg-admin-hzl","title":"Add visual status icons (● Online/● Offline) for better scanning","description":"Connection status is currently just text ('Connected'/'Disconnected'). Add visual indicators with colored circles/icons for faster scanning. Green ● for connected, red ● for disconnected. This improves visual hierarchy and scanability.","status":"in_progress","priority":1,"issue_type":"feature","owner":"Calmcacil@Raion","created_at":"2026-01-12T21:40:04.554974884+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T22:59:07.349352101+01:00"}
{"id":"wg-admin-iqs","title":"Add navigation breadcrumbs to all screens for context","description":"Currently no breadcrumbs or navigation context in screens. Users can't tell where they are in the navigation hierarchy (list \u003e details, add \u003e form). Add breadcrumb at top of each screen showing navigation path.","status":"in_progress","priority":2,"issue_type":"feature","owner":"Calmcacil@Raion","created_at":"2026-01-12T21:40:23.210874178+01:00","created_by":"Calmcacil","updated_at":"2026-01-12T22:59:07.202238466+01:00"}

View File

@@ -99,6 +99,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
case screens.ErrMsg:
// An error occurred - show error screen
m.previousScreen = m.currentScreen
m.errorScreen = screens.NewErrorScreen(msg.Err)
m.currentScreen = m.errorScreen
return m, m.currentScreen.Init()
}
// Pass messages to current screen

View File

@@ -29,7 +29,7 @@ var (
addHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
loadingStyle = lipgloss.NewStyle().
addLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginTop(1)
@@ -147,7 +147,7 @@ func (s *AddScreen) View() string {
}
if s.isCreating {
return loadingStyle.Render(
return addLoadingStyle.Render(
lipgloss.JoinVertical(
lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"),
@@ -172,7 +172,7 @@ func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
// Create the client via wireguard package
err := wireguard.CreateClient(name, dns, usePSK)
if err != nil {
return errMsg{err: fmt.Errorf("failed to create client: %w", err)}
return ErrMsg{Err: fmt.Errorf("failed to create client: %w", err)}
}
// Return success message

View File

@@ -26,11 +26,8 @@ type DetailScreen struct {
// Styles
var (
detailTitleStyle = lipgloss.NewStyle().Bold(true).MarginTop(0)
detailSectionStyle = lipgloss.NewStyle().Bold(true).MarginTop(1)
detailLabelStyle = lipgloss.NewStyle().Width(18)
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
dimmedContentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
dimmedContentStyle = theme.StyleMuted
)
// NewDetailScreen creates a new detail screen for a client
@@ -118,9 +115,7 @@ func (s *DetailScreen) View() string {
if s.showConfig && s.configDisplay != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
dimmedContent := dimmedContentStyle.Render(content)
// Overlay config display modal
return lipgloss.JoinVertical(
@@ -134,9 +129,7 @@ func (s *DetailScreen) View() string {
if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
dimmedContent := dimmedContentStyle.Render(content)
// Overlay confirmation modal
return lipgloss.JoinVertical(
@@ -153,21 +146,21 @@ func (s *DetailScreen) View() string {
func (s *DetailScreen) renderContent() string {
statusText := s.status
if s.status == wireguard.StatusConnected {
statusText = detailConnectedStyle.Render("● " + s.status)
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status)
} else {
statusText = detailDisconnectedStyle.Render("● " + s.status)
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
}
// Build content
content := lipgloss.JoinVertical(
lipgloss.Left,
detailTitleStyle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
theme.StyleTitle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
"",
s.renderField("Status", statusText),
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
"",
detailSectionStyle.Render("WireGuard Configuration"),
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("WireGuard Configuration"),
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
if s.client.HasPSK {
@@ -176,7 +169,7 @@ func (s *DetailScreen) renderContent() string {
return "Not configured"
}())),
"",
detailSectionStyle.Render("Connection Info"),
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("Connection Info"),
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
@@ -184,7 +177,7 @@ func (s *DetailScreen) renderContent() string {
)
// Add help text
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] View Config • [q/b] Back")
helpText := theme.StyleHelpKey.MarginTop(1).Render("Actions: [d] Delete • [c] View Config • [q/b] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
return content
@@ -193,7 +186,7 @@ func (s *DetailScreen) renderContent() string {
// renderField renders a label-value pair
func (s *DetailScreen) renderField(label string, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left,
detailLabelStyle.Render(label),
theme.StyleSubtitle.Width(18).Render(label),
value,
)
}
@@ -221,7 +214,7 @@ func (s *DetailScreen) formatHandshake() string {
func (s *DetailScreen) loadClientStatus() tea.Msg {
peers, err := wireguard.GetAllPeers()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
// Find peer by public key
@@ -250,7 +243,7 @@ func (s *DetailScreen) loadConfig() tea.Cmd {
return func() tea.Msg {
config, err := wireguard.GetClientConfigContent(s.client.Name)
if err != nil {
return errMsg{fmt.Errorf("failed to load client config: %w", err)}
return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
}
// Create or update config display modal
@@ -269,7 +262,7 @@ func (s *DetailScreen) deleteClient() tea.Cmd {
return func() tea.Msg {
err := wireguard.DeleteClient(s.client.Name)
if err != nil {
return errMsg{fmt.Errorf("failed to delete client: %w", err)}
return ErrMsg{fmt.Errorf("failed to delete client: %w", err)}
}
return ClientDeletedMsg{
Name: s.client.Name,

View File

@@ -137,9 +137,13 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the list screen
func (s *ListScreen) View() string {
// Breadcrumb: Home
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
})
if len(s.clients) == 0 {
// Empty state with helpful guidance
return s.search.View() + "\n\n" +
return breadcrumb + "\n" + s.search.View() + "\n\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
@@ -164,7 +168,7 @@ func (s *ListScreen) View() string {
// Check if there are no matches
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
// Empty search results with helpful tips
return s.search.View() + "\n\n" +
return breadcrumb + "\n" + s.search.View() + "\n\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
@@ -200,7 +204,7 @@ func (s *ListScreen) View() string {
Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo)
return s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText
}
// formatDuration returns a human-readable string for the duration
@@ -218,7 +222,7 @@ func formatDuration(d time.Duration) string {
func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
// Get status for each client

View File

@@ -54,8 +54,8 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
case configLoadedMsg:
s.configContent = msg.content
s.generateQRCode()
case errMsg:
s.errorMsg = msg.err.Error()
case ErrMsg:
s.errorMsg = msg.Err.Error()
}
return s, nil
@@ -76,7 +76,7 @@ func (s *QRScreen) View() string {
func (s *QRScreen) loadConfig() tea.Msg {
content, err := wireguard.GetClientConfigContent(s.clientName)
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
return configLoadedMsg{content: content}
}

View File

@@ -43,15 +43,21 @@ var (
restoreInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
loadingStyle = lipgloss.NewStyle().
restoreLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
)
// NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen {
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &RestoreScreen{
showConfirm: false,
spinner: s,
}
}
@@ -64,6 +70,12 @@ func (s *RestoreScreen) Init() tea.Cmd {
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// If restoring, only update spinner
if s.isRestoring && !s.showConfirm {
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
@@ -74,7 +86,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// User confirmed restore
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
// User cancelled - close modal
s.showConfirm = false
@@ -87,7 +99,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
}
@@ -130,14 +142,14 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
}
}
}
case restoreCompletedMsg:
case RestoreCompletedMsg:
s.isRestoring = false
if msg.err != nil {
s.restoreError = msg.err
s.message = fmt.Sprintf("Restore failed: %v", msg.err)
if msg.Err != nil {
s.restoreError = msg.Err
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
} else {
s.restoreSuccess = true
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.safetyBackupPath)
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
}
}
@@ -227,7 +239,7 @@ func (s *RestoreScreen) renderContent() string {
func (s *RestoreScreen) loadBackups() tea.Msg {
backups, err := backup.ListBackups()
if err != nil {
return errMsg{err: err}
return ErrMsg{Err: err}
}
return backupsLoadedMsg{backups: backups}
}