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
|
// 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user