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/list"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -75,9 +76,11 @@ 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
|
||||||
flowList list.Model
|
flowList list.Model
|
||||||
|
viewport viewport.Model
|
||||||
|
|
||||||
// File browser for pcap import
|
// File browser for pcap import
|
||||||
fileBrowser FileBrowserModel
|
fileBrowser FileBrowserModel
|
||||||
@@ -229,6 +232,10 @@ func NewModel() Model {
|
|||||||
nm = config.NewNetworkMap()
|
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{
|
return Model{
|
||||||
currentView: ViewDashboard,
|
currentView: ViewDashboard,
|
||||||
subView: SubViewNone,
|
subView: SubViewNone,
|
||||||
@@ -237,6 +244,7 @@ func NewModel() Model {
|
|||||||
lastPackets: make([]string, 0, 50),
|
lastPackets: make([]string, 0, 50),
|
||||||
sshConfig: NewSSHConfigModel(),
|
sshConfig: NewSSHConfigModel(),
|
||||||
nodeInput: createNodeInputs(),
|
nodeInput: createNodeInputs(),
|
||||||
|
viewport: vp,
|
||||||
styles: defaultStyles(),
|
styles: defaultStyles(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +328,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
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:
|
case PacketMsg:
|
||||||
if msg.Packet != nil {
|
if msg.Packet != nil {
|
||||||
m.packetCount++
|
m.packetCount++
|
||||||
@@ -513,13 +536,18 @@ func (m *Model) updateSubView(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateNodeInput(msg)
|
return m.updateNodeInput(msg)
|
||||||
|
|
||||||
case SubViewCallDetail:
|
case SubViewCallDetail:
|
||||||
|
var cmd tea.Cmd
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
|
if keyMsg.String() == "esc" || keyMsg.String() == "q" {
|
||||||
m.subView = SubViewNone
|
m.subView = SubViewNone
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update viewport
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -745,23 +773,31 @@ func (m Model) renderCallDetail() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flow := flows[m.selectedFlow]
|
flow := flows[m.selectedFlow]
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(m.styles.Title.Render("📞 Call Detail"))
|
// Split Widths
|
||||||
b.WriteString("\n\n")
|
totalWidth := m.width
|
||||||
b.WriteString(fmt.Sprintf("Call-ID: %s\n", flow.CallID))
|
// Subtract borders/padding if any
|
||||||
b.WriteString(fmt.Sprintf("From: %s\n", flow.From))
|
innerW := totalWidth - 4
|
||||||
b.WriteString(fmt.Sprintf("To: %s\n", flow.To))
|
leftW := innerW / 2
|
||||||
b.WriteString(fmt.Sprintf("State: %s\n", flow.State))
|
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
|
// Calculate and display duration
|
||||||
duration := flow.EndTime.Sub(flow.StartTime)
|
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
|
// Network Summary Section
|
||||||
b.WriteString("Network Layer:\n")
|
left.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]
|
||||||
@@ -779,12 +815,16 @@ func (m Model) renderCallDetail() string {
|
|||||||
dstLabel = m.styleForNode(node).Render(dstLabel)
|
dstLabel = m.styleForNode(node).Render(dstLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(fmt.Sprintf(" Source: %s (%s:%d)\n", srcLabel, first.SourceIP, first.SourcePort))
|
left.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(" 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 {
|
for i, pkt := range flow.Packets {
|
||||||
arrow := "→"
|
arrow := "→"
|
||||||
arrowStyle := m.styles.ArrowOut
|
arrowStyle := m.styles.ArrowOut
|
||||||
@@ -829,7 +869,7 @@ 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)
|
||||||
b.WriteString(fmt.Sprintf(" %d. [%s] %s %s\n",
|
right.WriteString(fmt.Sprintf("%d. [%s] %s %s\n",
|
||||||
i+1,
|
i+1,
|
||||||
ts,
|
ts,
|
||||||
arrowStyle.Render(arrow),
|
arrowStyle.Render(arrow),
|
||||||
@@ -844,15 +884,32 @@ 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)
|
||||||
}
|
}
|
||||||
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")
|
// Set content to viewport
|
||||||
b.WriteString(m.styles.Help.Render("Press Esc to go back"))
|
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 {
|
func (m Model) renderNav() string {
|
||||||
|
|||||||
Reference in New Issue
Block a user