- 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
325 lines
7.4 KiB
Go
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
|
|
}
|