feat: Add pcap import, file browser, logging, local capture, and stable call ordering

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 14:28:56 +01:00
parent 3e5742d353
commit efb50ffc8e
19 changed files with 2660 additions and 45 deletions

View File

@@ -2,8 +2,16 @@ package tui
import (
"fmt"
"telephony-inspector/internal/config"
"strconv"
"strings"
"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"
)
@@ -18,9 +26,31 @@ const (
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
@@ -28,9 +58,29 @@ type Model struct {
networkMap *config.NetworkMap
// Capture state
capturing bool
packetCount int
lastPackets []string // Last N packet summaries
captureMode CaptureMode
sshConfig SSHConfigModel
capturer *capture.Capturer
localCapturer *capture.LocalCapturer
connected bool
capturing bool
packetCount int
lastPackets []string
captureError string
captureIface string
// Call flow analysis
callFlowStore *sip.CallFlowStore
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
@@ -44,6 +94,11 @@ type Styles struct {
Inactive lipgloss.Style
Help lipgloss.Style
StatusBar lipgloss.Style
Error lipgloss.Style
Success lipgloss.Style
Box lipgloss.Style
PacketRow lipgloss.Style
CallFlow lipgloss.Style
}
func defaultStyles() Styles {
@@ -66,17 +121,67 @@ func defaultStyles() Styles {
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),
}
}
// NewModel creates a new TUI model with default values
func NewModel() Model {
return Model{
currentView: ViewDashboard,
networkMap: config.NewNetworkMap(),
lastPackets: make([]string, 0, 20),
styles: defaultStyles(),
// 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(),
}
}
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
@@ -84,12 +189,30 @@ 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
@@ -99,18 +222,336 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
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)
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 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
m.localCapturer = capture.NewLocalCapturer()
m.localCapturer.OnPacket = func(p *sip.Packet) {
// Note: In real implementation, use channel + tea.Cmd
}
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
}
func (m *Model) startSSHCapture() tea.Cmd {
m.capturing = true
m.captureError = ""
m.packetCount = 0
m.lastPackets = m.lastPackets[:0]
m.capturer.OnPacket = func(p *sip.Packet) {
// Note: In real implementation, use channel + tea.Cmd
}
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
}
func (m *Model) stopCapture() {
if m.localCapturer != nil {
m.localCapturer.Stop()
}
if m.capturer != nil {
m.capturer.Stop()
}
m.capturing = false
}
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 {
@@ -124,15 +565,94 @@ func (m Model) View() string {
content = m.viewNetworkMap()
}
// Navigation bar
nav := m.renderNav()
// Status bar
status := m.styles.StatusBar.Render(" Telephony Inspector v0.1.0 ")
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("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))
b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
b.WriteString("Transaction Flow:\n")
for i, pkt := range flow.Packets {
arrow := "→"
if !pkt.IsRequest {
arrow = "←"
}
b.WriteString(fmt.Sprintf(" %d. %s %s\n", i+1, arrow, pkt.Summary()))
// Show SDP info if present
if pkt.SDP != nil {
mediaIP := pkt.SDP.GetSDPMediaIP()
if mediaIP != "" {
label := m.networkMap.LabelForIP(mediaIP)
b.WriteString(fmt.Sprintf(" SDP Media: %s\n", 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"}
var rendered []string
@@ -148,52 +668,163 @@ func (m Model) renderNav() string {
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + "\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")
info := lipgloss.JoinVertical(lipgloss.Left,
m.styles.Subtitle.Render("SIP Telephony Inspector"),
"",
"• Capture SIP traffic via SSH + tcpdump",
"• Analyze SIP/SDP packets in real-time",
"• Map network topology for better debugging",
"",
m.styles.Help.Render("Press 1-4 to navigate, q to quit"),
)
var stats []string
stats = append(stats, m.styles.Subtitle.Render("SIP Telephony Inspector"))
stats = append(stats, "")
return lipgloss.JoinVertical(lipgloss.Left, title, info)
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")
status := "Status: "
if m.capturing {
status += m.styles.Active.Render("● Capturing")
} else {
status += m.styles.Inactive.Render("○ Stopped")
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"))
}
packetInfo := fmt.Sprintf("Packets captured: %d", m.packetCount)
// 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"))
}
help := m.styles.Help.Render("[c] Connect SSH [s] Start/Stop Capture [q] Quit")
lines = append(lines, fmt.Sprintf("Packets: %d", m.packetCount))
lines = append(lines, "")
return lipgloss.JoinVertical(lipgloss.Left, title, status, packetInfo, "", help)
// 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 [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")
content := lipgloss.JoinVertical(lipgloss.Left,
"Call flows will appear here once packets are captured.",
"",
"Features:",
"• Group packets by Call-ID",
"• Visualize SIP transaction flow",
"• Decode SDP offers/answers",
)
flows := m.callFlowStore.GetRecentFlows(20)
return lipgloss.JoinVertical(lipgloss.Left, title, content)
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 {
@@ -203,14 +834,71 @@ func (m Model) viewNetworkMap() string {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No network nodes configured.",
"",
m.styles.Help.Render("[a] Add node [l] Load from file"),
)
"Add nodes to label IPs in your SIP infrastructure.",
"",
m.styles.Help.Render("[a] Add node [l] Load file [g] Generate sample"))
}
var nodes []string
var lines []string
for _, node := range m.networkMap.Nodes {
nodes = append(nodes, fmt.Sprintf("• %s (%s): %s", node.Name, node.Type, node.IP))
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)
}
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, nodes...))
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" <sip:user@host>
if idx := strings.Index(sipAddr, "<sip:"); idx >= 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
}