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, 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 } 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 - 4 leftW := innerW / 2 rightW := innerW - leftW // Left Pane Heights leftTotalH := m.height - 3 // Nav + Status leftTopH := leftTotalH / 3 // 1/3 for call summary leftBotH := leftTotalH - leftTopH // 2/3 for packet details if leftTopH < 10 { leftTopH = 10 } // Min height if leftBotH < 0 { leftBotH = 0 } // --- Left Pane TOP (Call Info) --- var leftTop strings.Builder leftTop.WriteString(m.styles.Title.Render("πŸ“ž Call Detail")) leftTop.WriteString("\n\n") 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)) // Calculate and display duration 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))) // Network Summary Section leftTop.WriteString("Network Layer:\n") // Find first packet to get initial IPs if len(flow.Packets) > 0 { first := flow.Packets[0] srcLabel := m.networkMap.LabelForIP(first.SourceIP) if srcLabel != first.SourceIP { // Find node type to apply style 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) --- 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 content m.detailsViewport.SetContent(detailsContent) m.detailsViewport.Width = leftW - 2 // Account for padding/borders roughly m.detailsViewport.Height = leftBotH // --- Right Pane (Transaction Flow) --- var right strings.Builder // right.WriteString("Transaction Flow:\n\n") // Removed header to save space or added to viewport content for i, pkt := range flow.Packets { arrow := "β†’" arrowStyle := m.styles.ArrowOut // Simple direction indicator based on whether it matches initial source if len(flow.Packets) > 0 && pkt.SourceIP != flow.Packets[0].SourceIP { arrow = "←" arrowStyle = m.styles.ArrowIn } // Style the packet summary (Method or Status) 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 } } // Format timestamp ts := pkt.Timestamp.Format("15:04:05.000") // Clean packet info line (Timestamp + Arrow + Method/Status) lineStr := fmt.Sprintf("%d. [%s] %s %s", i+1, ts, arrowStyle.Render(arrow), summaryStyle.Render(pkt.Summary())) // Show SDP info if present 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) } } // Highlight selected line if i == m.selectedPacketIndex { lineStr = m.styles.Active.Render("> " + lineStr) } else { lineStr = " " + lineStr } right.WriteString(lineStr + "\n") } // Set content to viewport m.viewport.SetContent(right.String()) // Determine Border Colors based on Focus detailsBorderColor := lipgloss.Color("#44475A") flowBorderColor := lipgloss.Color("#44475A") if m.focusPacketDetails { detailsBorderColor = lipgloss.Color("#bd93f9") // Active Purple } else { flowBorderColor = lipgloss.Color("#bd93f9") } // Layout Construction leftTopStyle := lipgloss.NewStyle().Width(leftW).Height(leftTopH).Padding(1, 2) leftBotStyle := lipgloss.NewStyle().Width(leftW).Height(leftBotH).Padding(0, 1).Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(detailsBorderColor) rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(flowBorderColor) // Render left pane parts leftTopRendered := leftTopStyle.Render(leftTop.String()) // Use detailsViewport view leftBotRendered := leftBotStyle.Render(m.detailsViewport.View()) // Combine Left leftCol := lipgloss.JoinVertical(lipgloss.Left, leftTopRendered, leftBotRendered) // Render viewport (Right pane) rightRendered := rightStyle.Render(m.viewport.View()) 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" if idx := strings.Index(sipAddr, "= 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 } }