Files
wg-admin/internal/tui/screens/list.go
Calmcacil 26120b8bc2 Add WireGuard TUI implementation
- Add Go TUI with bubbletea for WireGuard management
- Implement client CRUD operations with QR code generation
- Add configuration and validation modules
- Install/update scripts for client setup
- Update Makefile to build binaries to bin/ directory
- Add .gitignore for Go projects
2026-01-12 19:03:35 +01:00

325 lines
7.4 KiB
Go

package screens
import (
"sort"
"strings"
"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 = 10 // 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
}
// 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),
)
}
// 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.search.SetTotalCount(len(s.clients))
s.applyFilter()
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
func (s *ListScreen) View() string {
if len(s.clients) == 0 {
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit."
}
// Check if there are no matches
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
return s.search.View() + "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Italic(true).
Render("No matching clients found. Try a different search term.")
}
return s.search.View() + "\n" + s.table.View()
}
// 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()
}
// 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: 12},
}
// 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 {
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
cws.Status,
}
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
}