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 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 } 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 "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": // 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 • [b] 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{}