Compare commits
2 Commits
5136484cd2
...
34951221d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34951221d3 | ||
|
|
68939cdc08 |
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user