package screens import ( "fmt" "sort" "strings" "time" "github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/wireguard" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const statusRefreshInterval = 3 // seconds // ListScreen displays a table of WireGuard clients type ListScreen struct { table table.Model search *components.SearchModel clients []ClientWithStatus filtered []ClientWithStatus sortedBy string // Column name being sorted by ascending bool // Sort direction lastUpdated time.Time } // ClientWithStatus wraps a client with its connection status type ClientWithStatus struct { Client wireguard.Client Status string } // NewListScreen creates a new list screen func NewListScreen() *ListScreen { return &ListScreen{ search: components.NewSearch(), sortedBy: "Name", ascending: true, } } // Init initializes the list screen func (s *ListScreen) Init() tea.Cmd { return tea.Batch( s.loadClients, wireguard.Tick(statusRefreshInterval), ticker(), ) } // ticker sends a message every second to update the time display func ticker() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return timeTickMsg(t) }) } // timeTickMsg is sent every second to update the time display type timeTickMsg time.Time // Update handles messages for the list screen func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // Handle search activation if msg.String() == "/" && !s.search.IsActive() { s.search.Activate() return s, nil } // If search is active, pass input to search if s.search.IsActive() { s.search, cmd = s.search.Update(msg) // Apply filter to clients s.applyFilter() return s, cmd } // Normal key handling when search is not active switch msg.String() { case "q", "ctrl+c": // Handle quit in parent model return s, nil case "r": // Refresh clients return s, s.loadClients case "R": // Show restore screen return NewRestoreScreen(), nil case "Q": // Show QR code for selected client if len(s.table.Rows()) > 0 { selected := s.table.SelectedRow() clientName := selected[0] // First column is Name return NewQRScreen(clientName), nil } case "enter": // Open detail view for selected client if len(s.table.Rows()) > 0 { selectedRow := s.table.SelectedRow() selectedName := selectedRow[0] // First column is Name // Find the client with this name for _, cws := range s.clients { if cws.Client.Name == selectedName { return s, func() tea.Msg { return ClientSelectedMsg{Client: cws} } } } } case "1", "2", "3", "4": // Sort by column number (Name, IPv4, IPv6, Status) s.sortByColumn(msg.String()) } case clientsLoadedMsg: s.clients = msg.clients s.lastUpdated = time.Now() s.search.SetTotalCount(len(s.clients)) s.applyFilter() case timeTickMsg: // Trigger a re-render to update "Last updated" display case wireguard.StatusTickMsg: // Refresh status on periodic tick return s, s.loadClients case wireguard.RefreshStatusMsg: // Refresh status on manual refresh return s, s.loadClients } s.table, cmd = s.table.Update(msg) return s, cmd } // View renders the list screen // Breadcrumb: Home breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{ {Label: "Clients", ID: "list"}, }) func (s *ListScreen) View() string { if len(s.clients) == 0 { // Empty state with helpful guidance return s.search.View() + "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("226")). Bold(true). Render("No clients yet") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Render("Let's get started! Here are your options:") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [a] to add your first client") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [R] to restore from backup") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [r] to refresh the client list") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [q] to quit") } // Check if there are no matches if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" { // Empty search results with helpful tips return s.search.View() + "\n\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("226")). Bold(true). Render("No matching clients found") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Render("Search tips:") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Check your spelling") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Try a shorter search term") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Search by name, IP, or status") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [esc] to clear search") + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("109")). Render(" • Press [r] to refresh client list") } // Calculate time since last update timeAgo := "never" if !s.lastUpdated.IsZero() { duration := time.Since(s.lastUpdated) timeAgo = formatDuration(duration) } lastUpdatedText := lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Render("Last updated: " + timeAgo) return s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText } // 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 }