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
310 lines
7.9 KiB
Go
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
|
|
}
|