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 // Call flow analysis selectedFlow 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 if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" || keyMsg.String() == "q" { m.subView = SubViewNone return m, nil } } // Update viewport m.viewport, cmd = m.viewport.Update(msg) 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 totalWidth := m.width // Subtract borders/padding if any innerW := totalWidth - 4 leftW := innerW / 2 rightW := innerW - leftW // --- Left Pane (Details) --- var left strings.Builder left.WriteString(m.styles.Title.Render("πŸ“ž Call Detail")) left.WriteString("\n\n") left.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID)) left.WriteString(fmt.Sprintf("From: %s\n", flow.From)) left.WriteString(fmt.Sprintf("To: %s\n", flow.To)) left.WriteString(fmt.Sprintf("State: %s\n", flow.State)) // Calculate and display duration duration := flow.EndTime.Sub(flow.StartTime) left.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond))) left.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets))) // Network Summary Section left.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) } left.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort)) left.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort)) } left.WriteString("\n\n") left.WriteString(m.styles.Help.Render("Esc: Back β€’ ↑/↓: Scroll Flow")) // --- 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) right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n", 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) } right.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label)) } } } // Set content to viewport m.viewport.SetContent(right.String()) // Ensure viewport size is correct (it might need update on resize msg, but being safe here) // We cheat a bit by updating width here during render if needed, but ideally handled in Update or Layout // But since this is a subview render, we just assume Update handled size or use current m.height // Note: You can't mutate model in Render. So we rely on Update setting viewport size. // However, we need to enforce sizing or styling on the rendered string. leftStyle := lipgloss.NewStyle().Width(leftW).Padding(1, 2) rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border // Render left pane leftRendered := leftStyle.Render(left.String()) // Render viewport (Right pane) // We style the viewport string itself or wrapper? // The viewport.View() returns the string. rightRendered := rightStyle.Render(m.viewport.View()) return lipgloss.JoinHorizontal(lipgloss.Top, leftRendered, 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 } }