feat: Implement interactive packet selection and split-pane layout in Call Detail view

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 16:37:43 +01:00
parent 42d27c0647
commit 0566cb9268

View File

@@ -77,10 +77,10 @@ type Model struct {
callFlowStore *sip.CallFlowStore
// Call flow analysis
// Call flow analysis
selectedFlow int
flowList list.Model
viewport viewport.Model
selectedFlow int
selectedPacketIndex int
flowList list.Model
viewport viewport.Model
// File browser for pcap import
fileBrowser FileBrowserModel
@@ -568,15 +568,42 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
case SubViewCallDetail:
var cmd tea.Cmd
// We handle scrolling manually via selection now
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
switch keyMsg.String() {
case "esc", "q":
m.subView = SubViewNone
m.selectedPacketIndex = 0 // Reset selection
return m, nil
case "up", "k":
if m.selectedPacketIndex > 0 {
m.selectedPacketIndex--
// Sync viewport
if m.selectedPacketIndex < m.viewport.YOffset {
m.viewport.SetYOffset(m.selectedPacketIndex)
}
}
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)
}
}
}
}
}
// Update viewport
m.viewport, cmd = m.viewport.Update(msg)
// We generally don't need viewport.Update for keys anymore as we handle scroll manually
// But we might need it for resizing or other generic msgs?
// Actually viewport.Update handles scroll keys by default.
// If we return cmd from it, it might conflict.
// Let's NOT call viewport.Update for keys if we consumed them.
return m, cmd
}
@@ -805,30 +832,41 @@ func (m Model) renderCallDetail() string {
flow := flows[m.selectedFlow]
// Split Widths
// Split Widths and Heights
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))
// Left Pane Heights
leftTotalH := m.height - 3 // Nav + Status
leftTopH := leftTotalH / 3 // 1/3 for call summary
leftBotH := leftTotalH - leftTopH // 2/3 for packet details
if leftTopH < 10 {
leftTopH = 10
} // Min height
if leftBotH < 0 {
leftBotH = 0
}
// --- Left Pane TOP (Call Info) ---
var leftTop strings.Builder
leftTop.WriteString(m.styles.Title.Render("📞 Call Detail"))
leftTop.WriteString("\n\n")
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))
// Calculate and display duration
duration := flow.EndTime.Sub(flow.StartTime)
left.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
leftTop.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
left.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
leftTop.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
// Network Summary Section
left.WriteString("Network Layer:\n")
leftTop.WriteString("Network Layer:\n")
// Find first packet to get initial IPs
if len(flow.Packets) > 0 {
first := flow.Packets[0]
@@ -846,11 +884,29 @@ func (m Model) renderCallDetail() string {
dstLabel = m.styleForNode(node).Render(dstLabel)
}
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))
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) ---
var leftBot strings.Builder
leftBot.WriteString(m.styles.Title.Render("📦 Packet Details"))
leftBot.WriteString("\n\n")
if m.selectedPacketIndex < len(flow.Packets) {
pkt := flow.Packets[m.selectedPacketIndex]
leftBot.WriteString(fmt.Sprintf("Time: %s\n", pkt.Timestamp.Format("15:04:05.000")))
leftBot.WriteString(fmt.Sprintf("IP: %s -> %s\n\n", pkt.SourceIP, pkt.DestIP))
// Render Payload/Raw
// Truncate if too long?
raw := pkt.Raw
// Simple header parsing or just raw dump?
// Raw dump is useful.
leftBot.WriteString(raw)
} else {
leftBot.WriteString("No packet selected")
}
left.WriteString("\n\n")
left.WriteString(m.styles.Help.Render("Esc: Back • ↑/↓: Scroll Flow"))
// --- Right Pane (Transaction Flow) ---
var right strings.Builder
@@ -900,11 +956,11 @@ func (m Model) renderCallDetail() string {
ts := pkt.Timestamp.Format("15:04:05.000")
// Clean packet info line (Timestamp + Arrow + Method/Status)
right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n",
lineStr := fmt.Sprintf("%d. [%s] %s %s",
i+1,
ts,
arrowStyle.Render(arrow),
summaryStyle.Render(pkt.Summary())))
summaryStyle.Render(pkt.Summary()))
// Show SDP info if present
if pkt.SDP != nil {
@@ -915,32 +971,39 @@ func (m Model) renderCallDetail() string {
node := m.networkMap.FindByIP(mediaIP)
label = m.styleForNode(node).Render(label)
}
right.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label))
lineStr += fmt.Sprintf(" (SDP: %s %s)", mediaIP, label)
}
}
// Highlight selected line
if i == m.selectedPacketIndex {
lineStr = m.styles.Active.Render("> " + lineStr)
} else {
lineStr = " " + lineStr
}
right.WriteString(lineStr + "\n")
}
// 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.
// Layout Construction
leftTopStyle := lipgloss.NewStyle().Width(leftW).Height(leftTopH).Padding(1, 2)
leftBotStyle := lipgloss.NewStyle().Width(leftW).Height(leftBotH).Padding(1, 2).Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(lipgloss.Color("#44475A")) // Top border for bottom pane
rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border for right pane
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 parts
leftTopRendered := leftTopStyle.Render(leftTop.String())
leftBotRendered := leftBotStyle.Render(leftBot.String())
// Render left pane
leftRendered := leftStyle.Render(left.String())
// Combine Left
leftCol := lipgloss.JoinVertical(lipgloss.Left, leftTopRendered, leftBotRendered)
// 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)
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightRendered)
}
func (m Model) renderNav() string {