Files
telephony-inspector/internal/tui/model.go

1404 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"strconv"
"strings"
"time"
"telephony-inspector/internal/capture"
"telephony-inspector/internal/config"
"telephony-inspector/internal/sip"
internalSSH "telephony-inspector/internal/ssh"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// View represents the current screen in the TUI
type View int
const (
ViewDashboard View = iota
ViewCapture
ViewAnalysis
ViewNetworkMap
)
// SubView for modal states
type SubView int
const (
SubViewNone SubView = iota
SubViewSSHConfig
SubViewAddNode
SubViewCallDetail
SubViewCaptureMenu
SubViewFileBrowser
)
// CaptureMode defines local vs SSH capture
type CaptureMode int
const (
CaptureModeNone CaptureMode = iota
CaptureModeLocal
CaptureModeSSH
)
// Model holds the application state
type Model struct {
currentView View
subView SubView
width int
height int
// Network map configuration
networkMap *config.NetworkMap
captureMode CaptureMode
sshConfig SSHConfigModel
// Capture state
capturing bool
connected bool
captureIface string
localCapturer *capture.LocalCapturer
capturer *capture.Capturer
captureError string
packetCount int
lastPackets []string
packetChan chan *sip.Packet // Channel for receiving packets from callbacks
// Data stores
callFlowStore *sip.CallFlowStore
// Call flow analysis
selectedFlow int
selectedPacketIndex int
flowList list.Model
viewport viewport.Model
// Packet Details View
detailsViewport viewport.Model
focusPacketDetails bool
// File browser for pcap import
fileBrowser FileBrowserModel
loadedPcapPath string
// Network node input
nodeInput []textinput.Model
nodeInputFocus int
// Style definitions
styles Styles
}
// Styles holds the lipgloss styles for the TUI
type Styles struct {
Title lipgloss.Style
Subtitle lipgloss.Style
Active lipgloss.Style
Inactive lipgloss.Style
Help lipgloss.Style
StatusBar lipgloss.Style
Error lipgloss.Style
Success lipgloss.Style
Box lipgloss.Style
PacketRow lipgloss.Style
CallFlow lipgloss.Style
// SIP Styles
MethodInvite lipgloss.Style
MethodBye lipgloss.Style
MethodRegister lipgloss.Style
MethodOther lipgloss.Style
Status1xx lipgloss.Style
Status2xx lipgloss.Style
Status3xx lipgloss.Style
Status4xx lipgloss.Style
Status5xx lipgloss.Style
Status6xx lipgloss.Style
NodeLabel lipgloss.Style
NodePBX lipgloss.Style
NodeProxy lipgloss.Style
NodeGateway lipgloss.Style
NodeCarrier lipgloss.Style
NodeEndpoint lipgloss.Style
NodeDefault lipgloss.Style
ArrowOut lipgloss.Style
ArrowIn lipgloss.Style
}
func defaultStyles() Styles {
return Styles{
Title: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(1),
Subtitle: lipgloss.NewStyle().
Foreground(lipgloss.Color("#AFAFAF")),
Active: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#04B575")),
Inactive: lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")),
Help: lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")).
MarginTop(1),
StatusBar: lipgloss.NewStyle().
Background(lipgloss.Color("#7D56F4")).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1),
Error: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")),
Success: lipgloss.NewStyle().
Foreground(lipgloss.Color("#50FA7B")),
Box: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 2),
PacketRow: lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")),
CallFlow: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#44475A")).
Padding(0, 1),
// SIP Styles Initialization
MethodInvite: lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")).Bold(true), // Cyan
MethodBye: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true), // Red
MethodRegister: lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true), // Green
MethodOther: lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")).Bold(true), // Purple
Status1xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C")), // Yellow
Status2xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")), // Green
Status3xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#8BE9FD")), // Cyan
Status4xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")), // Red
Status5xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true), // Red Bold
Status6xx: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true).Underline(true),
NodeLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#BD93F9")). // Purple background
Padding(0, 1).
Bold(true),
// Node Styles with different background colors
NodePBX: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#FF79C6")). // Pink
Padding(0, 1).
Bold(true),
NodeProxy: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#8BE9FD")). // Cyan
Padding(0, 1).
Bold(true),
NodeGateway: lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#F1FA8C")). // Yellow
Padding(0, 1).
Bold(true),
NodeCarrier: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#BD93F9")). // Purple
Padding(0, 1).
Bold(true),
NodeEndpoint: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#6272A4")). // Grey/Blue
Padding(0, 1).
Bold(true),
NodeDefault: lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#44475A")). // Grey
Padding(0, 1).
Bold(true),
ArrowOut: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6")), // Pink
ArrowIn: lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C")), // Yellow
}
}
// NewModel creates a new TUI model with default values
func NewModel() Model {
// Try to load existing network map
nm, err := config.LoadNetworkMap(config.DefaultNetworkMapPath())
if err != nil {
nm = config.NewNetworkMap()
}
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
vp := viewport.New(0, 0)
vp.YPosition = 0
return Model{
currentView: ViewDashboard,
subView: SubViewNone,
networkMap: nm,
callFlowStore: sip.NewCallFlowStore(),
lastPackets: make([]string, 0, 50),
sshConfig: NewSSHConfigModel(),
nodeInput: createNodeInputs(),
viewport: vp,
detailsViewport: viewport.New(0, 0),
styles: defaultStyles(),
}
}
// waitForPacket waits for a packet on the channel
func waitForPacket(ch chan *sip.Packet) tea.Cmd {
return func() tea.Msg {
return PacketMsg{Packet: <-ch}
}
}
func createNodeInputs() []textinput.Model {
inputs := make([]textinput.Model, 4)
inputs[0] = textinput.New()
inputs[0].Placeholder = "Node Name"
inputs[0].Prompt = "Name: "
inputs[0].CharLimit = 64
inputs[1] = textinput.New()
inputs[1].Placeholder = "192.168.1.x"
inputs[1].Prompt = "IP: "
inputs[1].CharLimit = 45
inputs[2] = textinput.New()
inputs[2].Placeholder = "PBX/Proxy/MediaServer/Gateway"
inputs[2].Prompt = "Type: "
inputs[2].CharLimit = 20
inputs[3] = textinput.New()
inputs[3].Placeholder = "Optional description"
inputs[3].Prompt = "Desc: "
inputs[3].CharLimit = 256
return inputs
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return nil
}
// PacketMsg is sent when a new packet is received
type PacketMsg struct {
Packet *sip.Packet
}
// ErrorMsg is sent when an error occurs
type ErrorMsg struct {
Error error
}
// Update handles messages and updates the model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// GLOBAL HANDLERS: Handle signals independent of view
switch msg := msg.(type) {
case PacketMsg:
if msg.Packet != nil {
m.packetCount++
summary := formatPacketSummary(msg.Packet, m.networkMap)
m.lastPackets = append(m.lastPackets, summary)
if len(m.lastPackets) > 50 {
m.lastPackets = m.lastPackets[1:]
}
m.callFlowStore.AddPacket(msg.Packet)
// If we are in Call Detail view, we might need to update the viewport content dynamically!
if m.subView == SubViewCallDetail {
// Re-render subview content effectively updates the strings, but
// we need to set the content on viewport again if it changed.
// This is handled in View() normally, but viewport needs SetContent.
// Let's force a viewport update by triggering a dummy message or just re-setting it.
// Actually, View() calls renderCallDetail which calls SetContent.
// But View() is only called if Update returns a modified model.
// We modified the store, so that counts.
}
}
// PROCESS NEXT PACKET - CRITICAL loop
if m.capturing {
cmds = append(cmds, waitForPacket(m.packetChan))
}
// If we processed a packet, we typically don't need to pass this msg to subviews
// UNLESS the subview reacts to it explicitly.
// For now, we return here to avoid double processing, BUT we must ensure
// UI refreshes.
return m, tea.Batch(cmds...)
case ErrorMsg:
m.captureError = msg.Error.Error()
return m, nil
}
// Handle standard key/window messages dependent on view
// Handle subview updates
if m.subView != SubViewNone {
newM, cmd := m.updateSubView(msg)
// We need to type assert back to Model because updateSubView follows the interface but returns concrete logic
// Actually updateSubView returns tea.Model, tea.Cmd.
// Since we are inside Model.Update, we can cast or just return.
realM, ok := newM.(Model)
if ok {
m = realM
}
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.cleanup()
return m, tea.Quit
case "1":
m.currentView = ViewDashboard
case "2":
m.currentView = ViewCapture
case "3":
m.currentView = ViewAnalysis
case "4":
m.currentView = ViewNetworkMap
default:
cmds = append(cmds, m.handleViewKeys(msg))
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Update viewport size for Call Detail view
// Top nav is ~1 line, Status bar ~1 line.
// Layout is vertical join of nav, content, status.
// Available height for content = Height - 2 (approx).
headerHeight := 2 // Nav + margin
footerHeight := 1 // Status bar
contentHeight := m.height - headerHeight - footerHeight
if contentHeight < 0 {
contentHeight = 0
}
m.viewport.Width = m.width / 2 // Right pane width
m.viewport.Height = contentHeight
// Correctly calculate Split Layout Dimensions for Viewports
// Ideally we should share this logic or calculate it once
totalWidth := m.width
innerW := totalWidth - 2
leftW := innerW / 2
rightW := innerW - leftW
leftTotalH := m.height - 3
leftTopH := leftTotalH / 3
if leftTopH < 10 {
leftTopH = 10
}
leftBotH := leftTotalH - leftTopH
if leftBotH < 0 {
leftBotH = 0
}
// Update Flow Viewport (Right)
rvpHeight := (leftTotalH - 2) - 2 // -2 Borders, -2 Title
if rvpHeight < 0 {
rvpHeight = 0
}
m.viewport.Height = rvpHeight
m.viewport.Width = rightW - 4
// Update Details Viewport (Left Bottom)
vpHeight := (leftBotH - 2) - 2 // -2 Borders, -2 Title
if vpHeight < 0 {
vpHeight = 0
}
m.detailsViewport.Height = vpHeight
m.detailsViewport.Width = leftW - 4
}
return m, tea.Batch(cmds...)
}
func (m *Model) handleViewKeys(msg tea.KeyMsg) tea.Cmd {
switch m.currentView {
case ViewCapture:
switch msg.String() {
case "c":
// Show capture mode menu if not capturing
if !m.capturing && m.captureMode == CaptureModeNone {
m.subView = SubViewCaptureMenu
}
case "l":
// Start local capture directly
if !m.capturing {
m.captureMode = CaptureModeLocal
m.captureIface = "any"
return m.startLocalCapture()
}
case "r":
// SSH remote capture
if !m.capturing {
m.subView = SubViewSSHConfig
m.sshConfig = NewSSHConfigModel()
return m.sshConfig.Init()
}
case "s":
if m.capturing {
m.stopCapture()
} else if m.captureMode != CaptureModeNone {
if m.captureMode == CaptureModeLocal {
return m.startLocalCapture()
} else if m.connected {
return m.startSSHCapture()
}
}
case "d":
m.disconnect()
case "p":
if !m.capturing {
m.fileBrowser = NewFileBrowser("", ".pcap")
m.subView = SubViewFileBrowser
return nil
}
}
case ViewAnalysis:
switch msg.String() {
case "up", "k":
if m.selectedFlow > 0 {
m.selectedFlow--
}
case "down", "j":
flows := m.callFlowStore.GetRecentFlows(20)
if m.selectedFlow < len(flows)-1 {
m.selectedFlow++
}
case "enter":
m.subView = SubViewCallDetail
}
case ViewNetworkMap:
switch msg.String() {
case "a":
m.subView = SubViewAddNode
m.nodeInput = createNodeInputs()
m.nodeInput[0].Focus()
m.nodeInputFocus = 0
case "l":
if nm, err := config.LoadNetworkMap(config.DefaultNetworkMapPath()); err == nil {
m.networkMap = nm
}
case "s":
config.SaveNetworkMap(m.networkMap, config.DefaultNetworkMapPath())
case "g":
m.networkMap = config.CreateSampleNetworkMap()
}
}
return nil
}
func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.subView {
case SubViewCaptureMenu:
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "l", "1":
m.captureMode = CaptureModeLocal
m.captureIface = "any"
m.subView = SubViewNone
return m, m.startLocalCapture()
case "r", "2":
m.subView = SubViewSSHConfig
m.sshConfig = NewSSHConfigModel()
return m, m.sshConfig.Init()
case "p", "3":
// Open file browser for pcap
m.fileBrowser = NewFileBrowser("", ".pcap")
m.subView = SubViewFileBrowser
return m, nil
case "esc", "q":
m.subView = SubViewNone
}
}
return m, nil
case SubViewFileBrowser:
var cmd tea.Cmd
m.fileBrowser, cmd = m.fileBrowser.Update(msg)
if m.fileBrowser.IsSelected() {
// Load the pcap file
pcapPath := m.fileBrowser.GetSelectedFile()
m.loadedPcapPath = pcapPath
m.subView = SubViewNone
// Load packets from pcap
reader := capture.NewPcapReader(pcapPath)
packets, err := reader.ReadAll()
if err != nil {
m.captureError = err.Error()
} else {
m.captureError = ""
m.packetCount = len(packets)
for _, pkt := range packets {
m.callFlowStore.AddPacket(pkt)
summary := formatPacketSummary(pkt, m.networkMap)
m.lastPackets = append(m.lastPackets, summary)
}
}
return m, nil
} else if m.fileBrowser.IsCancelled() {
m.subView = SubViewNone
}
return m, cmd
case SubViewSSHConfig:
var cmd tea.Cmd
m.sshConfig, cmd = m.sshConfig.Update(msg)
if m.sshConfig.IsSubmitted() {
host, port, user, password := m.sshConfig.GetConfig()
portInt, _ := strconv.Atoi(port)
if portInt == 0 {
portInt = 22
}
cfg := internalSSH.Config{
Host: host,
Port: portInt,
User: user,
Password: password,
}
m.capturer = capture.NewCapturer(cfg)
if err := m.capturer.Connect(); err != nil {
m.captureError = err.Error()
} else {
m.connected = true
m.captureMode = CaptureModeSSH
m.captureError = ""
}
m.subView = SubViewNone
} else if m.sshConfig.IsCancelled() {
m.subView = SubViewNone
}
return m, cmd
case SubViewAddNode:
return m.updateNodeInput(msg)
case SubViewCallDetail:
var cmd tea.Cmd
// Handle Focus Switching
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "esc", "q":
m.subView = SubViewNone
m.selectedPacketIndex = 0 // Reset selection
m.focusPacketDetails = false
return m, nil
case "tab":
m.focusPacketDetails = !m.focusPacketDetails
return m, nil
}
}
// Route keys based on focus
if m.focusPacketDetails {
// Forward keys to Details Viewport for scrolling
m.detailsViewport, cmd = m.detailsViewport.Update(msg)
return m, cmd
} else {
// Handle Flow Navigation
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "up", "k":
if m.selectedPacketIndex > 0 {
m.selectedPacketIndex--
// Sync viewport
if m.selectedPacketIndex < m.viewport.YOffset {
m.viewport.SetYOffset(m.selectedPacketIndex)
}
// Force update of details content happens in View currently, but for state consistency
// we should probably do it here or let View handle it.
// Since View is pure function of state, it's fine.
// But detailsViewport scroll position should reset if packet changes?
m.detailsViewport.GotoTop()
}
case "down", "j":
flows := m.callFlowStore.GetRecentFlows(20)
if m.selectedFlow < len(flows) {
flow := flows[m.selectedFlow]
if m.selectedPacketIndex < len(flow.Packets)-1 {
m.selectedPacketIndex++
// Sync viewport
if m.selectedPacketIndex >= m.viewport.YOffset+m.viewport.Height {
m.viewport.SetYOffset(m.selectedPacketIndex - m.viewport.Height + 1)
}
m.detailsViewport.GotoTop()
}
}
}
}
}
return m, cmd
}
return m, nil
}
func (m *Model) updateNodeInput(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "esc":
m.subView = SubViewNone
return m, nil
case "tab", "down":
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus = (m.nodeInputFocus + 1) % len(m.nodeInput)
return m, m.nodeInput[m.nodeInputFocus].Focus()
case "shift+tab", "up":
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus--
if m.nodeInputFocus < 0 {
m.nodeInputFocus = len(m.nodeInput) - 1
}
return m, m.nodeInput[m.nodeInputFocus].Focus()
case "enter":
if m.nodeInputFocus == len(m.nodeInput)-1 {
// Submit
name := m.nodeInput[0].Value()
ip := m.nodeInput[1].Value()
nodeType := m.nodeInput[2].Value()
desc := m.nodeInput[3].Value()
if name != "" && ip != "" {
m.networkMap.AddNode(config.NetworkNode{
Name: name,
IP: ip,
Type: config.NodeType(nodeType),
Description: desc,
})
}
m.subView = SubViewNone
return m, nil
}
m.nodeInput[m.nodeInputFocus].Blur()
m.nodeInputFocus++
return m, m.nodeInput[m.nodeInputFocus].Focus()
}
}
var cmd tea.Cmd
m.nodeInput[m.nodeInputFocus], cmd = m.nodeInput[m.nodeInputFocus].Update(msg)
return m, cmd
}
func (m *Model) startLocalCapture() tea.Cmd {
m.capturing = true
m.captureError = ""
m.packetCount = 0
m.lastPackets = m.lastPackets[:0]
m.captureMode = CaptureModeLocal
// Create a buffered channel for packets
m.packetChan = make(chan *sip.Packet, 100)
m.localCapturer = capture.NewLocalCapturer()
m.localCapturer.OnPacket = func(p *sip.Packet) {
if m.capturing {
m.packetChan <- p
}
}
m.localCapturer.OnError = func(err error) {
m.captureError = err.Error()
}
iface := m.captureIface
if iface == "" {
iface = "any"
}
if err := m.localCapturer.Start(iface, 5060); err != nil {
m.captureError = err.Error()
m.capturing = false
return nil
}
return waitForPacket(m.packetChan)
}
func (m *Model) startSSHCapture() tea.Cmd {
m.capturing = true
m.captureError = ""
m.packetCount = 0
m.lastPackets = m.lastPackets[:0]
// Create a buffered channel for packets
m.packetChan = make(chan *sip.Packet, 100)
m.capturer.OnPacket = func(p *sip.Packet) {
if m.capturing {
m.packetChan <- p
}
}
m.capturer.OnError = func(err error) {
m.captureError = err.Error()
}
if err := m.capturer.Start("any", 5060); err != nil {
m.captureError = err.Error()
m.capturing = false
return nil
}
return waitForPacket(m.packetChan)
}
func (m *Model) stopCapture() {
if m.localCapturer != nil {
m.localCapturer.Stop()
}
if m.capturer != nil {
m.capturer.Stop()
}
wasCapturing := m.capturing
m.capturing = false
// Unblock any waiting waitForPacket command
if wasCapturing && m.packetChan != nil {
go func() {
m.packetChan <- nil
}()
}
}
func (m *Model) disconnect() {
m.stopCapture()
if m.localCapturer != nil {
m.localCapturer.Close()
m.localCapturer = nil
}
if m.capturer != nil {
m.capturer.Close()
m.capturer = nil
}
m.connected = false
m.captureMode = CaptureModeNone
}
func (m *Model) cleanup() {
m.disconnect()
}
// View renders the TUI
func (m Model) View() string {
// Handle subview modals
if m.subView != SubViewNone {
return m.renderSubView()
}
var content string
switch m.currentView {
case ViewDashboard:
content = m.viewDashboard()
case ViewCapture:
content = m.viewCapture()
case ViewAnalysis:
content = m.viewAnalysis()
case ViewNetworkMap:
content = m.viewNetworkMap()
}
nav := m.renderNav()
status := m.renderStatusBar()
return lipgloss.JoinVertical(lipgloss.Left, nav, content, status)
}
func (m Model) renderSubView() string {
switch m.subView {
case SubViewCaptureMenu:
return m.styles.Box.Render(m.renderCaptureMenu())
case SubViewFileBrowser:
return m.styles.Box.Render(m.fileBrowser.View())
case SubViewSSHConfig:
return m.styles.Box.Render(m.sshConfig.View())
case SubViewAddNode:
return m.styles.Box.Render(m.renderAddNodeForm())
case SubViewCallDetail:
return m.renderCallDetail()
}
return ""
}
func (m Model) renderCaptureMenu() string {
var b strings.Builder
b.WriteString(m.styles.Title.Render("📡 Select Capture Mode"))
b.WriteString("\n\n")
b.WriteString(" [1] [L]ocal - Capture on this machine (requires tcpdump)\n")
b.WriteString(" [2] [R]emote - Capture via SSH on remote server\n")
b.WriteString(" [3] [P]cap - Import pcap file from disk\n")
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Press 1/L, 2/R, or 3/P to select • Esc to cancel"))
return b.String()
}
func (m Model) renderAddNodeForm() string {
var b strings.Builder
b.WriteString(m.styles.Title.Render(" Add Network Node"))
b.WriteString("\n\n")
for _, input := range m.nodeInput {
b.WriteString(input.View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Node Types: PBX, Proxy, SBC, Carrier, Handset, Firewall"))
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Tab navigate • Enter submit • Esc cancel"))
return b.String()
}
func (m Model) renderCallDetail() string {
flows := m.callFlowStore.GetRecentFlows(20)
if m.selectedFlow >= len(flows) || len(flows) == 0 {
return m.styles.Box.Render("No call selected\n\nPress Esc to go back")
}
flow := flows[m.selectedFlow]
// Split Widths and Heights
totalWidth := m.width
// Subtract borders/padding if any
innerW := totalWidth - 2 // Outer margin?
leftW := innerW / 2
rightW := innerW - leftW
// Left Pane Heights
leftTotalH := m.height - 3 // Nav + Status
// Adjust for borders: we have two boxes stacked.
// Let's say we split space 33% / 66%.
leftTopH := leftTotalH / 3
leftBotH := leftTotalH - leftTopH
if leftTopH < 10 {
leftTopH = 10
}
// Determine Border Colors based on Focus
detailsBorderColor := lipgloss.Color("#44475A")
flowBorderColor := lipgloss.Color("#44475A")
infoBorderColor := lipgloss.Color("#44475A") // Call info usually static focus
if m.focusPacketDetails {
detailsBorderColor = lipgloss.Color("#bd93f9") // Active Purple
} else {
flowBorderColor = lipgloss.Color("#bd93f9")
}
// Definition of Styles
// We want Borders on ALL sides now since they are distinct boxes
baseStyle := lipgloss.NewStyle().Padding(0, 1).Border(lipgloss.RoundedBorder())
leftTopStyle := baseStyle.Copy().
Width(leftW - 2). // -2 for borders
Height(leftTopH - 2). // -2 for borders
BorderForeground(infoBorderColor)
leftBotStyle := baseStyle.Copy().
Width(leftW - 2).
Height(leftBotH - 2).
BorderForeground(detailsBorderColor)
rightStyle := baseStyle.Copy().
Width(rightW - 2).
Height(leftTotalH - 2).
BorderForeground(flowBorderColor)
// --- Left Pane TOP (Call Info) ---
var leftTop strings.Builder
// Title
leftTop.WriteString(m.styles.Title.Render("📞 Call Detail"))
leftTop.WriteString("\n\n")
// Content
leftTop.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
leftTop.WriteString(fmt.Sprintf("From: %s\n", flow.From))
leftTop.WriteString(fmt.Sprintf("To: %s\n", flow.To))
leftTop.WriteString(fmt.Sprintf("State: %s\n", flow.State))
duration := flow.EndTime.Sub(flow.StartTime)
leftTop.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
leftTop.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
leftTop.WriteString("Network Layer:\n")
if len(flow.Packets) > 0 {
first := flow.Packets[0]
srcLabel := m.networkMap.LabelForIP(first.SourceIP)
if srcLabel != first.SourceIP {
node := m.networkMap.FindByIP(first.SourceIP)
srcLabel = m.styleForNode(node).Render(srcLabel)
}
dstLabel := m.networkMap.LabelForIP(first.DestIP)
if dstLabel != first.DestIP {
node := m.networkMap.FindByIP(first.DestIP)
dstLabel = m.styleForNode(node).Render(dstLabel)
}
leftTop.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort))
leftTop.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort))
}
// --- Left Pane BOTTOM (Selected Packet Details) ---
// Title
detailsTitle := m.styles.Title.Render("📦 Packet Details")
// Content
var detailsContent string
if m.selectedPacketIndex < len(flow.Packets) {
pkt := flow.Packets[m.selectedPacketIndex]
detailsContent = fmt.Sprintf("Time: %s\nIP: %s -> %s\n\n%s",
pkt.Timestamp.Format("15:04:05.000"),
pkt.SourceIP, pkt.DestIP,
pkt.Raw)
} else {
detailsContent = "No packet selected"
}
// Update details viewport
// Calculate available height: Pane Height - Borders(2) - Header(2 approx)
// Actually styles handle borders on the container.
// Viewport height should be container InnerHeight - TitleHeight - Padding.
// leftBotStyle.GetHeight() returns outer height.
// We set style height explicitly, so we know it.
vpHeight := (leftBotH - 2) - 2 // -2 Borders, -2 Title/Margin
if vpHeight < 0 {
vpHeight = 0
}
m.detailsViewport.Width = leftW - 4 // Margin/Padding
m.detailsViewport.Height = vpHeight
m.detailsViewport.SetContent(detailsContent)
// --- Right Pane (Transaction Flow) ---
// Title
flowTitle := m.styles.Title.Render("🚀 Transaction Flow")
var right strings.Builder
for i, pkt := range flow.Packets {
arrow := "→"
arrowStyle := m.styles.ArrowOut
if len(flow.Packets) > 0 && pkt.SourceIP != flow.Packets[0].SourceIP {
arrow = "←"
arrowStyle = m.styles.ArrowIn
}
var summaryStyle lipgloss.Style
if pkt.IsRequest {
switch pkt.Method {
case sip.MethodINVITE:
summaryStyle = m.styles.MethodInvite
case sip.MethodBYE, sip.MethodCANCEL:
summaryStyle = m.styles.MethodBye
case sip.MethodREGISTER:
summaryStyle = m.styles.MethodRegister
default:
summaryStyle = m.styles.MethodOther
}
} else {
switch {
case pkt.StatusCode >= 100 && pkt.StatusCode < 200:
summaryStyle = m.styles.Status1xx
case pkt.StatusCode >= 200 && pkt.StatusCode < 300:
summaryStyle = m.styles.Status2xx
case pkt.StatusCode >= 300 && pkt.StatusCode < 400:
summaryStyle = m.styles.Status3xx
case pkt.StatusCode >= 400 && pkt.StatusCode < 500:
summaryStyle = m.styles.Status4xx
case pkt.StatusCode >= 500 && pkt.StatusCode < 600:
summaryStyle = m.styles.Status5xx
default:
summaryStyle = m.styles.Status6xx
}
}
ts := pkt.Timestamp.Format("15:04:05.000")
lineStr := fmt.Sprintf("%d. [%s] %s %s", i+1, ts, arrowStyle.Render(arrow), summaryStyle.Render(pkt.Summary()))
if pkt.SDP != nil {
mediaIP := pkt.SDP.GetSDPMediaIP()
if mediaIP != "" {
label := m.networkMap.LabelForIP(mediaIP)
if label != mediaIP {
node := m.networkMap.FindByIP(mediaIP)
label = m.styleForNode(node).Render(label)
}
lineStr += fmt.Sprintf(" (SDP: %s %s)", mediaIP, label)
}
}
if i == m.selectedPacketIndex {
lineStr = m.styles.Active.Render("> " + lineStr)
} else {
lineStr = " " + lineStr
}
right.WriteString(lineStr + "\n")
}
m.viewport.SetContent(right.String())
// Right Pane Height logic
rvpHeight := (leftTotalH - 2) - 2 // -2 Borders, -2 Title/Margin
if rvpHeight < 0 {
rvpHeight = 0
}
m.viewport.Height = rvpHeight
m.viewport.Width = rightW - 4
// Render Final Layout
// Left Top: Just content
leftTopRendered := leftTopStyle.Render(leftTop.String())
// Left Bot: Title + Viewport
leftBotContent := lipgloss.JoinVertical(lipgloss.Left, detailsTitle, "\n", m.detailsViewport.View())
leftBotRendered := leftBotStyle.Render(leftBotContent)
// Left Col
leftCol := lipgloss.JoinVertical(lipgloss.Left, leftTopRendered, leftBotRendered)
// Right: Title + Viewport
rightContent := lipgloss.JoinVertical(lipgloss.Left, flowTitle, "\n", m.viewport.View())
rightRendered := rightStyle.Render(rightContent)
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightRendered)
}
func (m Model) renderNav() string {
tabs := []string{"[1] Dashboard", "[2] Capture", "[3] Analysis", "[4] Network Map"}
// Calculate tab width for even distribution
tabWidth := m.width / len(tabs)
if tabWidth < 15 {
tabWidth = 15
}
// Style for active and inactive tabs with fixed width
activeStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#7D56F4")).
Width(tabWidth).
Align(lipgloss.Center)
inactiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AFAFAF")).
Background(lipgloss.Color("#282A36")).
Width(tabWidth).
Align(lipgloss.Center)
var rendered []string
for i, tab := range tabs {
if View(i) == m.currentView {
rendered = append(rendered, activeStyle.Render(tab))
} else {
rendered = append(rendered, inactiveStyle.Render(tab))
}
}
// Join tabs and add background bar
navBar := lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
// Fill remaining width with background
barStyle := lipgloss.NewStyle().
Background(lipgloss.Color("#282A36")).
Width(m.width)
return barStyle.Render(navBar) + "\n"
}
func (m Model) renderStatusBar() string {
var parts []string
parts = append(parts, " Telephony Inspector v0.1.0 ")
if m.connected {
parts = append(parts, m.styles.Success.Render(" SSH: Connected "))
}
if m.capturing {
parts = append(parts, m.styles.Active.Render(fmt.Sprintf(" Capturing: %d pkts ", m.packetCount)))
}
if m.captureError != "" {
parts = append(parts, m.styles.Error.Render(" Error: "+m.captureError+" "))
}
return m.styles.StatusBar.Render(strings.Join(parts, "|"))
}
func (m Model) viewDashboard() string {
title := m.styles.Title.Render("📞 Dashboard")
var stats []string
stats = append(stats, m.styles.Subtitle.Render("SIP Telephony Inspector"))
stats = append(stats, "")
if m.connected {
stats = append(stats, m.styles.Success.Render("✓ SSH Connected"))
} else {
stats = append(stats, m.styles.Inactive.Render("○ SSH Disconnected"))
}
stats = append(stats, fmt.Sprintf("Network Nodes: %d", len(m.networkMap.Nodes)))
stats = append(stats, fmt.Sprintf("Active Calls: %d", m.callFlowStore.Count()))
stats = append(stats, fmt.Sprintf("Packets Captured: %d", m.packetCount))
stats = append(stats, "")
stats = append(stats, "Quick Start:")
stats = append(stats, " 1. Go to [2] Capture → Press 'c' to connect SSH")
stats = append(stats, " 2. Press 's' to start capturing SIP traffic")
stats = append(stats, " 3. Go to [3] Analysis to view call flows")
stats = append(stats, " 4. Go to [4] Network Map to label IPs")
stats = append(stats, "")
stats = append(stats, m.styles.Help.Render("Press 1-4 to navigate, q to quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, stats...))
}
func (m Model) viewCapture() string {
title := m.styles.Title.Render("🔍 Capture")
var lines []string
// Mode indicator
switch m.captureMode {
case CaptureModeLocal:
lines = append(lines, m.styles.Success.Render("● Mode: Local capture"))
case CaptureModeSSH:
if m.connected {
lines = append(lines, m.styles.Success.Render("● Mode: SSH (connected)"))
} else {
lines = append(lines, m.styles.Inactive.Render("○ Mode: SSH (disconnected)"))
}
default:
lines = append(lines, m.styles.Inactive.Render("○ No capture mode selected"))
}
// Capture status
if m.capturing {
mode := "local"
if m.captureMode == CaptureModeSSH {
mode = "SSH"
}
lines = append(lines, m.styles.Active.Render(fmt.Sprintf("● Capturing on port 5060 (%s)", mode)))
} else if m.captureMode != CaptureModeNone {
lines = append(lines, m.styles.Inactive.Render("○ Capture stopped"))
}
lines = append(lines, fmt.Sprintf("Packets: %d", m.packetCount))
lines = append(lines, "")
// Last packets
if len(m.lastPackets) > 0 {
lines = append(lines, "Recent Packets:")
start := 0
if len(m.lastPackets) > 15 {
start = len(m.lastPackets) - 15
}
for _, pkt := range m.lastPackets[start:] {
lines = append(lines, " "+pkt)
}
}
lines = append(lines, "")
// Help
var help string
if m.captureMode == CaptureModeNone {
help = "[c] Choose mode [l] Local [r] Remote SSH [p] Pcap Import [q] Quit"
} else if !m.capturing {
help = "[s] Start [c] Change mode [d] Disconnect [q] Quit"
} else {
help = "[s] Stop [d] Disconnect [q] Quit"
}
lines = append(lines, m.styles.Help.Render(help))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
func (m Model) viewAnalysis() string {
title := m.styles.Title.Render("📊 Analysis")
flows := m.callFlowStore.GetRecentFlows(20)
if len(flows) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No calls captured yet.",
"",
"Start capturing on the Capture tab to see call flows here.",
"",
m.styles.Help.Render("Press 2 to go to Capture"))
}
var lines []string
lines = append(lines, fmt.Sprintf("Calls: %d", len(flows)))
lines = append(lines, "")
for i, flow := range flows {
prefix := " "
style := m.styles.Inactive
if i == m.selectedFlow {
prefix = "▶ "
style = m.styles.Active
}
stateIcon := "○"
switch flow.State {
case sip.CallStateRinging:
stateIcon = "◐"
case sip.CallStateConnected:
stateIcon = "●"
case sip.CallStateTerminated:
stateIcon = "◯"
case sip.CallStateFailed:
stateIcon = "✕"
}
summary := fmt.Sprintf("%s%s %s → %s [%d pkts]",
prefix, stateIcon,
truncate(extractUser(flow.From), 15),
truncate(extractUser(flow.To), 15),
len(flow.Packets))
lines = append(lines, style.Render(summary))
}
lines = append(lines, "")
lines = append(lines, m.styles.Help.Render("↑/↓ select • Enter details • q quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
func (m Model) viewNetworkMap() string {
title := m.styles.Title.Render("🗺️ Network Map")
if len(m.networkMap.Nodes) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No network nodes configured.",
"",
"Add nodes to label IPs in your SIP infrastructure.",
"",
m.styles.Help.Render("[a] Add node [l] Load file [g] Generate sample"))
}
var lines []string
for _, node := range m.networkMap.Nodes {
icon := "○"
switch node.Type {
case config.NodeTypePBX:
icon = "☎"
case config.NodeTypeProxy:
icon = "⇄"
case config.NodeTypeMediaServer:
icon = "♪"
case config.NodeTypeGateway:
icon = "⬚"
}
line := fmt.Sprintf(" %s %s (%s): %s", icon, node.Name, node.Type, node.IP)
if node.Description != "" {
line += " - " + node.Description
}
lines = append(lines, line)
}
lines = append(lines, "")
lines = append(lines, m.styles.Help.Render("[a] Add [s] Save [l] Load [g] Sample [q] Quit"))
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
}
// Helper functions
func formatPacketSummary(p *sip.Packet, nm *config.NetworkMap) string {
src := nm.LabelForIP(p.SourceIP)
dst := nm.LabelForIP(p.DestIP)
if p.IsRequest {
return fmt.Sprintf("%s → %s: %s", src, dst, p.Method)
}
return fmt.Sprintf("%s → %s: %d %s", src, dst, p.StatusCode, p.StatusText)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
func extractUser(sipAddr string) string {
// Extract user from "Display Name" <sip:user@host>
if idx := strings.Index(sipAddr, "<sip:"); idx >= 0 {
start := idx + 5
end := strings.Index(sipAddr[start:], "@")
if end > 0 {
return sipAddr[start : start+end]
}
}
if idx := strings.Index(sipAddr, "sip:"); idx >= 0 {
start := idx + 4
end := strings.Index(sipAddr[start:], "@")
if end > 0 {
return sipAddr[start : start+end]
}
}
return sipAddr
}
// styleForNode returns the style for a given node type
func (m Model) styleForNode(node *config.NetworkNode) lipgloss.Style {
if node == nil {
return m.styles.NodeDefault
}
switch node.Type {
case config.NodeTypePBX:
return m.styles.NodePBX
case config.NodeTypeProxy:
return m.styles.NodeProxy
case config.NodeTypeGateway:
return m.styles.NodeGateway
case config.NodeTypeMediaServer:
// Reusing Proxy style for MediaServer if no specific one defined
return m.styles.NodeProxy
case config.NodeTypeEndpoint:
return m.styles.NodeEndpoint
case config.NodeTypeUnknown:
return m.styles.NodeDefault
default:
// Attempt to match by name if type is custom or carrier
lowerName := strings.ToLower(node.Name)
if strings.Contains(lowerName, "carrier") || strings.Contains(lowerName, "provider") {
return m.styles.NodeCarrier
}
return m.styles.NodeDefault
}
}