292 lines
7.5 KiB
Go
292 lines
7.5 KiB
Go
package screens
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
|
"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.DeleteConfirmModel
|
|
showConfirm bool
|
|
configDisplay *components.ConfigDisplayModel
|
|
showConfig bool
|
|
}
|
|
|
|
// Styles
|
|
var (
|
|
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
|
dimmedContentStyle = theme.StyleMuted
|
|
)
|
|
|
|
// 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 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
|
|
}
|
|
|
|
return s, cmd
|
|
}
|
|
|
|
// Handle config display modal
|
|
if s.showConfig && s.configDisplay != nil {
|
|
_, cmd = s.configDisplay.Update(msg)
|
|
|
|
// Handle modal close
|
|
if !s.configDisplay.IsVisible() {
|
|
s.showConfig = false
|
|
return s, nil
|
|
}
|
|
|
|
return s, cmd
|
|
}
|
|
|
|
// Handle normal screen messages
|
|
switch msg := msg.(type) {
|
|
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", "b", "esc":
|
|
// Return to list screen - signal parent to switch screens
|
|
return nil, nil
|
|
case "d":
|
|
// Show delete confirmation
|
|
s.confirmModal = components.NewDeleteConfirm(
|
|
s.client.Name,
|
|
80,
|
|
24,
|
|
)
|
|
s.showConfirm = true
|
|
case "c":
|
|
// Show client configuration
|
|
return s, s.loadConfig()
|
|
}
|
|
}
|
|
|
|
return s, cmd
|
|
}
|
|
|
|
// View renders the detail screen
|
|
func (s *DetailScreen) View() string {
|
|
// Handle config display modal
|
|
if s.showConfig && s.configDisplay != nil {
|
|
// Render underlying content dimmed
|
|
content := s.renderContent()
|
|
dimmedContent := dimmedContentStyle.Render(content)
|
|
|
|
// Overlay config display modal
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
dimmedContent,
|
|
s.configDisplay.View(),
|
|
)
|
|
}
|
|
|
|
// Handle confirmation modal
|
|
if s.showConfirm && s.confirmModal != nil {
|
|
// Render underlying content dimmed
|
|
content := s.renderContent()
|
|
dimmedContent := dimmedContentStyle.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 {
|
|
// Breadcrumb: Clients > Client Name
|
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
|
{Label: "Clients", ID: "list"},
|
|
{Label: s.client.Name, ID: "detail"},
|
|
})
|
|
|
|
statusText := s.status
|
|
if s.status == wireguard.StatusConnected {
|
|
duration := time.Since(s.lastHandshake)
|
|
quality := wireguard.CalculateQuality(duration)
|
|
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
|
|
} else {
|
|
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
|
}
|
|
|
|
// Build content
|
|
content := lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
breadcrumb,
|
|
"",
|
|
theme.StyleTitle.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)),
|
|
"",
|
|
theme.StyleSubtitle.Bold(true).MarginTop(1).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"
|
|
}())),
|
|
"",
|
|
theme.StyleSubtitle.Bold(true).MarginTop(1).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 := theme.StyleHelpKey.MarginTop(1).Render("Actions: [d] Delete • [c] View Config • [q/b] Back")
|
|
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
|
|
|
return content
|
|
}
|
|
|
|
// renderField renders a label-value pair
|
|
func (s *DetailScreen) renderField(label string, value string) string {
|
|
return lipgloss.JoinHorizontal(lipgloss.Left,
|
|
theme.StyleSubtitle.Width(18).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: "",
|
|
}
|
|
}
|
|
|
|
// loadConfig loads and displays the client configuration
|
|
func (s *DetailScreen) loadConfig() tea.Cmd {
|
|
return func() tea.Msg {
|
|
config, err := wireguard.GetClientConfigContent(s.client.Name)
|
|
if err != nil {
|
|
return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
|
|
}
|
|
|
|
// Create or update config display modal
|
|
if s.configDisplay == nil {
|
|
s.configDisplay = components.NewConfigDisplay(config, 80, 24)
|
|
} else {
|
|
s.configDisplay.Show(config)
|
|
}
|
|
s.showConfig = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|