refactor: Use viewport for Analysis interactions to enable proper scrolling

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-19 23:16:02 +01:00
parent a5ba4d6c11
commit b7a5bede87

View File

@@ -84,6 +84,7 @@ type Model struct {
selectedPacketIndex int selectedPacketIndex int
flowList list.Model flowList list.Model
viewport viewport.Model viewport viewport.Model
analysisViewport viewport.Model
// Packet Details View // Packet Details View
detailsViewport viewport.Model detailsViewport viewport.Model
@@ -240,20 +241,30 @@ func NewModel() Model {
} }
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry // Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
// Viewport for Transaction Flow
vp := viewport.New(0, 0) vp := viewport.New(0, 0)
vp.YPosition = 0 vp.YPosition = 0
// Viewport for Analysis List
avp := viewport.New(0, 0)
avp.YPosition = 0
// Viewport for Packet Details
dvp := viewport.New(0, 0)
return Model{ return Model{
currentView: ViewDashboard, currentView: ViewDashboard,
subView: SubViewNone, subView: SubViewNone,
networkMap: nm, networkMap: nm,
callFlowStore: sip.NewCallFlowStore(), callFlowStore: sip.NewCallFlowStore(),
lastPackets: make([]string, 0, 50), lastPackets: make([]string, 0, 50),
sshConfig: NewSSHConfigModel(), sshConfig: NewSSHConfigModel(),
nodeInput: createNodeInputs(), nodeInput: createNodeInputs(),
viewport: vp, viewport: vp,
detailsViewport: viewport.New(0, 0), analysisViewport: avp,
styles: defaultStyles(), detailsViewport: dvp,
fileBrowser: NewFileBrowser(".", ".pcap"),
styles: defaultStyles(),
} }
} }
@@ -324,6 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If we are in Call Detail view, we might need to update the viewport content dynamically! // If we are in Call Detail view, we might need to update the viewport content dynamically!
if m.subView == SubViewCallDetail { if m.subView == SubViewCallDetail {
m.updateCallDetailView() m.updateCallDetailView()
} else if m.currentView == ViewAnalysis {
m.updateAnalysisView()
} }
} }
@@ -432,6 +445,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.subView == SubViewCallDetail { if m.subView == SubViewCallDetail {
m.updateCallDetailView() m.updateCallDetailView()
} }
// Update Analysis Viewport size
// Header (Title+Counts) ~2 lines, Footer (Help) ~2 lines -> ~4 lines overhead
analysisHeight := m.height - 4
if analysisHeight < 0 {
analysisHeight = 0
}
m.analysisViewport.Width = m.width
m.analysisViewport.Height = analysisHeight
if m.currentView == ViewAnalysis {
m.updateAnalysisView()
}
} }
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
@@ -485,52 +511,27 @@ func (m *Model) handleViewKeys(msg tea.KeyMsg) tea.Cmd {
case "up", "k": case "up", "k":
if m.selectedFlow > 0 { if m.selectedFlow > 0 {
m.selectedFlow-- m.selectedFlow--
if m.selectedFlow < m.analysisOffset { m.updateAnalysisView()
m.analysisOffset = m.selectedFlow
}
} }
case "down", "j": case "down", "j":
flows := m.callFlowStore.GetSortedFlows() flows := m.callFlowStore.GetSortedFlows()
if m.selectedFlow < len(flows)-1 { if m.selectedFlow < len(flows)-1 {
m.selectedFlow++ m.selectedFlow++
// Check if we need to scroll down m.updateAnalysisView()
// Visible height calculation (approximate)
headerHeight := 4 // Title + Call count + padding
footerHeight := 2 // Help text
availableHeight := m.height - headerHeight - footerHeight
if availableHeight < 5 {
availableHeight = 5
}
if m.selectedFlow >= m.analysisOffset+availableHeight {
m.analysisOffset++
}
} }
case "pgup": case "pgup":
m.selectedFlow -= 10 m.selectedFlow -= 10
if m.selectedFlow < 0 { if m.selectedFlow < 0 {
m.selectedFlow = 0 m.selectedFlow = 0
} }
if m.selectedFlow < m.analysisOffset { m.updateAnalysisView()
m.analysisOffset = m.selectedFlow
}
case "pgdown": case "pgdown":
flows := m.callFlowStore.GetSortedFlows() flows := m.callFlowStore.GetSortedFlows()
m.selectedFlow += 10 m.selectedFlow += 10
if m.selectedFlow >= len(flows) { if m.selectedFlow >= len(flows) {
m.selectedFlow = len(flows) - 1 m.selectedFlow = len(flows) - 1
} }
m.updateAnalysisView()
headerHeight := 4
footerHeight := 2
availableHeight := m.height - headerHeight - footerHeight
if availableHeight < 5 {
availableHeight = 5
}
if m.selectedFlow >= m.analysisOffset+availableHeight {
m.analysisOffset = m.selectedFlow - availableHeight + 1
}
case "enter": case "enter":
m.subView = SubViewCallDetail m.subView = SubViewCallDetail
m.updateCallDetailView() m.updateCallDetailView()
@@ -1247,85 +1248,6 @@ func (m Model) viewCapture() string {
return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...)) return lipgloss.JoinVertical(lipgloss.Left, title, lipgloss.JoinVertical(lipgloss.Left, lines...))
} }
func (m Model) viewAnalysis() string {
title := m.styles.Title.Render("📊 Analysis")
flows := m.callFlowStore.GetSortedFlows()
if len(flows) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, title,
"No calls captured yet.",
"",
"Start capturing on the Capture tab to see call flows here.",
"",
m.styles.Help.Render("Press 2 to go to Capture"))
}
var lines []string
// Header is handled in return statement now
// lines = append(lines, fmt.Sprintf("Calls: %d", len(flows)))
// lines = append(lines, "")
for i, flow := range flows {
prefix := " "
style := m.styles.Inactive
if i == m.selectedFlow {
prefix = "▶ "
style = m.styles.Active
}
stateIcon := "○"
switch flow.State {
case sip.CallStateRinging:
stateIcon = "◐"
case sip.CallStateConnected:
stateIcon = "●"
case sip.CallStateTerminated:
stateIcon = "◯"
case sip.CallStateFailed:
stateIcon = "✕"
}
summary := fmt.Sprintf("%s%s %s → %s [%d pkts]",
prefix, stateIcon,
truncate(extractUser(flow.From), 15),
truncate(extractUser(flow.To), 15),
len(flow.Packets))
lines = append(lines, style.Render(summary))
}
// Apply Viewport/Scrolling
headerHeight := 4 // Title + Call count + padding
footerHeight := 2 // Help text
availableHeight := m.height - headerHeight - footerHeight
if availableHeight < 5 {
availableHeight = 5
}
// Sanity check offset
if m.analysisOffset > len(lines)-1 {
m.analysisOffset = len(lines) - 1
}
if m.analysisOffset < 0 {
m.analysisOffset = 0
}
end := m.analysisOffset + availableHeight
if end > len(lines) {
end = len(lines)
}
visibleLines := lines[m.analysisOffset:end]
return lipgloss.JoinVertical(lipgloss.Left, title,
fmt.Sprintf("Calls: %d (Showing %d-%d)", len(flows), m.analysisOffset+1, end),
"",
lipgloss.JoinVertical(lipgloss.Left, visibleLines...),
"",
m.styles.Help.Render("↑/↓ select • Enter details • q quit"))
}
func (m Model) viewNetworkMap() string { func (m Model) viewNetworkMap() string {
title := m.styles.Title.Render("🗺️ Network Map") title := m.styles.Title.Render("🗺️ Network Map")
@@ -1541,6 +1463,70 @@ func (m *Model) updateCallDetailView() {
m.viewport.SetContent(right.String()) m.viewport.SetContent(right.String())
} }
func (m *Model) updateAnalysisView() {
flows := m.callFlowStore.GetSortedFlows()
if len(flows) == 0 {
m.analysisViewport.SetContent("No calls captured yet.\n\nStart capturing on the Capture tab to see call flows here.")
return
}
var lines []string
for i, flow := range flows {
prefix := " "
style := m.styles.Inactive
if i == m.selectedFlow {
prefix = "> "
style = m.styles.Active
}
stateIcon := "○"
switch flow.State {
case sip.CallStateRinging:
stateIcon = "◐"
case sip.CallStateConnected:
stateIcon = "●"
case sip.CallStateTerminated:
stateIcon = "◯"
case sip.CallStateFailed:
stateIcon = "✕"
}
summary := fmt.Sprintf("%s%s %s → %s [%d pkts]",
prefix, stateIcon,
truncate(extractUser(flow.From), 15),
truncate(extractUser(flow.To), 15),
len(flow.Packets))
lines = append(lines, style.Render(summary))
}
m.analysisViewport.SetContent(strings.Join(lines, "\n"))
// Sync viewport scrolling
// Similar to transaction flow: keep selected line visible
if m.selectedFlow < m.analysisViewport.YOffset {
m.analysisViewport.SetYOffset(m.selectedFlow)
} else if m.selectedFlow >= m.analysisViewport.YOffset+m.analysisViewport.Height {
m.analysisViewport.SetYOffset(m.selectedFlow - m.analysisViewport.Height + 1)
}
}
func (m Model) viewAnalysis() string {
title := m.styles.Title.Render("📊 Analysis")
flows := m.callFlowStore.GetSortedFlows()
countStr := fmt.Sprintf("Calls: %d", len(flows))
return lipgloss.JoinVertical(lipgloss.Left,
title,
countStr,
"",
m.analysisViewport.View(),
"",
m.styles.Help.Render("↑/↓ select • Enter details • q quit"))
}
func (m *Model) exportCallToLog(flow *sip.CallFlow, filename string) error { func (m *Model) exportCallToLog(flow *sip.CallFlow, filename string) error {
f, err := os.Create(filename) f, err := os.Create(filename)
if err != nil { if err != nil {