feat: Redesign Call Detail view with split layout and scrollable viewport

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 16:12:51 +01:00
parent ee7201ce57
commit ee0e827b78

View File

@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -75,9 +76,11 @@ type Model struct {
// Data stores
callFlowStore *sip.CallFlowStore
// Call flow analysis
// Call flow analysis
selectedFlow int
flowList list.Model
viewport viewport.Model
// File browser for pcap import
fileBrowser FileBrowserModel
@@ -229,6 +232,10 @@ func NewModel() Model {
nm = config.NewNetworkMap()
}
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
vp := viewport.New(0, 0)
vp.YPosition = 0
return Model{
currentView: ViewDashboard,
subView: SubViewNone,
@@ -237,6 +244,7 @@ func NewModel() Model {
lastPackets: make([]string, 0, 50),
sshConfig: NewSSHConfigModel(),
nodeInput: createNodeInputs(),
viewport: vp,
styles: defaultStyles(),
}
}
@@ -320,6 +328,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
// 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
case PacketMsg:
if msg.Packet != nil {
m.packetCount++
@@ -513,13 +536,18 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateNodeInput(msg)
case SubViewCallDetail:
var cmd tea.Cmd
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
m.subView = SubViewNone
}
}
return m, nil
}
}
// Update viewport
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
return m, nil
}
@@ -745,23 +773,31 @@ func (m Model) renderCallDetail() string {
}
flow := flows[m.selectedFlow]
var b strings.Builder
b.WriteString(m.styles.Title.Render("📞 Call Detail"))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
b.WriteString(fmt.Sprintf("From: %s\n", flow.From))
b.WriteString(fmt.Sprintf("To: %s\n", flow.To))
b.WriteString(fmt.Sprintf("State: %s\n", flow.State))
// 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))
// Calculate and display duration
duration := flow.EndTime.Sub(flow.StartTime)
b.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
left.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
left.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
// Network Summary Section
b.WriteString("Network Layer:\n")
left.WriteString("Network Layer:\n")
// Find first packet to get initial IPs
if len(flow.Packets) > 0 {
first := flow.Packets[0]
@@ -779,12 +815,16 @@ func (m Model) renderCallDetail() string {
dstLabel = m.styleForNode(node).Render(dstLabel)
}
b.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort))
b.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort))
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))
}
b.WriteString("\n")
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
b.WriteString("Transaction Flow:\n")
for i, pkt := range flow.Packets {
arrow := "→"
arrowStyle := m.styles.ArrowOut
@@ -829,7 +869,7 @@ func (m Model) renderCallDetail() string {
ts := pkt.Timestamp.Format("15:04:05.000")
// Clean packet info line (Timestamp + Arrow + Method/Status)
b.WriteString(fmt.Sprintf(" %d. [%s] %s %s\n",
right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n",
i+1,
ts,
arrowStyle.Render(arrow),
@@ -844,15 +884,32 @@ func (m Model) renderCallDetail() string {
node := m.networkMap.FindByIP(mediaIP)
label = m.styleForNode(node).Render(label)
}
b.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label))
right.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label))
}
}
}
b.WriteString("\n")
b.WriteString(m.styles.Help.Render("Press Esc to go back"))
// Set content to viewport
m.viewport.SetContent(right.String())
return m.styles.Box.Render(b.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())
return lipgloss.JoinHorizontal(lipgloss.Top, leftRendered, rightRendered)
}
func (m Model) renderNav() string {