feat: Redesign Call Detail view with split layout and scrollable viewport
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
@@ -75,9 +76,11 @@ type Model struct {
|
||||
// Data stores
|
||||
callFlowStore *sip.CallFlowStore
|
||||
|
||||
// Call flow analysis
|
||||
// Call flow analysis
|
||||
selectedFlow int
|
||||
flowList list.Model
|
||||
viewport viewport.Model
|
||||
|
||||
// File browser for pcap import
|
||||
fileBrowser FileBrowserModel
|
||||
@@ -229,6 +232,10 @@ func NewModel() Model {
|
||||
nm = config.NewNetworkMap()
|
||||
}
|
||||
|
||||
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
|
||||
vp := viewport.New(0, 0)
|
||||
vp.YPosition = 0
|
||||
|
||||
return Model{
|
||||
currentView: ViewDashboard,
|
||||
subView: SubViewNone,
|
||||
@@ -237,6 +244,7 @@ func NewModel() Model {
|
||||
lastPackets: make([]string, 0, 50),
|
||||
sshConfig: NewSSHConfigModel(),
|
||||
nodeInput: createNodeInputs(),
|
||||
viewport: vp,
|
||||
styles: defaultStyles(),
|
||||
}
|
||||
}
|
||||
@@ -320,6 +328,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
// Update viewport size for Call Detail view
|
||||
// Top nav is ~1 line, Status bar ~1 line.
|
||||
// Layout is vertical join of nav, content, status.
|
||||
// Available height for content = Height - 2 (approx).
|
||||
headerHeight := 2 // Nav + margin
|
||||
footerHeight := 1 // Status bar
|
||||
contentHeight := m.height - headerHeight - footerHeight
|
||||
|
||||
if contentHeight < 0 {
|
||||
contentHeight = 0
|
||||
}
|
||||
|
||||
m.viewport.Width = m.width / 2 // Right pane width
|
||||
m.viewport.Height = contentHeight
|
||||
|
||||
case PacketMsg:
|
||||
if msg.Packet != nil {
|
||||
m.packetCount++
|
||||
@@ -513,13 +536,18 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateNodeInput(msg)
|
||||
|
||||
case SubViewCallDetail:
|
||||
var cmd tea.Cmd
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
|
||||
m.subView = SubViewNone
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update viewport
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -745,23 +773,31 @@ func (m Model) renderCallDetail() string {
|
||||
}
|
||||
|
||||
flow := flows[m.selectedFlow]
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(m.styles.Title.Render("📞 Call Detail"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
|
||||
b.WriteString(fmt.Sprintf("From: %s\n", flow.From))
|
||||
b.WriteString(fmt.Sprintf("To: %s\n", flow.To))
|
||||
b.WriteString(fmt.Sprintf("State: %s\n", flow.State))
|
||||
// Split Widths
|
||||
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))
|
||||
|
||||
// Calculate and display duration
|
||||
duration := flow.EndTime.Sub(flow.StartTime)
|
||||
b.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
|
||||
left.WriteString(fmt.Sprintf("Duration: %s\n", duration.Round(time.Millisecond)))
|
||||
|
||||
b.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
|
||||
left.WriteString(fmt.Sprintf("Packets: %d\n\n", len(flow.Packets)))
|
||||
|
||||
// Network Summary Section
|
||||
b.WriteString("Network Layer:\n")
|
||||
left.WriteString("Network Layer:\n")
|
||||
// Find first packet to get initial IPs
|
||||
if len(flow.Packets) > 0 {
|
||||
first := flow.Packets[0]
|
||||
@@ -779,12 +815,16 @@ func (m Model) renderCallDetail() string {
|
||||
dstLabel = m.styleForNode(node).Render(dstLabel)
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort))
|
||||
b.WriteString(fmt.Sprintf(" Destination: %s (%s:%d)\n", dstLabel, first.DestIP, first.DestPort))
|
||||
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))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
left.WriteString("\n\n")
|
||||
left.WriteString(m.styles.Help.Render("Esc: Back • ↑/↓: Scroll Flow"))
|
||||
|
||||
// --- Right Pane (Transaction Flow) ---
|
||||
var right strings.Builder
|
||||
// right.WriteString("Transaction Flow:\n\n") // Removed header to save space or added to viewport content
|
||||
|
||||
b.WriteString("Transaction Flow:\n")
|
||||
for i, pkt := range flow.Packets {
|
||||
arrow := "→"
|
||||
arrowStyle := m.styles.ArrowOut
|
||||
@@ -829,7 +869,7 @@ func (m Model) renderCallDetail() string {
|
||||
ts := pkt.Timestamp.Format("15:04:05.000")
|
||||
|
||||
// Clean packet info line (Timestamp + Arrow + Method/Status)
|
||||
b.WriteString(fmt.Sprintf(" %d. [%s] %s %s\n",
|
||||
right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n",
|
||||
i+1,
|
||||
ts,
|
||||
arrowStyle.Render(arrow),
|
||||
@@ -844,15 +884,32 @@ func (m Model) renderCallDetail() string {
|
||||
node := m.networkMap.FindByIP(mediaIP)
|
||||
label = m.styleForNode(node).Render(label)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label))
|
||||
right.WriteString(fmt.Sprintf(" SDP Media: %s %s\n", mediaIP, label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.Help.Render("Press Esc to go back"))
|
||||
// Set content to viewport
|
||||
m.viewport.SetContent(right.String())
|
||||
|
||||
return m.styles.Box.Render(b.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.
|
||||
|
||||
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
|
||||
leftRendered := leftStyle.Render(left.String())
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (m Model) renderNav() string {
|
||||
|
||||
Reference in New Issue
Block a user