Files
telephony-inspector/internal/tui/model.go
2026-01-19 14:52:38 +01:00

1113 lines
29 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
// 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(),
}
}
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("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))
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" <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
}
// 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
}
}