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
This commit is contained in:
299
internal/tui/screens/detail.go
Normal file
299
internal/tui/screens/detail.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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{}
|
||||
Reference in New Issue
Block a user