refactor: Use viewport for Analysis interactions to enable proper scrolling
This commit is contained in:
@@ -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,9 +241,17 @@ 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,
|
||||||
@@ -252,7 +261,9 @@ func NewModel() Model {
|
|||||||
sshConfig: NewSSHConfigModel(),
|
sshConfig: NewSSHConfigModel(),
|
||||||
nodeInput: createNodeInputs(),
|
nodeInput: createNodeInputs(),
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
detailsViewport: viewport.New(0, 0),
|
analysisViewport: avp,
|
||||||
|
detailsViewport: dvp,
|
||||||
|
fileBrowser: NewFileBrowser(".", ".pcap"),
|
||||||
styles: defaultStyles(),
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user