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 16:37:43 +01:00
|
|
|
|
selectedFlow int
|
|
|
|
|
|
selectedPacketIndex int
|
|
|
|
|
|
flowList list.Model
|
|
|
|
|
|
viewport viewport.Model
|
2026-01-19 14:28:56 +01:00
|
|
|
|
|
2026-01-19 16:43:08 +01:00
|
|
|
|
// Packet Details View
|
|
|
|
|
|
detailsViewport viewport.Model
|
|
|
|
|
|
focusPacketDetails bool
|
|
|
|
|
|
|
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 16:55:41 +01:00
|
|
|
|
currentView: ViewDashboard,
|
|
|
|
|
|
subView: SubViewNone,
|
|
|
|
|
|
networkMap: nm,
|
|
|
|
|
|
callFlowStore: sip.NewCallFlowStore(),
|
|
|
|
|
|
lastPackets: make([]string, 0, 50),
|
|
|
|
|
|
sshConfig: NewSSHConfigModel(),
|
|
|
|
|
|
nodeInput: createNodeInputs(),
|
|
|
|
|
|
viewport: vp,
|
|
|
|
|
|
detailsViewport: viewport.New(0, 0),
|
|
|
|
|
|
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 16:25:32 +01:00
|
|
|
|
var cmds []tea.Cmd
|
2026-01-19 14:28:56 +01:00
|
|
|
|
|
2026-01-19 16:25:32 +01:00
|
|
|
|
// GLOBAL HANDLERS: Handle signals independent of view
|
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
|
case PacketMsg:
|
|
|
|
|
|
if msg.Packet != nil {
|
|
|
|
|
|
m.packetCount++
|
|
|
|
|
|
summary := formatPacketSummary(msg.Packet, m.networkMap)
|
|
|
|
|
|
m.lastPackets = append(m.lastPackets, summary)
|
|
|
|
|
|
if len(m.lastPackets) > 50 {
|
|
|
|
|
|
m.lastPackets = m.lastPackets[1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
m.callFlowStore.AddPacket(msg.Packet)
|
|
|
|
|
|
|
|
|
|
|
|
// If we are in Call Detail view, we might need to update the viewport content dynamically!
|
|
|
|
|
|
if m.subView == SubViewCallDetail {
|
|
|
|
|
|
// Re-render subview content effectively updates the strings, but
|
|
|
|
|
|
// we need to set the content on viewport again if it changed.
|
|
|
|
|
|
// This is handled in View() normally, but viewport needs SetContent.
|
|
|
|
|
|
// Let's force a viewport update by triggering a dummy message or just re-setting it.
|
|
|
|
|
|
// Actually, View() calls renderCallDetail which calls SetContent.
|
|
|
|
|
|
// But View() is only called if Update returns a modified model.
|
|
|
|
|
|
// We modified the store, so that counts.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PROCESS NEXT PACKET - CRITICAL loop
|
|
|
|
|
|
if m.capturing {
|
|
|
|
|
|
cmds = append(cmds, waitForPacket(m.packetChan))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If we processed a packet, we typically don't need to pass this msg to subviews
|
|
|
|
|
|
// UNLESS the subview reacts to it explicitly.
|
|
|
|
|
|
// For now, we return here to avoid double processing, BUT we must ensure
|
|
|
|
|
|
// UI refreshes.
|
|
|
|
|
|
return m, tea.Batch(cmds...)
|
|
|
|
|
|
|
|
|
|
|
|
case ErrorMsg:
|
|
|
|
|
|
m.captureError = msg.Error.Error()
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle standard key/window messages dependent on view
|
|
|
|
|
|
// Handle subview updates
|
2026-01-19 14:28:56 +01:00
|
|
|
|
if m.subView != SubViewNone {
|
2026-01-19 16:25:32 +01:00
|
|
|
|
newM, cmd := m.updateSubView(msg)
|
|
|
|
|
|
// We need to type assert back to Model because updateSubView follows the interface but returns concrete logic
|
|
|
|
|
|
// Actually updateSubView returns tea.Model, tea.Cmd.
|
|
|
|
|
|
// Since we are inside Model.Update, we can cast or just return.
|
|
|
|
|
|
realM, ok := newM.(Model)
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
m = realM
|
|
|
|
|
|
}
|
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
|
return m, tea.Batch(cmds...)
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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:
|
2026-01-19 16:25:32 +01:00
|
|
|
|
cmds = append(cmds, 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 17:06:09 +01:00
|
|
|
|
|
|
|
|
|
|
// Correctly calculate Split Layout Dimensions for Viewports
|
|
|
|
|
|
// Ideally we should share this logic or calculate it once
|
|
|
|
|
|
totalWidth := m.width
|
|
|
|
|
|
innerW := totalWidth - 2
|
|
|
|
|
|
leftW := innerW / 2
|
|
|
|
|
|
rightW := innerW - leftW
|
|
|
|
|
|
|
|
|
|
|
|
leftTotalH := m.height - 3
|
|
|
|
|
|
leftTopH := leftTotalH / 3
|
|
|
|
|
|
if leftTopH < 10 {
|
|
|
|
|
|
leftTopH = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
leftBotH := leftTotalH - leftTopH
|
|
|
|
|
|
if leftBotH < 0 {
|
|
|
|
|
|
leftBotH = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update Flow Viewport (Right)
|
|
|
|
|
|
rvpHeight := (leftTotalH - 2) - 2 // -2 Borders, -2 Title
|
|
|
|
|
|
if rvpHeight < 0 {
|
|
|
|
|
|
rvpHeight = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
m.viewport.Height = rvpHeight
|
|
|
|
|
|
m.viewport.Width = rightW - 4
|
|
|
|
|
|
|
|
|
|
|
|
// Update Details Viewport (Left Bottom)
|
|
|
|
|
|
vpHeight := (leftBotH - 2) - 2 // -2 Borders, -2 Title
|
|
|
|
|
|
if vpHeight < 0 {
|
|
|
|
|
|
vpHeight = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
m.detailsViewport.Height = vpHeight
|
|
|
|
|
|
m.detailsViewport.Width = leftW - 4
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:25:32 +01:00
|
|
|
|
return m, tea.Batch(cmds...)
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 16:43:08 +01:00
|
|
|
|
|
|
|
|
|
|
// Handle Focus Switching
|
2026-01-19 14:28:56 +01:00
|
|
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
2026-01-19 16:37:43 +01:00
|
|
|
|
switch keyMsg.String() {
|
|
|
|
|
|
case "esc", "q":
|
2026-01-19 14:28:56 +01:00
|
|
|
|
m.subView = SubViewNone
|
2026-01-19 16:37:43 +01:00
|
|
|
|
m.selectedPacketIndex = 0 // Reset selection
|
2026-01-19 16:43:08 +01:00
|
|
|
|
m.focusPacketDetails = false
|
2026-01-19 16:12:51 +01:00
|
|
|
|
return m, nil
|
2026-01-19 16:43:08 +01:00
|
|
|
|
case "tab":
|
|
|
|
|
|
m.focusPacketDetails = !m.focusPacketDetails
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Route keys based on focus
|
|
|
|
|
|
if m.focusPacketDetails {
|
|
|
|
|
|
// Forward keys to Details Viewport for scrolling
|
|
|
|
|
|
m.detailsViewport, cmd = m.detailsViewport.Update(msg)
|
|
|
|
|
|
return m, cmd
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Handle Flow Navigation
|
|
|
|
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
|
|
|
|
switch keyMsg.String() {
|
|
|
|
|
|
case "up", "k":
|
|
|
|
|
|
if m.selectedPacketIndex > 0 {
|
|
|
|
|
|
m.selectedPacketIndex--
|
2026-01-19 16:37:43 +01:00
|
|
|
|
// Sync viewport
|
2026-01-19 16:43:08 +01:00
|
|
|
|
if m.selectedPacketIndex < m.viewport.YOffset {
|
|
|
|
|
|
m.viewport.SetYOffset(m.selectedPacketIndex)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Force update of details content happens in View currently, but for state consistency
|
|
|
|
|
|
// we should probably do it here or let View handle it.
|
|
|
|
|
|
// Since View is pure function of state, it's fine.
|
|
|
|
|
|
// But detailsViewport scroll position should reset if packet changes?
|
|
|
|
|
|
m.detailsViewport.GotoTop()
|
|
|
|
|
|
}
|
|
|
|
|
|
case "down", "j":
|
|
|
|
|
|
flows := m.callFlowStore.GetRecentFlows(20)
|
|
|
|
|
|
if m.selectedFlow < len(flows) {
|
|
|
|
|
|
flow := flows[m.selectedFlow]
|
|
|
|
|
|
if m.selectedPacketIndex < len(flow.Packets)-1 {
|
|
|
|
|
|
m.selectedPacketIndex++
|
|
|
|
|
|
// Sync viewport
|
|
|
|
|
|
if m.selectedPacketIndex >= m.viewport.YOffset+m.viewport.Height {
|
|
|
|
|
|
m.viewport.SetYOffset(m.selectedPacketIndex - m.viewport.Height + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
m.detailsViewport.GotoTop()
|
2026-01-19 16:37:43 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 16:12:51 +01:00
|
|
|
|
|
|
|
|
|
|
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:37:43 +01:00
|
|
|
|
// Split Widths and Heights
|
2026-01-19 16:12:51 +01:00
|
|
|
|
totalWidth := m.width
|
|
|
|
|
|
// Subtract borders/padding if any
|
2026-01-19 16:51:24 +01:00
|
|
|
|
innerW := totalWidth - 2 // Outer margin?
|
2026-01-19 16:12:51 +01:00
|
|
|
|
leftW := innerW / 2
|
|
|
|
|
|
rightW := innerW - leftW
|
|
|
|
|
|
|
2026-01-19 16:37:43 +01:00
|
|
|
|
// Left Pane Heights
|
2026-01-19 16:51:24 +01:00
|
|
|
|
leftTotalH := m.height - 3 // Nav + Status
|
|
|
|
|
|
// Adjust for borders: we have two boxes stacked.
|
|
|
|
|
|
// Let's say we split space 33% / 66%.
|
|
|
|
|
|
leftTopH := leftTotalH / 3
|
|
|
|
|
|
leftBotH := leftTotalH - leftTopH
|
|
|
|
|
|
|
2026-01-19 16:37:43 +01:00
|
|
|
|
if leftTopH < 10 {
|
|
|
|
|
|
leftTopH = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Determine Border Colors based on Focus
|
|
|
|
|
|
detailsBorderColor := lipgloss.Color("#44475A")
|
|
|
|
|
|
flowBorderColor := lipgloss.Color("#44475A")
|
|
|
|
|
|
infoBorderColor := lipgloss.Color("#44475A") // Call info usually static focus
|
|
|
|
|
|
|
|
|
|
|
|
if m.focusPacketDetails {
|
|
|
|
|
|
detailsBorderColor = lipgloss.Color("#bd93f9") // Active Purple
|
|
|
|
|
|
} else {
|
|
|
|
|
|
flowBorderColor = lipgloss.Color("#bd93f9")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Definition of Styles
|
|
|
|
|
|
// We want Borders on ALL sides now since they are distinct boxes
|
|
|
|
|
|
baseStyle := lipgloss.NewStyle().Padding(0, 1).Border(lipgloss.RoundedBorder())
|
|
|
|
|
|
|
|
|
|
|
|
leftTopStyle := baseStyle.Copy().
|
|
|
|
|
|
Width(leftW - 2). // -2 for borders
|
|
|
|
|
|
Height(leftTopH - 2). // -2 for borders
|
|
|
|
|
|
BorderForeground(infoBorderColor)
|
|
|
|
|
|
|
|
|
|
|
|
leftBotStyle := baseStyle.Copy().
|
|
|
|
|
|
Width(leftW - 2).
|
|
|
|
|
|
Height(leftBotH - 2).
|
|
|
|
|
|
BorderForeground(detailsBorderColor)
|
|
|
|
|
|
|
|
|
|
|
|
rightStyle := baseStyle.Copy().
|
|
|
|
|
|
Width(rightW - 2).
|
|
|
|
|
|
Height(leftTotalH - 2).
|
|
|
|
|
|
BorderForeground(flowBorderColor)
|
|
|
|
|
|
|
2026-01-19 16:37:43 +01:00
|
|
|
|
// --- Left Pane TOP (Call Info) ---
|
|
|
|
|
|
var leftTop strings.Builder
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Title
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftTop.WriteString(m.styles.Title.Render("📞 Call Detail"))
|
|
|
|
|
|
leftTop.WriteString("\n\n")
|
2026-01-19 16:51:24 +01:00
|
|
|
|
|
|
|
|
|
|
// Content
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftTop.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
|
|
|
|
|
|
leftTop.WriteString(fmt.Sprintf("From: %s\n", flow.From))
|
|
|
|
|
|
leftTop.WriteString(fmt.Sprintf("To: %s\n", flow.To))
|
|
|
|
|
|
leftTop.WriteString(fmt.Sprintf("State: %s\n", flow.State))
|
2026-01-19 15:02:27 +01:00
|
|
|
|
|
|
|
|
|
|
duration := flow.EndTime.Sub(flow.StartTime)
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftTop.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
|
|
|
|
|
|
leftTop.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
|
2026-01-19 14:28:56 +01:00
|
|
|
|
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftTop.WriteString("Network Layer:\n")
|
2026-01-19 14:36:01 +01:00
|
|
|
|
if len(flow.Packets) > 0 {
|
|
|
|
|
|
first := flow.Packets[0]
|
|
|
|
|
|
srcLabel := m.networkMap.LabelForIP(first.SourceIP)
|
2026-01-19 14:48:03 +01:00
|
|
|
|
if srcLabel != first.SourceIP {
|
|
|
|
|
|
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 16:37:43 +01:00
|
|
|
|
leftTop.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort))
|
|
|
|
|
|
leftTop.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Left Pane BOTTOM (Selected Packet Details) ---
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Title
|
|
|
|
|
|
detailsTitle := m.styles.Title.Render("📦 Packet Details")
|
|
|
|
|
|
|
|
|
|
|
|
// Content
|
2026-01-19 16:43:08 +01:00
|
|
|
|
var detailsContent string
|
2026-01-19 16:37:43 +01:00
|
|
|
|
if m.selectedPacketIndex < len(flow.Packets) {
|
|
|
|
|
|
pkt := flow.Packets[m.selectedPacketIndex]
|
2026-01-19 16:43:08 +01:00
|
|
|
|
detailsContent = fmt.Sprintf("Time: %s\nIP: %s -> %s\n\n%s",
|
|
|
|
|
|
pkt.Timestamp.Format("15:04:05.000"),
|
|
|
|
|
|
pkt.SourceIP, pkt.DestIP,
|
|
|
|
|
|
pkt.Raw)
|
2026-01-19 16:37:43 +01:00
|
|
|
|
} else {
|
2026-01-19 16:43:08 +01:00
|
|
|
|
detailsContent = "No packet selected"
|
2026-01-19 14:36:01 +01:00
|
|
|
|
}
|
2026-01-19 16:12:51 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Update details viewport
|
|
|
|
|
|
// Calculate available height: Pane Height - Borders(2) - Header(2 approx)
|
|
|
|
|
|
// Actually styles handle borders on the container.
|
|
|
|
|
|
// Viewport height should be container InnerHeight - TitleHeight - Padding.
|
|
|
|
|
|
// leftBotStyle.GetHeight() returns outer height.
|
|
|
|
|
|
// We set style height explicitly, so we know it.
|
|
|
|
|
|
|
|
|
|
|
|
vpHeight := (leftBotH - 2) - 2 // -2 Borders, -2 Title/Margin
|
|
|
|
|
|
if vpHeight < 0 {
|
|
|
|
|
|
vpHeight = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
m.detailsViewport.Width = leftW - 4 // Margin/Padding
|
|
|
|
|
|
m.detailsViewport.Height = vpHeight
|
2026-01-19 16:43:08 +01:00
|
|
|
|
m.detailsViewport.SetContent(detailsContent)
|
|
|
|
|
|
|
2026-01-19 16:12:51 +01:00
|
|
|
|
// --- Right Pane (Transaction Flow) ---
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Title
|
|
|
|
|
|
flowTitle := m.styles.Title.Render("🚀 Transaction Flow")
|
2026-01-19 14:36:01 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
var right strings.Builder
|
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
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
ts := pkt.Timestamp.Format("15:04:05.000")
|
2026-01-19 16:51:24 +01:00
|
|
|
|
lineStr := fmt.Sprintf("%d. [%s] %s %s", i+1, ts, arrowStyle.Render(arrow), summaryStyle.Render(pkt.Summary()))
|
2026-01-19 14:34:43 +01:00
|
|
|
|
|
2026-01-19 14:28:56 +01:00
|
|
|
|
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:37:43 +01:00
|
|
|
|
lineStr += fmt.Sprintf(" (SDP: %s %s)", mediaIP, label)
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 16:37:43 +01:00
|
|
|
|
|
|
|
|
|
|
if i == m.selectedPacketIndex {
|
|
|
|
|
|
lineStr = m.styles.Active.Render("> " + lineStr)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lineStr = " " + lineStr
|
|
|
|
|
|
}
|
|
|
|
|
|
right.WriteString(lineStr + "\n")
|
2026-01-19 14:28:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:12:51 +01:00
|
|
|
|
m.viewport.SetContent(right.String())
|
|
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Right Pane Height logic
|
|
|
|
|
|
rvpHeight := (leftTotalH - 2) - 2 // -2 Borders, -2 Title/Margin
|
|
|
|
|
|
if rvpHeight < 0 {
|
|
|
|
|
|
rvpHeight = 0
|
2026-01-19 16:43:08 +01:00
|
|
|
|
}
|
2026-01-19 16:51:24 +01:00
|
|
|
|
m.viewport.Height = rvpHeight
|
|
|
|
|
|
m.viewport.Width = rightW - 4
|
2026-01-19 16:43:08 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Render Final Layout
|
2026-01-19 16:12:51 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Left Top: Just content
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftTopRendered := leftTopStyle.Render(leftTop.String())
|
2026-01-19 16:12:51 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Left Bot: Title + Viewport
|
|
|
|
|
|
leftBotContent := lipgloss.JoinVertical(lipgloss.Left, detailsTitle, "\n", m.detailsViewport.View())
|
|
|
|
|
|
leftBotRendered := leftBotStyle.Render(leftBotContent)
|
|
|
|
|
|
|
|
|
|
|
|
// Left Col
|
2026-01-19 16:37:43 +01:00
|
|
|
|
leftCol := lipgloss.JoinVertical(lipgloss.Left, leftTopRendered, leftBotRendered)
|
2026-01-19 16:12:51 +01:00
|
|
|
|
|
2026-01-19 16:51:24 +01:00
|
|
|
|
// Right: Title + Viewport
|
|
|
|
|
|
rightContent := lipgloss.JoinVertical(lipgloss.Left, flowTitle, "\n", m.viewport.View())
|
|
|
|
|
|
rightRendered := rightStyle.Render(rightContent)
|
2026-01-19 14:28:56 +01:00
|
|
|
|
|
2026-01-19 16:37:43 +01:00
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|