feat: Implement interactive packet selection and split-pane layout in Call Detail view
This commit is contained in:
@@ -76,9 +76,9 @@ type Model struct {
|
||||
// Data stores
|
||||
callFlowStore *sip.CallFlowStore
|
||||
|
||||
// Call flow analysis
|
||||
// Call flow analysis
|
||||
selectedFlow int
|
||||
selectedPacketIndex int
|
||||
flowList list.Model
|
||||
viewport viewport.Model
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user