2026-01-19 13:46:27 +01:00
package tui
import (
"fmt"
2026-01-19 14:28:56 +01:00
"strconv"
"strings"
2026-01-19 15:02:27 +01:00
"time"
2026-01-19 14:28:56 +01:00
"telephony-inspector/internal/capture"
2026-01-19 13:46:27 +01:00
"telephony-inspector/internal/config"
2026-01-19 14:28:56 +01:00
"telephony-inspector/internal/sip"
internalSSH "telephony-inspector/internal/ssh"
2026-01-19 13:46:27 +01:00
2026-01-19 14:28:56 +01:00
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
2026-01-19 16:12:51 +01:00
"github.com/charmbracelet/bubbles/viewport"
2026-01-19 13:46:27 +01:00
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
)
2026-01-19 14:28:56 +01:00
// 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
)
2026-01-19 13:46:27 +01:00
// Model holds the application state
type Model struct {
currentView View
2026-01-19 14:28:56 +01:00
subView SubView
2026-01-19 13:46:27 +01:00
width int
height int
// Network map configuration
2026-01-19 15:51:25 +01:00
networkMap * config . NetworkMap
captureMode CaptureMode
sshConfig SSHConfigModel
2026-01-19 13:46:27 +01:00
// Capture state
2026-01-19 14:28:56 +01:00
capturing bool
2026-01-19 15:51:25 +01:00
connected bool
captureIface string
localCapturer * capture . LocalCapturer
capturer * capture . Capturer
captureError string
2026-01-19 14:28:56 +01:00
packetCount int
lastPackets [ ] string
2026-01-19 15:51:25 +01:00
packetChan chan * sip . Packet // Channel for receiving packets from callbacks
// Data stores
2026-01-19 14:28:56 +01:00
callFlowStore * sip . CallFlowStore
2026-01-19 15:51:25 +01:00
2026-01-19 16:12:51 +01:00
// Call flow analysis
2026-01-19 15:51:25 +01:00
// Call flow analysis
selectedFlow int
flowList list . Model
2026-01-19 16:12:51 +01:00
viewport viewport . Model
2026-01-19 14:28:56 +01:00
// File browser for pcap import
fileBrowser FileBrowserModel
loadedPcapPath string
// Network node input
nodeInput [ ] textinput . Model
nodeInputFocus int
2026-01-19 13:46:27 +01:00
// 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
2026-01-19 14:28:56 +01:00
Error lipgloss . Style
Success lipgloss . Style
Box lipgloss . Style
PacketRow lipgloss . Style
CallFlow lipgloss . Style
2026-01-19 14:48:03 +01:00
// 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
2026-01-19 13:46:27 +01:00
}
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 ) ,
2026-01-19 14:28:56 +01:00
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 ) ,
2026-01-19 14:48:03 +01:00
// 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
2026-01-19 13:46:27 +01:00
}
}
// NewModel creates a new TUI model with default values
func NewModel ( ) Model {
2026-01-19 14:28:56 +01:00
// Try to load existing network map
nm , err := config . LoadNetworkMap ( config . DefaultNetworkMapPath ( ) )
if err != nil {
nm = config . NewNetworkMap ( )
}
2026-01-19 16:12:51 +01:00
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
vp := viewport . New ( 0 , 0 )
vp . YPosition = 0
2026-01-19 13:46:27 +01:00
return Model {
2026-01-19 14:28:56 +01:00
currentView : ViewDashboard ,
subView : SubViewNone ,
networkMap : nm ,
callFlowStore : sip . NewCallFlowStore ( ) ,
lastPackets : make ( [ ] string , 0 , 50 ) ,
sshConfig : NewSSHConfigModel ( ) ,
nodeInput : createNodeInputs ( ) ,
2026-01-19 16:12:51 +01:00
viewport : vp ,
2026-01-19 14:28:56 +01:00
styles : defaultStyles ( ) ,
2026-01-19 13:46:27 +01:00
}
}
2026-01-19 15:51:25 +01:00
// waitForPacket waits for a packet on the channel
func waitForPacket ( ch chan * sip . Packet ) tea . Cmd {
return func ( ) tea . Msg {
return PacketMsg { Packet : <- ch }
}
}
2026-01-19 14:28:56 +01:00
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
}
2026-01-19 13:46:27 +01:00
// Init initializes the model
func ( m Model ) Init ( ) tea . Cmd {
return nil
}
2026-01-19 14:28:56 +01:00
// 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
}
2026-01-19 13:46:27 +01:00
// Update handles messages and updates the model
func ( m Model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
2026-01-19 14:28:56 +01:00
var cmd tea . Cmd
// Handle subview updates first
if m . subView != SubViewNone {
return m . updateSubView ( msg )
}
2026-01-19 13:46:27 +01:00
switch msg := msg . ( type ) {
case tea . KeyMsg :
switch msg . String ( ) {
case "q" , "ctrl+c" :
2026-01-19 14:28:56 +01:00
m . cleanup ( )
2026-01-19 13:46:27 +01:00
return m , tea . Quit
case "1" :
m . currentView = ViewDashboard
case "2" :
m . currentView = ViewCapture
case "3" :
m . currentView = ViewAnalysis
case "4" :
m . currentView = ViewNetworkMap
2026-01-19 14:28:56 +01:00
default :
cmd = m . handleViewKeys ( msg )
2026-01-19 13:46:27 +01:00
}
case tea . WindowSizeMsg :
m . width = msg . Width
m . height = msg . Height
2026-01-19 14:28:56 +01:00
2026-01-19 16:12:51 +01:00
// Update viewport size for Call Detail view
// Top nav is ~1 line, Status bar ~1 line.
// Layout is vertical join of nav, content, status.
// Available height for content = Height - 2 (approx).
headerHeight := 2 // Nav + margin
footerHeight := 1 // Status bar
contentHeight := m . height - headerHeight - footerHeight
if contentHeight < 0 {
contentHeight = 0
}
m . viewport . Width = m . width / 2 // Right pane width
m . viewport . Height = contentHeight
2026-01-19 14:28:56 +01:00
case PacketMsg :
2026-01-19 15:51:25 +01:00
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 )
2026-01-19 14:28:56 +01:00
}
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 ( )
2026-01-19 14:54:49 +01:00
case "p" :
if ! m . capturing {
m . fileBrowser = NewFileBrowser ( "" , ".pcap" )
m . subView = SubViewFileBrowser
return nil
}
2026-01-19 14:28:56 +01:00
}
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 :
2026-01-19 16:12:51 +01:00
var cmd tea . Cmd
2026-01-19 14:28:56 +01:00
if keyMsg , ok := msg . ( tea . KeyMsg ) ; ok {
if keyMsg . String ( ) == "esc" || keyMsg . String ( ) == "q" {
m . subView = SubViewNone
2026-01-19 16:12:51 +01:00
return m , nil
2026-01-19 14:28:56 +01:00
}
}
2026-01-19 16:12:51 +01:00
// Update viewport
m . viewport , cmd = m . viewport . Update ( msg )
return m , cmd
2026-01-19 13:46:27 +01:00
}
return m , nil
}
2026-01-19 14:28:56 +01:00
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
2026-01-19 15:51:25 +01:00
// Create a buffered channel for packets
m . packetChan = make ( chan * sip . Packet , 100 )
2026-01-19 14:28:56 +01:00
m . localCapturer = capture . NewLocalCapturer ( )
m . localCapturer . OnPacket = func ( p * sip . Packet ) {
2026-01-19 15:51:25 +01:00
if m . capturing {
m . packetChan <- p
}
2026-01-19 14:28:56 +01:00
}
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
2026-01-19 15:51:25 +01:00
return nil
2026-01-19 14:28:56 +01:00
}
2026-01-19 15:51:25 +01:00
return waitForPacket ( m . packetChan )
2026-01-19 14:28:56 +01:00
}
func ( m * Model ) startSSHCapture ( ) tea . Cmd {
m . capturing = true
m . captureError = ""
m . packetCount = 0
m . lastPackets = m . lastPackets [ : 0 ]
2026-01-19 15:51:25 +01:00
// Create a buffered channel for packets
m . packetChan = make ( chan * sip . Packet , 100 )
2026-01-19 14:28:56 +01:00
m . capturer . OnPacket = func ( p * sip . Packet ) {
2026-01-19 15:51:25 +01:00
if m . capturing {
m . packetChan <- p
}
2026-01-19 14:28:56 +01:00
}
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
2026-01-19 15:51:25 +01:00
return nil
2026-01-19 14:28:56 +01:00
}
2026-01-19 15:51:25 +01:00
return waitForPacket ( m . packetChan )
2026-01-19 14:28:56 +01:00
}
func ( m * Model ) stopCapture ( ) {
if m . localCapturer != nil {
m . localCapturer . Stop ( )
}
if m . capturer != nil {
m . capturer . Stop ( )
}
2026-01-19 15:51:25 +01:00
wasCapturing := m . capturing
2026-01-19 14:28:56 +01:00
m . capturing = false
2026-01-19 15:51:25 +01:00
// Unblock any waiting waitForPacket command
if wasCapturing && m . packetChan != nil {
go func ( ) {
m . packetChan <- nil
} ( )
}
2026-01-19 14:28:56 +01:00
}
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 ( )
}
2026-01-19 13:46:27 +01:00
// View renders the TUI
func ( m Model ) View ( ) string {
2026-01-19 14:28:56 +01:00
// Handle subview modals
if m . subView != SubViewNone {
return m . renderSubView ( )
}
2026-01-19 13:46:27 +01:00
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 ( )
2026-01-19 14:28:56 +01:00
status := m . renderStatusBar ( )
2026-01-19 13:46:27 +01:00
return lipgloss . JoinVertical ( lipgloss . Left , nav , content , status )
}
2026-01-19 14:28:56 +01:00
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" )
2026-01-19 14:48:03 +01:00
b . WriteString ( m . styles . Help . Render ( "Node Types: PBX, Proxy, SBC, Carrier, Handset, Firewall" ) )
b . WriteString ( "\n" )
2026-01-19 14:28:56 +01:00
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 ]
2026-01-19 16:12:51 +01:00
// Split Widths
totalWidth := m . width
// Subtract borders/padding if any
innerW := totalWidth - 4
leftW := innerW / 2
rightW := innerW - leftW
// --- Left Pane (Details) ---
var left strings . Builder
left . WriteString ( m . styles . Title . Render ( "📞 Call Detail" ) )
left . WriteString ( "\n\n" )
left . WriteString ( fmt . Sprintf ( "Call-ID: %s\n" , flow . CallID ) )
left . WriteString ( fmt . Sprintf ( "From: %s\n" , flow . From ) )
left . WriteString ( fmt . Sprintf ( "To: %s\n" , flow . To ) )
left . WriteString ( fmt . Sprintf ( "State: %s\n" , flow . State ) )
2026-01-19 15:02:27 +01:00
// Calculate and display duration
duration := flow . EndTime . Sub ( flow . StartTime )
2026-01-19 16:12:51 +01:00
left . WriteString ( fmt . Sprintf ( "Duration: %s\n" , duration . Round ( time . Millisecond ) ) )
2026-01-19 15:02:27 +01:00
2026-01-19 16:12:51 +01:00
left . WriteString ( fmt . Sprintf ( "Packets: %d\n\n" , len ( flow . Packets ) ) )
2026-01-19 14:28:56 +01:00
2026-01-19 14:36:01 +01:00
// Network Summary Section
2026-01-19 16:12:51 +01:00
left . WriteString ( "Network Layer:\n" )
2026-01-19 14:36:01 +01:00
// Find first packet to get initial IPs
if len ( flow . Packets ) > 0 {
first := flow . Packets [ 0 ]
2026-01-19 14:48:03 +01:00
2026-01-19 14:36:01 +01:00
srcLabel := m . networkMap . LabelForIP ( first . SourceIP )
2026-01-19 14:48:03 +01:00
if srcLabel != first . SourceIP {
// Find node type to apply style
node := m . networkMap . FindByIP ( first . SourceIP )
srcLabel = m . styleForNode ( node ) . Render ( srcLabel )
}
2026-01-19 14:36:01 +01:00
dstLabel := m . networkMap . LabelForIP ( first . DestIP )
2026-01-19 14:48:03 +01:00
if dstLabel != first . DestIP {
node := m . networkMap . FindByIP ( first . DestIP )
dstLabel = m . styleForNode ( node ) . Render ( dstLabel )
}
2026-01-19 14:36:01 +01:00
2026-01-19 16:12:51 +01:00
left . WriteString ( fmt . Sprintf ( " Source: %s (%s:%d)\n" , srcLabel , first . SourceIP , first . SourcePort ) )
left . WriteString ( fmt . Sprintf ( " Destination: %s (%s:%d)\n" , dstLabel , first . DestIP , first . DestPort ) )
2026-01-19 14:36:01 +01:00
}
2026-01-19 16:12:51 +01:00
left . WriteString ( "\n\n" )
left . WriteString ( m . styles . Help . Render ( "Esc: Back • ↑/↓: Scroll Flow" ) )
// --- Right Pane (Transaction Flow) ---
var right strings . Builder
// right.WriteString("Transaction Flow:\n\n") // Removed header to save space or added to viewport content
2026-01-19 14:36:01 +01:00
2026-01-19 14:28:56 +01:00
for i , pkt := range flow . Packets {
arrow := "→"
2026-01-19 14:48:03 +01:00
arrowStyle := m . styles . ArrowOut
2026-01-19 14:36:01 +01:00
// Simple direction indicator based on whether it matches initial source
if len ( flow . Packets ) > 0 && pkt . SourceIP != flow . Packets [ 0 ] . SourceIP {
2026-01-19 14:28:56 +01:00
arrow = "←"
2026-01-19 14:48:03 +01:00
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
}
2026-01-19 14:28:56 +01:00
}
2026-01-19 14:34:43 +01:00
// Format timestamp
ts := pkt . Timestamp . Format ( "15:04:05.000" )
2026-01-19 14:36:01 +01:00
// Clean packet info line (Timestamp + Arrow + Method/Status)
2026-01-19 16:12:51 +01:00
right . WriteString ( fmt . Sprintf ( "%d. [%s] %s %s\n" ,
2026-01-19 14:48:03 +01:00
i + 1 ,
ts ,
arrowStyle . Render ( arrow ) ,
summaryStyle . Render ( pkt . Summary ( ) ) ) )
2026-01-19 14:28:56 +01:00
// Show SDP info if present
if pkt . SDP != nil {
mediaIP := pkt . SDP . GetSDPMediaIP ( )
if mediaIP != "" {
label := m . networkMap . LabelForIP ( mediaIP )
2026-01-19 14:48:03 +01:00
if label != mediaIP {
node := m . networkMap . FindByIP ( mediaIP )
label = m . styleForNode ( node ) . Render ( label )
}
2026-01-19 16:12:51 +01:00
right . WriteString ( fmt . Sprintf ( " SDP Media: %s %s\n" , mediaIP , label ) )
2026-01-19 14:28:56 +01:00
}
}
}
2026-01-19 16:12:51 +01:00
// Set content to viewport
m . viewport . SetContent ( right . String ( ) )
// Ensure viewport size is correct (it might need update on resize msg, but being safe here)
// We cheat a bit by updating width here during render if needed, but ideally handled in Update or Layout
// But since this is a subview render, we just assume Update handled size or use current m.height
// Note: You can't mutate model in Render. So we rely on Update setting viewport size.
// However, we need to enforce sizing or styling on the rendered string.
leftStyle := lipgloss . NewStyle ( ) . Width ( leftW ) . Padding ( 1 , 2 )
rightStyle := lipgloss . NewStyle ( ) . Width ( rightW ) . Padding ( 0 , 1 ) . Border ( lipgloss . NormalBorder ( ) , false , false , false , true ) . BorderForeground ( lipgloss . Color ( "#44475A" ) ) // Left border
// Render left pane
leftRendered := leftStyle . Render ( left . String ( ) )
// Render viewport (Right pane)
// We style the viewport string itself or wrapper?
// The viewport.View() returns the string.
rightRendered := rightStyle . Render ( m . viewport . View ( ) )
2026-01-19 14:28:56 +01:00
2026-01-19 16:12:51 +01:00
return lipgloss . JoinHorizontal ( lipgloss . Top , leftRendered , rightRendered )
2026-01-19 14:28:56 +01:00
}
2026-01-19 13:46:27 +01:00
func ( m Model ) renderNav ( ) string {
tabs := [ ] string { "[1] Dashboard" , "[2] Capture" , "[3] Analysis" , "[4] Network Map" }
2026-01-19 14:34:43 +01:00
// 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
2026-01-19 13:46:27 +01:00
for i , tab := range tabs {
if View ( i ) == m . currentView {
2026-01-19 14:34:43 +01:00
rendered = append ( rendered , activeStyle . Render ( tab ) )
2026-01-19 13:46:27 +01:00
} else {
2026-01-19 14:34:43 +01:00
rendered = append ( rendered , inactiveStyle . Render ( tab ) )
2026-01-19 13:46:27 +01:00
}
}
2026-01-19 14:34:43 +01:00
// 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"
2026-01-19 13:46:27 +01:00
}
2026-01-19 14:28:56 +01:00
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 , "|" ) )
}
2026-01-19 13:46:27 +01:00
func ( m Model ) viewDashboard ( ) string {
title := m . styles . Title . Render ( "📞 Dashboard" )
2026-01-19 14:28:56 +01:00
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" ) )
}
2026-01-19 13:46:27 +01:00
2026-01-19 14:28:56 +01:00
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 ... ) )
2026-01-19 13:46:27 +01:00
}
func ( m Model ) viewCapture ( ) string {
title := m . styles . Title . Render ( "🔍 Capture" )
2026-01-19 14:28:56 +01:00
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
2026-01-19 13:46:27 +01:00
if m . capturing {
2026-01-19 14:28:56 +01:00
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" ) )
2026-01-19 13:46:27 +01:00
}
2026-01-19 14:28:56 +01:00
lines = append ( lines , fmt . Sprintf ( "Packets: %d" , m . packetCount ) )
lines = append ( lines , "" )
2026-01-19 13:46:27 +01:00
2026-01-19 14:28:56 +01:00
// 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 )
}
}
2026-01-19 13:46:27 +01:00
2026-01-19 14:28:56 +01:00
lines = append ( lines , "" )
// Help
var help string
if m . captureMode == CaptureModeNone {
2026-01-19 14:49:50 +01:00
help = "[c] Choose mode [l] Local [r] Remote SSH [p] Pcap Import [q] Quit"
2026-01-19 14:28:56 +01:00
} 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 ... ) )
2026-01-19 13:46:27 +01:00
}
func ( m Model ) viewAnalysis ( ) string {
title := m . styles . Title . Render ( "📊 Analysis" )
2026-01-19 14:28:56 +01:00
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 ) )
2026-01-19 13:46:27 +01:00
2026-01-19 14:28:56 +01:00
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 ... ) )
2026-01-19 13:46:27 +01:00
}
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." ,
"" ,
2026-01-19 14:28:56 +01:00
"Add nodes to label IPs in your SIP infrastructure." ,
"" ,
m . styles . Help . Render ( "[a] Add node [l] Load file [g] Generate sample" ) )
2026-01-19 13:46:27 +01:00
}
2026-01-19 14:28:56 +01:00
var lines [ ] string
2026-01-19 13:46:27 +01:00
for _ , node := range m . networkMap . Nodes {
2026-01-19 14:28:56 +01:00
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 )
2026-01-19 13:46:27 +01:00
}
2026-01-19 14:28:56 +01:00
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
2026-01-19 13:46:27 +01:00
}
2026-01-19 14:48:03 +01:00
// 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
}
}