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 // 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 // We handle scrolling manually via selection now if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { case "esc", "q": m.subView = SubViewNone m.selectedPacketIndex = 0 // Reset selection return m, nil case "up", "k": if m.selectedPacketIndex > 0 { m.selectedPacketIndex-- // Sync viewport if m.selectedPacketIndex < m.viewport.YOffset { m.viewport.SetYOffset(m.selectedPacketIndex) } } 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) } } } } } // We generally don't need viewport.Update for keys anymore as we handle scroll manually // But we might need it for resizing or other generic msgs? // Actually viewport.Update handles scroll keys by default. // If we return cmd from it, it might conflict. // Let's NOT call viewport.Update for keys if we consumed them. 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 leftBot strings.Builder leftBot.WriteString(m.styles.Title.Render("πŸ“¦ Packet Details")) leftBot.WriteString("\n\n") if m.selectedPacketIndex < len(flow.Packets) { pkt := flow.Packets[m.selectedPacketIndex] leftBot.WriteString(fmt.Sprintf("Time: %s\n", pkt.Timestamp.Format("15:04:05.000"))) leftBot.WriteString(fmt.Sprintf("IP: %s -> %s\n\n", pkt.SourceIP, pkt.DestIP)) // Render Payload/Raw // Truncate if too long? raw := pkt.Raw // Simple header parsing or just raw dump? // Raw dump is useful. leftBot.WriteString(raw) } else { leftBot.WriteString("No packet selected") } // --- 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()) // Layout Construction leftTopStyle := lipgloss.NewStyle().Width(leftW).Height(leftTopH).Padding(1, 2) leftBotStyle := lipgloss.NewStyle().Width(leftW).Height(leftBotH).Padding(1, 2).Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(lipgloss.Color("#44475A")) // Top border for bottom pane rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border for right pane // Render left pane parts leftTopRendered := leftTopStyle.Render(leftTop.String()) leftBotRendered := leftBotStyle.Render(leftBot.String()) // 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 } }