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 } 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" 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 } }