Files
telephony-inspector/internal/tui/model.go

905 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"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"
)
// 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
// Capture state
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
}
// 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
}
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),
}
}
// 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(),
}
}
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:
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 {
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("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
for i, tab := range tabs {
if View(i) == m.currentView {
rendered = append(rendered, m.styles.Active.Render(tab))
} else {
rendered = append(rendered, m.styles.Inactive.Render(tab))
}
}
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")
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 [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" <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
}