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" 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 flowList list.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() } return Model{ currentView: ViewDashboard, subView: SubViewNone, networkMap: nm, callFlowStore: sip.NewCallFlowStore(), lastPackets: make([]string, 0, 50), sshConfig: NewSSHConfigModel(), nodeInput: createNodeInputs(), 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 cmd tea.Cmd // Handle subview updates first if m.subView != SubViewNone { return m.updateSubView(msg) } 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: cmd = m.handleViewKeys(msg) } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height 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) } // Continue waiting if we are still capturing if m.capturing { return m, waitForPacket(m.packetChan) } case ErrorMsg: m.captureError = msg.Error.Error() } return m, cmd } 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: if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "esc" || keyMsg.String() == "q" { m.subView = SubViewNone } } return m, nil } 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] var b strings.Builder b.WriteString(m.styles.Title.Render("πŸ“ž Call Detail")) b.WriteString("\n\n") b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID)) b.WriteString(fmt.Sprintf("From: %s\n", flow.From)) b.WriteString(fmt.Sprintf("To: %s\n", flow.To)) b.WriteString(fmt.Sprintf("State: %s\n", flow.State)) // Calculate and display duration duration := flow.EndTime.Sub(flow.StartTime) b.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond))) b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets))) // Network Summary Section b.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) } b.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort)) b.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort)) } b.WriteString("\n") b.WriteString("Transaction Flow:\n") 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) b.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) } b.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label)) } } } b.WriteString("\n") b.WriteString(m.styles.Help.Render("Press Esc to go back")) return m.styles.Box.Render(b.String()) } 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 } }