From 0566cb9268208d3868747b9ed070a1ff177a3235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Mon, 19 Jan 2026 16:37:43 +0100 Subject: [PATCH] feat: Implement interactive packet selection and split-pane layout in Call Detail view --- internal/tui/model.go | 139 ++++++++++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 38 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 32a1087..b6f86ff 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 {