refactor: Use viewport for Analysis interactions to enable proper scrolling
This commit is contained in:
@@ -84,6 +84,7 @@ type Model struct {
|
||||
selectedPacketIndex int
|
||||
flowList list.Model
|
||||
viewport viewport.Model
|
||||
analysisViewport viewport.Model
|
||||
|
||||
// Packet Details View
|
||||
detailsViewport viewport.Model
|
||||
@@ -240,20 +241,30 @@ func NewModel() Model {
|
||||
}
|
||||
|
||||
// Initialize model with zero-sized viewport, will resize on WindowSizeMsg or view entry
|
||||
// Viewport for Transaction Flow
|
||||
vp := viewport.New(0, 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{
|
||||
currentView: ViewDashboard,
|
||||
subView: SubViewNone,
|
||||
networkMap: nm,
|
||||
callFlowStore: sip.NewCallFlowStore(),
|
||||
lastPackets: make([]string, 0, 50),
|
||||
sshConfig: NewSSHConfigModel(),
|
||||
nodeInput: createNodeInputs(),
|
||||
viewport: vp,
|
||||
detailsViewport: viewport.New(0, 0),
|
||||
styles: defaultStyles(),
|
||||
currentView: ViewDashboard,
|
||||
subView: SubViewNone,
|
||||
networkMap: nm,
|
||||
callFlowStore: sip.NewCallFlowStore(),
|
||||
lastPackets: make([]string, 0, 50),
|
||||
sshConfig: NewSSHConfigModel(),
|
||||
nodeInput: createNodeInputs(),
|
||||
viewport: vp,
|
||||
analysisViewport: avp,
|
||||
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 m.subView == SubViewCallDetail {
|
||||
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 {
|
||||
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...)
|
||||
@@ -485,52 +511,27 @@ func (m *Model) handleViewKeys(msg tea.KeyMsg) tea.Cmd {
|
||||
case "up", "k":
|
||||
if m.selectedFlow > 0 {
|
||||
m.selectedFlow--
|
||||
if m.selectedFlow < m.analysisOffset {
|
||||
m.analysisOffset = m.selectedFlow
|
||||
}
|
||||
m.updateAnalysisView()
|
||||
}
|
||||
case "down", "j":
|
||||
flows := m.callFlowStore.GetSortedFlows()
|
||||
if m.selectedFlow < len(flows)-1 {
|
||||
m.selectedFlow++
|
||||
// Check if we need to scroll down
|
||||
// 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++
|
||||
}
|
||||
m.updateAnalysisView()
|
||||
}
|
||||
case "pgup":
|
||||
m.selectedFlow -= 10
|
||||
if m.selectedFlow < 0 {
|
||||
m.selectedFlow = 0
|
||||
}
|
||||
if m.selectedFlow < m.analysisOffset {
|
||||
m.analysisOffset = m.selectedFlow
|
||||
}
|
||||
m.updateAnalysisView()
|
||||
case "pgdown":
|
||||
flows := m.callFlowStore.GetSortedFlows()
|
||||
m.selectedFlow += 10
|
||||
if m.selectedFlow >= len(flows) {
|
||||
m.selectedFlow = len(flows) - 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
m.updateAnalysisView()
|
||||
case "enter":
|
||||
m.subView = SubViewCallDetail
|
||||
m.updateCallDetailView()
|
||||
@@ -1247,85 +1248,6 @@ func (m Model) viewCapture() string {
|
||||
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 {
|
||||
title := m.styles.Title.Render("🗺️ Network Map")
|
||||
|
||||
@@ -1541,6 +1463,70 @@ func (m *Model) updateCallDetailView() {
|
||||
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 {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user