Files
wg-admin/internal/tui/screens/detail.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

300 lines
7.9 KiB
Go

package screens
import (
"fmt"
"time"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// DetailScreen displays detailed information about a single WireGuard client
type DetailScreen struct {
client wireguard.Client
status string
lastHandshake time.Time
transferRx string
transferTx string
confirmModal *components.ConfirmModel
showConfirm bool
clipboardCopied bool
clipboardTimer int
}
// Styles
var (
detailTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
detailSectionStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Bold(true).
MarginTop(1)
detailLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Width(18)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
detailConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
detailDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
detailWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
detailHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
detailErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
MarginTop(1)
)
// NewDetailScreen creates a new detail screen for a client
func NewDetailScreen(client wireguard.Client) *DetailScreen {
return &DetailScreen{
client: client,
showConfirm: false,
}
}
// Init initializes the detail screen
func (s *DetailScreen) Init() tea.Cmd {
return s.loadClientStatus
}
// Update handles messages for the detail screen
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// Handle clipboard copy timeout
if s.clipboardCopied {
s.clipboardTimer++
if s.clipboardTimer > 2 {
s.clipboardCopied = false
s.clipboardTimer = 0
}
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
// Handle confirmation result
if !s.confirmModal.Visible {
if s.confirmModal.IsConfirmed() {
// User confirmed deletion
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
return CloseDetailScreenMsg{}
})
}
// User cancelled - close modal
s.showConfirm = false
return s, nil
}
// Handle Enter key to confirm
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" && s.confirmModal.IsConfirmed() {
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
return CloseDetailScreenMsg{}
})
}
}
return s, cmd
}
// Handle normal screen messages
switch msg := msg.(type) {
case clipboardCopiedMsg:
s.clipboardCopied = true
case clientStatusLoadedMsg:
s.status = msg.status
s.lastHandshake = msg.lastHandshake
s.transferRx = msg.transferRx
s.transferTx = msg.transferTx
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
// Return to list screen - signal parent to switch screens
return s, nil
case "d":
// Show delete confirmation
s.confirmModal = components.NewConfirm(
fmt.Sprintf("Are you sure you want to delete client '%s'?\n\nThis action cannot be undone.", s.client.Name),
80,
24,
)
s.showConfirm = true
case "c":
// Copy public key to clipboard
return s, s.copyPublicKey()
}
}
return s, cmd
}
// View renders the detail screen
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)
// Overlay confirmation modal
return lipgloss.JoinVertical(
lipgloss.Left,
dimmedContent,
s.confirmModal.View(),
)
}
return s.renderContent()
}
// renderContent renders the main detail screen content
func (s *DetailScreen) renderContent() string {
statusText := s.status
if s.status == wireguard.StatusConnected {
statusText = detailConnectedStyle.Render(s.status)
} else {
statusText = detailDisconnectedStyle.Render(s.status)
}
// Build content
content := lipgloss.JoinVertical(
lipgloss.Left,
detailTitleStyle.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"),
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
if s.client.HasPSK {
return "✓ Configured"
}
return "Not configured"
}())),
"",
detailSectionStyle.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)),
"",
)
// Add help text
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] Copy Public Key • [q] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
// Show clipboard confirmation
if s.clipboardCopied {
content += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("✓ Public key copied to clipboard!")
}
return content
}
// renderField renders a label-value pair
func (s *DetailScreen) renderField(label string, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left,
detailLabelStyle.Render(label),
value,
)
}
// formatHandshake formats the last handshake time
func (s *DetailScreen) formatHandshake() string {
if s.lastHandshake.IsZero() {
return "Never"
}
duration := time.Since(s.lastHandshake)
if duration < time.Minute {
return "Just now"
} else if duration < time.Hour {
return fmt.Sprintf("%d min ago", int(duration.Minutes()))
} else if duration < 24*time.Hour {
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
} else if duration < 7*24*time.Hour {
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
}
return s.lastHandshake.Format("2006-01-02 15:04")
}
// loadClientStatus loads the current status of the client
func (s *DetailScreen) loadClientStatus() tea.Msg {
peers, err := wireguard.GetAllPeers()
if err != nil {
return errMsg{err: err}
}
// Find peer by public key
for _, peer := range peers {
if peer.PublicKey == s.client.PublicKey {
return clientStatusLoadedMsg{
status: peer.Status,
lastHandshake: peer.LatestHandshake,
transferRx: peer.TransferRx,
transferTx: peer.TransferTx,
}
}
}
// Peer not found in active list
return clientStatusLoadedMsg{
status: wireguard.StatusDisconnected,
lastHandshake: time.Time{},
transferRx: "",
transferTx: "",
}
}
// copyPublicKey copies the public key to clipboard
func (s *DetailScreen) copyPublicKey() tea.Cmd {
return func() tea.Msg {
// Note: In a real implementation, you would use a clipboard library like
// github.com/atotto/clipboard or implement platform-specific clipboard access
// For now, we'll just simulate the action
return clipboardCopiedMsg{}
}
}
// deleteClient deletes the client
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 ClientDeletedMsg{
Name: s.client.Name,
}
}
}
// Messages
// clientStatusLoadedMsg is sent when client status is loaded
type clientStatusLoadedMsg struct {
status string
lastHandshake time.Time
transferRx string
transferTx string
}
// clipboardCopiedMsg is sent when public key is copied to clipboard
type clipboardCopiedMsg struct{}