Files
wg-admin/internal/tui/screens/detail.go
Calmcacil 3f60ab8355 fix: add background dimming to config display modal
Config display modal now dims background content before showing,
following the same pattern as delete confirmation modal.
This provides visual consistency across all modals and makes
it clear to users when a modal is active.

Fixes: wg-admin-bfe
2026-01-12 22:44:11 +01:00

310 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.DeleteConfirmModel
showConfirm bool
configDisplay *components.ConfigDisplayModel
showConfig bool
}
// 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 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 "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 := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
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 := 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] View Config • [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,
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: "",
}
}
// 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{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
}