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

@@ -76,9 +76,9 @@ type Model struct {
// Data stores // Data stores
callFlowStore *sip.CallFlowStore callFlowStore *sip.CallFlowStore
// Call flow analysis
// Call flow analysis // Call flow analysis
selectedFlow int selectedFlow int
selectedPacketIndex int
flowList list.Model flowList list.Model
viewport viewport.Model viewport viewport.Model
@@ -568,15 +568,42 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
case SubViewCallDetail: case SubViewCallDetail:
var cmd tea.Cmd var cmd tea.Cmd
// We handle scrolling manually via selection now
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" || keyMsg.String() == "q" { switch keyMsg.String() {
case "esc", "q":
m.subView = SubViewNone m.subView = SubViewNone
m.selectedPacketIndex = 0 // Reset selection
return m, nil 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 // We generally don't need viewport.Update for keys anymore as we handle scroll manually
m.viewport, cmd = m.viewport.Update(msg) // 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 return m, cmd
} }
@@ -805,30 +832,41 @@ func (m Model) renderCallDetail() string {
flow := flows[m.selectedFlow] flow := flows[m.selectedFlow]
// Split Widths // Split Widths and Heights
totalWidth := m.width totalWidth := m.width
// Subtract borders/padding if any // Subtract borders/padding if any
innerW := totalWidth - 4 innerW := totalWidth - 4
leftW := innerW / 2 leftW := innerW / 2
rightW := innerW - leftW rightW := innerW - leftW
// --- Left Pane (Details) --- // Left Pane Heights
var left strings.Builder leftTotalH := m.height - 3 // Nav + Status
left.WriteString(m.styles.Title.Render("📞 Call Detail")) leftTopH := leftTotalH / 3 // 1/3 for call summary
left.WriteString("\n\n") leftBotH := leftTotalH - leftTopH // 2/3 for packet details
left.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID)) if leftTopH < 10 {
left.WriteString(fmt.Sprintf("From: %s\n", flow.From)) leftTopH = 10
left.WriteString(fmt.Sprintf("To: %s\n", flow.To)) } // Min height
left.WriteString(fmt.Sprintf("State: %s\n", flow.State)) 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 // Calculate and display duration
duration := flow.EndTime.Sub(flow.StartTime) 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 // Network Summary Section
left.WriteString("Network Layer:\n") leftTop.WriteString("Network Layer:\n")
// Find first packet to get initial IPs // Find first packet to get initial IPs
if len(flow.Packets) > 0 { if len(flow.Packets) > 0 {
first := flow.Packets[0] first := flow.Packets[0]
@@ -846,11 +884,29 @@ func (m Model) renderCallDetail() string {
dstLabel = m.styleForNode(node).Render(dstLabel) dstLabel = m.styleForNode(node).Render(dstLabel)
} }
left.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort)) leftTop.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(" 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) --- // --- Right Pane (Transaction Flow) ---
var right strings.Builder var right strings.Builder
@@ -900,11 +956,11 @@ func (m Model) renderCallDetail() string {
ts := pkt.Timestamp.Format("15:04:05.000") ts := pkt.Timestamp.Format("15:04:05.000")
// Clean packet info line (Timestamp + Arrow + Method/Status) // 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, i+1,
ts, ts,
arrowStyle.Render(arrow), arrowStyle.Render(arrow),
summaryStyle.Render(pkt.Summary()))) summaryStyle.Render(pkt.Summary()))
// Show SDP info if present // Show SDP info if present
if pkt.SDP != nil { if pkt.SDP != nil {
@@ -915,32 +971,39 @@ func (m Model) renderCallDetail() string {
node := m.networkMap.FindByIP(mediaIP) node := m.networkMap.FindByIP(mediaIP)
label = m.styleForNode(node).Render(label) 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 // Set content to viewport
m.viewport.SetContent(right.String()) m.viewport.SetContent(right.String())
// Ensure viewport size is correct (it might need update on resize msg, but being safe here) // Layout Construction
// We cheat a bit by updating width here during render if needed, but ideally handled in Update or Layout leftTopStyle := lipgloss.NewStyle().Width(leftW).Height(leftTopH).Padding(1, 2)
// But since this is a subview render, we just assume Update handled size or use current m.height 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
// Note: You can't mutate model in Render. So we rely on Update setting viewport size. rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border for right pane
// However, we need to enforce sizing or styling on the rendered string.
leftStyle := lipgloss.NewStyle().Width(leftW).Padding(1, 2) // Render left pane parts
rightStyle := lipgloss.NewStyle().Width(rightW).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("#44475A")) // Left border leftTopRendered := leftTopStyle.Render(leftTop.String())
leftBotRendered := leftBotStyle.Render(leftBot.String())
// Render left pane // Combine Left
leftRendered := leftStyle.Render(left.String()) leftCol := lipgloss.JoinVertical(lipgloss.Left, leftTopRendered, leftBotRendered)
// Render viewport (Right pane) // Render viewport (Right pane)
// We style the viewport string itself or wrapper?
// The viewport.View() returns the string.
rightRendered := rightStyle.Render(m.viewport.View()) 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 { func (m Model) renderNav() string {