diff options
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/doc.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 14 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/layout.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 218 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 234 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 45 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 33 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 85 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 82 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 12 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 15 |
15 files changed, 543 insertions, 218 deletions
diff --git a/internal/tui/dashboard/doc.go b/internal/tui/dashboard/doc.go new file mode 100644 index 0000000..b9bc30e --- /dev/null +++ b/internal/tui/dashboard/doc.go @@ -0,0 +1,2 @@ +// Package dashboard implements the multi-tab runtime dashboard used in TUI mode. +package dashboard diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index 80e3037..d43e215 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -2,12 +2,13 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" "path/filepath" "sort" "strconv" - "github.com/charmbracelet/bubbles/table" + "ior/internal/statsengine" + + "charm.land/bubbles/v2/table" ) type DirSnapshot struct { diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 7613230..28f5b2b 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -2,11 +2,12 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" - common "ior/internal/tui/common" "math" "strconv" "strings" + + "ior/internal/statsengine" + common "ior/internal/tui/common" ) func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { @@ -14,9 +15,10 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Latency: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -27,9 +29,10 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Gaps: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -53,6 +56,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he if width <= 0 { width = 80 } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) if height > 0 { @@ -93,7 +97,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he } lines = append(lines, "Scale: █▓▒░") - return common.PanelStyle.Width(panelInner).Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n")) } func renderHistogramBar(count, maxCount uint64, width int) string { diff --git a/internal/tui/dashboard/histogram_test.go b/internal/tui/dashboard/histogram_test.go index 7790394..48297a2 100644 --- a/internal/tui/dashboard/histogram_test.go +++ b/internal/tui/dashboard/histogram_test.go @@ -6,7 +6,7 @@ import ( "ior/internal/statsengine" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestRenderHistogramNoBuckets(t *testing.T) { diff --git a/internal/tui/dashboard/layout.go b/internal/tui/dashboard/layout.go index 0035a9d..75cbafb 100644 --- a/internal/tui/dashboard/layout.go +++ b/internal/tui/dashboard/layout.go @@ -4,7 +4,3 @@ const panelHorizontalChrome = 4 // Keep a small guard so sparkline rows never soft-wrap in panel cells. const sparklineSafetyMargin = 3 - -// Stats engine currently provides 120 time-series slots; cap rendering width -// so wide terminals don't introduce wrap/placement artifacts. -const sparklineMaxWidth = 120 diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index fc9caf6..d10a91a 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,20 +1,26 @@ package dashboard import ( + "strings" + "time" + "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" + flamegraphtui "ior/internal/tui/flamegraph" "ior/internal/tui/messages" - "strings" - "time" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" ) const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const flameRefreshMs = 200 const streamChromeRows = 4 +const dashboardHelpHintRows = 1 +const dashboardExpandedHelpRows = 2 +const dashboardTabBarRows = 1 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -23,6 +29,7 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type flameTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -31,8 +38,9 @@ type streamEditorDoneMsg struct { type Model struct { activeTab Tab - engine SnapshotSource - latest *statsengine.Snapshot + engine SnapshotSource + latest *statsengine.Snapshot + liveTrie flamegraphtui.LiveTrieSource width int height int @@ -46,32 +54,50 @@ type Model struct { filesDirOffset int processesOffset int streamModel eventstream.Model + flamegraphModel flamegraphtui.Model showHelp bool + isDark bool + focused bool } // NewModel creates a dashboard model with default refresh cadence. -func NewModel(engine SnapshotSource, streamSource *eventstream.RingBuffer) Model { +func NewModel(engine SnapshotSource, streamSource eventstream.Source) Model { return NewModelWithConfig(engine, streamSource, defaultRefreshMs, common.Keys) } // NewModelWithConfig creates a dashboard model with explicit refresh and keys. -func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuffer, refreshMs int, keys common.KeyMap) Model { +func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source, refreshMs int, keys common.KeyMap) Model { if refreshMs <= 0 { refreshMs = defaultRefreshMs } - return Model{ - activeTab: TabOverview, - engine: engine, - refreshEvery: time.Duration(refreshMs) * time.Millisecond, - keys: keys, - pidFilter: -1, - streamModel: eventstream.NewModel(streamSource), + m := Model{ + activeTab: TabFlame, + engine: engine, + refreshEvery: time.Duration(refreshMs) * time.Millisecond, + keys: keys, + pidFilter: -1, + streamModel: eventstream.NewModel(streamSource), + flamegraphModel: flamegraphtui.NewModel(nil), + isDark: true, + focused: true, } + m.SetDarkMode(true) + return m } // Init starts periodic refresh ticks. func (m Model) Init() tea.Cmd { - return tickCmd(m.refreshEvery) + cmds := []tea.Cmd{tickCmd(m.refreshEvery)} + switch m.activeTab { + case TabStream: + cmds = append(cmds, streamTickCmd()) + case TabFlame: + cmds = append(cmds, flameTickCmd()) + } + if len(cmds) == 1 { + return cmds[0] + } + return tea.Batch(cmds...) } // Update handles ticks, snapshots, tab changes, and resize events. @@ -82,19 +108,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) m.streamModel.SetViewport(streamWidth, streamHeight) + flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil case refreshTickMsg: + if !m.focused { + return m, nil + } snap := m.snapshot() return m, tea.Batch( tickCmd(m.refreshEvery), func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }, ) case streamTickMsg: + if !m.focused { + return m, nil + } if m.activeTab != TabStream { return m, nil } m.streamModel.Refresh() return m, streamTickCmd() + case flameTickMsg: + if !m.focused { + return m, nil + } + if m.activeTab != TabFlame { + return m, nil + } + var animCmd tea.Cmd + if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() { + animCmd = m.flamegraphModel.AnimationCmd() + } + if animCmd != nil { + return m, tea.Batch(flameTickCmd(), animCmd) + } + return m, flameTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -103,7 +152,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) m.streamModel.Refresh() return m, nil - case tea.KeyMsg: + case tea.KeyPressMsg: return m.handleKey(msg) case streamEditorDoneMsg: if msg.err != nil { @@ -111,17 +160,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.activeTab == TabFlame { + next, cmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, cmd + } return m, nil } -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() if keyStr == "H" { m.showHelp = !m.showHelp + flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil } + if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } handled, scrollCmd := m.handleScrollKey(msg) if scrollCmd != nil { cmd = scrollCmd @@ -132,29 +193,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !handled { switch { + case key.Matches(msg, m.keys.One): + m.activeTab = TabFlame + handled = true case key.Matches(msg, m.keys.Tab): m.activeTab = nextTab(m.activeTab) handled = true case key.Matches(msg, m.keys.ShiftTab): m.activeTab = prevTab(m.activeTab) handled = true - case key.Matches(msg, m.keys.One): - m.activeTab = TabOverview - handled = true case key.Matches(msg, m.keys.Two): - m.activeTab = TabSyscalls + m.activeTab = TabOverview handled = true case key.Matches(msg, m.keys.Three): - m.activeTab = TabFiles + m.activeTab = TabSyscalls handled = true case key.Matches(msg, m.keys.Four): - m.activeTab = TabProcesses + m.activeTab = TabFiles handled = true case key.Matches(msg, m.keys.Five): - m.activeTab = TabLatency + m.activeTab = TabProcesses handled = true case key.Matches(msg, m.keys.Six): - m.activeTab = TabStream + m.activeTab = TabLatency handled = true case key.Matches(msg, m.keys.Seven): m.activeTab = TabStream @@ -171,18 +232,34 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } if !handled { + if m.activeTab == TabFlame { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } return m, nil } + batch := make([]tea.Cmd, 0, 3) + if cmd != nil { + batch = append(batch, cmd) + } if prevActiveTab != TabStream && m.activeTab == TabStream { - if cmd == nil { - return m, streamTickCmd() - } - return m, tea.Batch(cmd, streamTickCmd()) + batch = append(batch, streamTickCmd()) + } + if prevActiveTab != TabFlame && m.activeTab == TabFlame { + batch = append(batch, flameTickCmd()) + } + switch len(batch) { + case 0: + return m, nil + case 1: + return m, batch[0] + default: + return m, tea.Batch(batch...) } - return m, cmd } -func (m *Model) handleScrollKey(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { keyStr := msg.String() switch m.activeTab { case TabSyscalls: @@ -271,26 +348,60 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } -// BlocksGlobalShortcuts reports whether modal UI in the active tab should -// suppress top-level shortcuts (for example global export key handling). -func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()) +// BlocksGlobalShortcuts reports whether the active tab should suppress a +// top-level shortcut for the given key press. +func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { + if m.activeTab == TabStream { + return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() + } + if m.activeTab == TabFlame { + return m.flamegraphModel.ConsumesKey(msg) + } + return false } // SetStreamSource updates the live stream source used by the stream tab. -func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { +func (m *Model) SetStreamSource(source eventstream.Source) { m.streamModel.SetSource(source) } +// SetLiveTrie updates the live trie source used by the flamegraph tab. +func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { + m.liveTrie = liveTrie + m.flamegraphModel.SetLiveTrie(liveTrie) + if m.width > 0 && m.height > 0 { + m.flamegraphModel.SetViewport(m.width, m.height) + } + m.flamegraphModel.RefreshFromLiveTrie() +} + +// SetDarkMode updates dashboard child models for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.streamModel.SetDarkMode(isDark) + m.flamegraphModel.SetDarkMode(isDark) +} + +// SetFocused controls whether periodic refresh ticks are processed. +func (m *Model) SetFocused(focused bool) { + m.focused = focused +} + +// SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot. +func (m Model) SnapshotCmd() tea.Cmd { + snap := m.snapshot() + return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } +} + // SetPidFilter updates the active PID filter used by tab render hints. func (m *Model) SetPidFilter(pid int) { m.pidFilter = pid } // View renders the tab bar, active tab scaffold, and help bar. -func (m Model) View() string { +func (m Model) View() tea.View { width, height := common.EffectiveViewport(m.width, m.height) - activeHeight := height + _, activeHeight := flameViewport(width, height, m.showHelp) streamModel := m.streamModel streamModel.SetFooterVisible(m.showHelp) if m.activeTab == TabStream { @@ -304,6 +415,7 @@ func (m Model) View() string { m.activeTab, m.latest, &streamModel, + &m.flamegraphModel, width, activeHeight, m.pidFilter, @@ -319,20 +431,27 @@ func (m Model) View() string { } else { b.WriteString(renderHelpHint(width)) } - return common.ScreenStyle.Render(b.String()) + return tea.NewView(common.ScreenStyle.Render(b.String())) } func tickCmd(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} }) } -func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { if tab == TabStream { if streamModel == nil { return common.PanelStyle.Render("Stream: waiting for source...") } return streamModel.View(width, height) } + if tab == TabFlame { + if flameModel == nil { + return common.PanelStyle.Render("Flame: waiting for model...") + } + flameModel.SetViewport(width, height) + return flameModel.View().Content + } if snap == nil { return common.PanelStyle.Render(tab.String() + ": waiting for stats...") @@ -361,6 +480,10 @@ func streamTickCmd() tea.Cmd { return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} }) } +func flameTickCmd() tea.Cmd { + return tea.Tick(flameRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return flameTickMsg{} }) +} + func streamViewport(width, height int) (int, int) { width, height = common.EffectiveViewport(width, height) height -= streamChromeRows @@ -369,3 +492,16 @@ func streamViewport(width, height int) (int, int) { } return width, height } + +func flameViewport(width, height int, showHelp bool) (int, int) { + width, height = common.EffectiveViewport(width, height) + chromeRows := dashboardTabBarRows + dashboardHelpHintRows + if showHelp { + chromeRows = dashboardTabBarRows + dashboardExpandedHelpRows + } + height -= chromeRows + if height < 1 { + height = 1 + } + return width, height +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 87b60e3..d5b78e0 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -7,12 +7,13 @@ import ( "strings" "testing" + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" "ior/internal/tui/messages" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) type fakeSnapshotSource struct { @@ -28,59 +29,60 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { func TestKeySwitchingChangesActiveTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) model := next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected syscalls tab, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected overview tab on key 2, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) model = next.(Model) - if model.activeTab != TabFiles { - t.Fatalf("expected next tab to be files, got %v", model.activeTab) + if model.activeTab != TabSyscalls { + t.Fatalf("expected next tab to be syscalls, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) model = next.(Model) - if model.activeTab != TabSyscalls { - t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab) + if model.activeTab != TabOverview { + t.Fatalf("expected previous tab to be overview, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})}) model = next.(Model) if model.activeTab != TabStream { t.Fatalf("expected stream tab on key 7, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'6'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'1'}[0], Text: string([]rune{'1'})}) model = next.(Model) - if model.activeTab != TabStream { - t.Fatalf("expected stream tab on key 6, got %v", model.activeTab) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab on key 1, got %v", model.activeTab) } } func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabOverview - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) model := next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected right arrow not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'l'}[0], Text: string([]rune{'l'})}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected l not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected left arrow not to change tabs, got %v", model.activeTab) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'h'}[0], Text: string([]rune{'h'})}) model = next.(Model) if model.activeTab != TabOverview { t.Fatalf("expected h not to change tabs, got %v", model.activeTab) @@ -93,13 +95,13 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}, {Name: "write", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.syscallsOffset != 1 { t.Fatalf("expected offset 1 after j, got %d", model.syscallsOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.syscallsOffset != 0 { t.Fatalf("expected offset 0 after k, got %d", model.syscallsOffset) @@ -112,13 +114,13 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1}, {PID: 2}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.processesOffset != 1 { t.Fatalf("expected processes offset 1 after j, got %d", model.processesOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.processesOffset != 0 { t.Fatalf("expected processes offset 0 after k, got %d", model.processesOffset) @@ -131,13 +133,13 @@ func TestFilesTabScrollsWithJK(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}, {Path: "/b"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.filesOffset != 1 { t.Fatalf("expected files offset 1 after j, got %d", model.filesOffset) } - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) model = next.(Model) if model.filesOffset != 0 { t.Fatalf("expected files offset 0 after k, got %d", model.filesOffset) @@ -155,7 +157,7 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) { }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) model := next.(Model) if model.filesDirOffset != 1 { t.Fatalf("expected grouped dir offset 1 after j, got %d", model.filesDirOffset) @@ -171,13 +173,73 @@ func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { m.activeTab = TabStream m.streamModel.HandleKey("space") // pause - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) _ = next if cmd == nil { t.Fatalf("expected stream tick command when unpausing stream") } } +func TestFlameTickRefreshesFlamegraphModel(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + liveTrie.Reset() + + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, cmd := m.Update(flameTickMsg{}) + model := next.(Model) + if cmd == nil { + t.Fatalf("expected flame tick to schedule next tick command") + } + if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want { + t.Fatalf("expected flame model version %d, got %d", want, got) + } +} + +func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + if !m.flamegraphModel.HasSnapshot() { + t.Fatalf("expected SetLiveTrie to preload a baseline snapshot") + } + + next, _ := m.Update(flameTickMsg{}) + model := next.(Model) + if !model.flamegraphModel.HasSnapshot() { + t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged") + } +} + +func TestFlameTickPausedFreezesAfterInitialSnapshot(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + model := next.(Model) + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + initialVersion := model.flamegraphModel.LastVersion() + + liveTrie.Reset() + if liveTrie.Version() == initialVersion { + t.Fatalf("expected reset to advance trie version") + } + + next, _ = model.Update(flameTickMsg{}) + model = next.(Model) + if got, want := model.flamegraphModel.LastVersion(), initialVersion; got != want { + t.Fatalf("expected paused flame tick to freeze version at %d, got %d", want, got) + } +} + func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { rb := eventstream.NewRingBuffer() for i := 0; i < 300; i++ { @@ -200,34 +262,34 @@ func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { m.streamModel.Refresh() _ = m.View() - next, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) // pause m = next.(Model) - before := rowFromStreamView(t, m.View()) + before := rowFromStreamView(t, m.View().Content) - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: string([]rune{'k'})}) m = next.(Model) - afterK := rowFromStreamView(t, m.View()) + afterK := rowFromStreamView(t, m.View().Content) if afterK >= before { t.Fatalf("expected k to scroll up while paused: before=%d afterK=%d", before, afterK) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = next.(Model) - afterDown := rowFromStreamView(t, m.View()) + afterDown := rowFromStreamView(t, m.View().Content) if afterDown <= afterK { t.Fatalf("expected down arrow to scroll down while paused: afterK=%d afterDown=%d", afterK, afterDown) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) m = next.(Model) - afterPgUp := rowFromStreamView(t, m.View()) + afterPgUp := rowFromStreamView(t, m.View().Content) if afterPgUp >= afterDown { t.Fatalf("expected pgup to scroll up while paused: afterDown=%d afterPgUp=%d", afterDown, afterPgUp) } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) m = next.(Model) - afterPgDown := rowFromStreamView(t, m.View()) + afterPgDown := rowFromStreamView(t, m.View().Content) if afterPgDown <= afterPgUp { t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) } @@ -251,14 +313,14 @@ func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) model := next.(Model) if !model.filesDirGrouped { t.Fatalf("expected filesDirGrouped to toggle on files tab") } model.activeTab = TabOverview - next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) model = next.(Model) if !model.filesDirGrouped { t.Fatalf("expected filesDirGrouped unchanged outside files tab") @@ -272,7 +334,7 @@ func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { m.latest = &snap for i := 0; i < 50; i++ { - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) m = next.(Model) } if m.syscallsOffset != 1 { @@ -284,7 +346,8 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 13} engine := &fakeSnapshotSource{snap: snap} m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m.activeTab = TabOverview + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) _ = next if cmd == nil { t.Fatalf("expected refresh command") @@ -299,6 +362,63 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { } } +func TestFlameTabReceivesSlashKey(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if cmd != nil { + t.Fatalf("did not expect global command for flame search key") + } + if !strings.Contains(model.View().Content, "0/0 matches") { + t.Fatalf("expected flame search footer after pressing /") + } +} + +func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + model := next.(Model) + if !strings.Contains(model.View().Content, "[PAUSED]") { + t.Fatalf("expected flame space key to toggle paused state") + } + + next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) + model = next.(Model) + if cmd != nil { + t.Fatalf("expected flame reset key to be handled by flame tab without global refresh command") + } + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after reset key") + } +} + +func TestFlameSearchConsumesNumericTabKeys(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})}) + model := next.(Model) + if model.activeTab != TabFlame { + t.Fatalf("expected flame tab to stay active after opening search") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + model = next.(Model) + if model.activeTab != TabFlame { + t.Fatalf("expected numeric key while searching to stay in flame tab") + } +} + func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 9} engine := &fakeSnapshotSource{snap: snap} @@ -366,9 +486,9 @@ func TestStatsTickClampsGroupedFilesOffset(t *testing.T) { func TestViewRendersTabBarAndHelp(t *testing.T) { m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) - out := m.View() - if !strings.Contains(out, "Overview") { - t.Fatalf("expected overview label in view") + out := m.View().Content + if !strings.Contains(out, "Flame") { + t.Fatalf("expected flame tab label in view") } if !strings.Contains(out, "press H for help") { t.Fatalf("expected help hint text in view") @@ -378,6 +498,18 @@ func TestViewRendersTabBarAndHelp(t *testing.T) { } } +func TestFlameTabRendersWaitingForDataPlaceholder(t *testing.T) { + m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) + m.activeTab = TabFlame + m.width = 120 + m.height = 30 + + out := m.View().Content + if !strings.Contains(out, "Flame: waiting for data...") { + t.Fatalf("expected flame waiting placeholder, got %q", out) + } +} + func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { snap := statsengine.NewSnapshot( nil, nil, nil, nil, @@ -386,7 +518,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}, ) - out := renderActiveTab(TabFiles, &snap, nil, 120, 30, -1, 0, 0, true, 0, 0) + out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0) if !strings.Contains(out, "Directory") { t.Fatalf("expected grouped directory files view header, got %q", out) } @@ -405,8 +537,8 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { m.streamModel.SetSource(rb) m.streamModel.Refresh() - out := m.View() - if !strings.Contains(out, "1:Overview") { + out := m.View().Content + if !strings.Contains(out, "1:Flame") { t.Fatalf("expected tab bar to remain visible in stream view") } if !strings.Contains(out, "press H for help") { @@ -416,21 +548,21 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { func TestHelpToggleWithH(t *testing.T) { m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) - out := m.View() + out := m.View().Content if !strings.Contains(out, "press H for help") { t.Fatalf("expected default help hint") } - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) m = next.(Model) - out = m.View() + out = m.View().Content if !strings.Contains(out, "tab next tab") { t.Fatalf("expected expanded help after pressing h") } - next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'H'}[0], Text: string([]rune{'H'})}) m = next.(Model) - out = m.View() + out = m.View().Content if !strings.Contains(out, "press H for help") { t.Fatalf("expected help hint after pressing h again") } diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go index 5b8fab8..24932b9 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -2,13 +2,14 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" - common "ior/internal/tui/common" "strings" "time" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "ior/internal/statsengine" + common "ior/internal/tui/common" + + "charm.land/lipgloss/v2" ) func renderOverview(snap *statsengine.Snapshot, width, height int) string { @@ -33,6 +34,7 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { trendWithArrow(snap.ThroughputTrend), ) + panelW := panelWidth(width) panelInner := panelInnerWidth(width) labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") latencySpark := renderOverviewSparklineAligned("Latency:", snap.LatencySeriesNs(), panelInner, labelWidth) @@ -44,8 +46,8 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { latencyHist := "Latency buckets: " + summarizeHistogramBrief(snap.LatencyHistogram) gapHist := "Gap buckets: " + summarizeHistogramBrief(snap.GapHistogram) - panel := common.PanelStyle.Width(panelInner) - sparkPanel := panel.Render(strings.Join([]string{latencySpark, "", gapSpark, "", throughputSpark}, "\n")) + panel := common.PanelStyle.Width(panelW) + sparkPanel := panel.Render(strings.Join([]string{latencySpark, gapSpark, throughputSpark}, "\n")) topPanel := panel.Render(strings.Join([]string{topSyscalls, topFiles, topProcesses}, "\n")) histPanel := panel.Render(strings.Join([]string{latencyHist, gapHist}, "\n")) @@ -73,7 +75,7 @@ func renderSyscallBox(snap *statsengine.Snapshot, width int) string { snap.SyscallRatePerSec, generatedAt, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderBytesBox(snap *statsengine.Snapshot, width int) string { @@ -83,7 +85,7 @@ func renderBytesBox(snap *statsengine.Snapshot, width int) string { formatBytes(snap.WriteBytesPerSec), formatBytes(float64(snap.TotalBytes)), ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderErrorBox(snap *statsengine.Snapshot, width int) string { @@ -99,7 +101,7 @@ func renderErrorBox(snap *statsengine.Snapshot, width int) string { snap.LatencyMeanNs, snap.GapMeanNs, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func trendWithArrow(trend statsengine.Trend) string { @@ -212,19 +214,8 @@ func summaryBoxWidth(width int) int { return w } -func summaryBoxInnerWidth(width int) int { - inner := width - panelHorizontalChrome - if inner < 14 { - return 14 - } - return inner -} - func renderOverviewSparkline(label string, data []float64, panelInner int) string { w := panelInner - utf8.RuneCountInString(label) - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -234,9 +225,6 @@ func renderOverviewSparkline(label string, data []float64, panelInner int) strin func renderOverviewSparklineAligned(label string, data []float64, panelInner int, labelWidth int) string { paddedLabel := padLabelRight(label, labelWidth) w := panelInner - labelWidth - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -262,13 +250,20 @@ func padLabelRight(label string, width int) string { return label + strings.Repeat(" ", pad) } -func panelInnerWidth(width int) int { +func panelWidth(width int) int { if width <= 0 { width = 80 } - inner := width - panelHorizontalChrome - if inner < 20 { + if width < 20 { return 20 } + return width +} + +func panelInnerWidth(width int) int { + inner := panelWidth(width) - panelHorizontalChrome + if inner < 16 { + return 16 + } return inner } diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go index 9895490..6ac3704 100644 --- a/internal/tui/dashboard/overview_test.go +++ b/internal/tui/dashboard/overview_test.go @@ -6,8 +6,9 @@ import ( "time" "ior/internal/statsengine" + common "ior/internal/tui/common" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestRenderOverviewIncludesCoreMetrics(t *testing.T) { @@ -121,23 +122,22 @@ func TestRenderOverviewDoesNotOverflowWidth(t *testing.T) { func TestRenderOverviewSparklineHasSafetyMargin(t *testing.T) { const panelInner = 80 out := renderOverviewSparkline("Latency:", []float64{1, 2, 3, 4, 5}, panelInner) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got, max := lipgloss.Width(lines[0]), panelInner-sparklineSafetyMargin; got > max { + if got, max := lipgloss.Width(out), panelInner-sparklineSafetyMargin; got > max { t.Fatalf("expected sparkline width <= %d with safety margin, got %d", max, got) } } -func TestRenderOverviewSparklineCapsWidth(t *testing.T) { +func TestRenderOverviewSparklineUsesAvailableWidth(t *testing.T) { out := renderOverviewSparkline("Latency:", make([]float64, 120), 400) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got := lipgloss.Width(lines[0]) - len("Latency: "); got > sparklineMaxWidth { - t.Fatalf("expected capped sparkline width <= %d, got %d", sparklineMaxWidth, got) + want := 400 - len("Latency:") - 1 - sparklineSafetyMargin + if got := lipgloss.Width(out) - len("Latency: "); got != want { + t.Fatalf("expected sparkline width %d, got %d", want, got) } } @@ -164,3 +164,14 @@ func TestRenderOverviewSparklineAlignedUsesSameSparkStartColumn(t *testing.T) { t.Fatalf("unexpected throughput prefix: %q", thrTop) } } + +func TestRenderOverviewSparklineAlignedFitsSinglePanelRow(t *testing.T) { + panelW := panelWidth(220) + panelInner := panelInnerWidth(220) + labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") + line := renderOverviewSparklineAligned("Latency:", []float64{0, 10, 5, 10, 0}, panelInner, labelWidth) + rendered := common.PanelStyle.Width(panelW).Render(line) + if got := len(strings.Split(rendered, "\n")); got != 3 { + t.Fatalf("expected sparkline to fit one panel row (3 total lines with border), got %d lines", got) + } +} diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index 281a86a..a5e8d79 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -2,11 +2,12 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" "strconv" "strings" - "github.com/charmbracelet/bubbles/table" + "ior/internal/statsengine" + + "charm.land/bubbles/v2/table" ) func renderProcesses(snap *statsengine.Snapshot, width, height int) string { diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go index 2ce8c90..ab78cce 100644 --- a/internal/tui/dashboard/sparkline.go +++ b/internal/tui/dashboard/sparkline.go @@ -1,9 +1,8 @@ package dashboard import "math" -import "strings" -var sparkRowChars = []rune(" ▁▂▃▄▅▆▇█") +var sparkChars = []rune("▁▂▃▄▅▆▇█") func renderSparkline(data []float64, width int) string { if len(data) == 0 || width <= 0 { @@ -11,23 +10,15 @@ func renderSparkline(data []float64, width int) string { } samples := sampleForWidth(data, width) - leftPad := 0 - if len(samples) < width { - leftPad = width - len(samples) - } min, max := minMax(samples) if min == max { - top := repeatRune(' ', width) - bottom := repeatRune(' ', leftPad) + repeatRune('█', len(samples)) - return top + "\n" + bottom + if min == 0 { + return repeatRune(' ', width) + } + return repeatRune('▁', width) } - top := make([]rune, width) - bottom := make([]rune, width) - for i := 0; i < leftPad; i++ { - top[i] = ' ' - bottom[i] = ' ' - } + row := make([]rune, width) scale := 16.0 denom := max - min for i, value := range samples { @@ -39,20 +30,17 @@ func renderSparkline(data []float64, width int) string { level = 16 } - topLevel := level - 8 - if topLevel < 0 { - topLevel = 0 + // Collapse the previous two-row 0..16 scale to a single-row 0..7 scale. + oneRow := level / 2 + if oneRow < 0 { + oneRow = 0 } - bottomLevel := level - if bottomLevel > 8 { - bottomLevel = 8 + if oneRow > 7 { + oneRow = 7 } - - col := leftPad + i - top[col] = sparkRowChars[topLevel] - bottom[col] = sparkRowChars[bottomLevel] + row[i] = sparkChars[oneRow] } - return string(top) + "\n" + string(bottom) + return string(row) } func renderLabeledSparkline(label string, data []float64, width int) string { @@ -60,20 +48,47 @@ func renderLabeledSparkline(label string, data []float64, width int) string { if spark == "" { return label } - lines := strings.Split(spark, "\n") - if len(lines) == 1 { - return label + " " + lines[0] - } - pad := repeatRune(' ', len([]rune(label))+1) - return label + " " + lines[0] + "\n" + pad + lines[1] + return label + " " + spark } func sampleForWidth(data []float64, width int) []float64 { - if width >= len(data) { + if width <= 0 || len(data) == 0 { + return nil + } + + if width < len(data) { + start := len(data) - width + return append([]float64(nil), data[start:]...) + } + + if width == len(data) { return append([]float64(nil), data...) } - start := len(data) - width - return append([]float64(nil), data[start:]...) + + if len(data) == 1 { + out := make([]float64, width) + for i := range out { + out[i] = data[0] + } + return out + } + + out := make([]float64, width) + srcLast := len(data) - 1 + dstLast := width - 1 + for i := 0; i < width; i++ { + // Nearest-neighbor upsampling preserves the original series shape + // without introducing interpolated spikes between samples. + srcIdx := int(math.Round(float64(i) * float64(srcLast) / float64(dstLast))) + if srcIdx < 0 { + srcIdx = 0 + } + if srcIdx > srcLast { + srcIdx = srcLast + } + out[i] = data[srcIdx] + } + return out } func minMax(values []float64) (float64, float64) { diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go index d7acd33..6f549d1 100644 --- a/internal/tui/dashboard/sparkline_test.go +++ b/internal/tui/dashboard/sparkline_test.go @@ -16,37 +16,52 @@ func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { func TestRenderSparklineSingleValue(t *testing.T) { got := renderSparkline([]float64{10}, 8) - if got != " \n █" { - t.Fatalf("expected two-line constant sparkline, got %q", got) + if got != "▁▁▁▁▁▁▁▁" { + t.Fatalf("expected single-line constant sparkline, got %q", got) } } func TestRenderSparklineAllEqualValues(t *testing.T) { got := renderSparkline([]float64{5, 5, 5, 5}, 4) - if got != " \n████" { - t.Fatalf("expected two-line flat sparkline, got %q", got) + if got != "▁▁▁▁" { + t.Fatalf("expected single-line flat sparkline, got %q", got) } } -func TestRenderSparklineRightAlignsShortHistory(t *testing.T) { +func TestRenderSparklineAllZeroValuesRendersBlank(t *testing.T) { + got := renderSparkline([]float64{0, 0, 0}, 5) + if got != " " { + t.Fatalf("expected blank sparkline for all-zero series, got %q", got) + } +} + +func TestRenderSparklineLeftAlignsShortHistory(t *testing.T) { got := renderSparkline([]float64{1, 2, 3}, 6) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) + first := strings.IndexFunc(got, func(r rune) bool { return r != ' ' }) + last := strings.LastIndexFunc(got, func(r rune) bool { return r != ' ' }) + if first < 0 || last < 0 { + t.Fatalf("expected visible sparkline cells, got %q", got) + } + if strings.HasPrefix(got, " ") { + t.Fatalf("expected sparkline not to use old right-aligned padding, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected left padding for short history, got %q", lines[1]) +} + +func TestRenderSparklineUsesRightmostColumn(t *testing.T) { + got := renderSparkline([]float64{1, 3, 2, 5}, 20) + row := []rune(got) + if len(row) != 20 { + t.Fatalf("expected 20 columns, got %d", len(row)) + } + if row[19] == ' ' { + t.Fatalf("expected rightmost column to contain sparkline data, got %q", got) } } func TestRenderSparklineRespectsWidthTruncation(t *testing.T) { got := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 4) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if len([]rune(lines[0])) != 4 || len([]rune(lines[1])) != 4 { - t.Fatalf("expected 4 runes per line, got %q", got) + if len([]rune(got)) != 4 { + t.Fatalf("expected 4 runes, got %q", got) } } @@ -63,27 +78,32 @@ func TestSampleForWidthUsesRecentTail(t *testing.T) { } } +func TestSampleForWidthUpsamplesToFullWidth(t *testing.T) { + got := sampleForWidth([]float64{10, 20, 30}, 7) + if len(got) != 7 { + t.Fatalf("expected 7 samples, got %d", len(got)) + } + if got[0] != 10 { + t.Fatalf("expected first sample to preserve series start, got %v", got[0]) + } + if got[len(got)-1] != 30 { + t.Fatalf("expected last sample to preserve series end, got %v", got[len(got)-1]) + } +} + func TestRenderSparklineSpansLowToHigh(t *testing.T) { got := renderSparkline([]float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.Contains(got, "█") { - t.Fatalf("expected high bar, got %q", got) + if got != "▁█" { + t.Fatalf("expected low-to-high one-row sparkline, got %q", got) } } -func TestRenderLabeledSparklineAlignsSecondRow(t *testing.T) { +func TestRenderLabeledSparklineSingleLine(t *testing.T) { got := renderLabeledSparkline("Latency:", []float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.HasPrefix(lines[0], "Latency: ") { - t.Fatalf("expected label prefix on first row, got %q", lines[0]) + if strings.Contains(got, "\n") { + t.Fatalf("expected single-line labeled sparkline, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected padding on second row to align sparkline, got %q", lines[1]) + if !strings.HasPrefix(got, "Latency: ") { + t.Fatalf("expected label prefix, got %q", got) } } diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 23fe37c..87acc80 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -2,11 +2,12 @@ package dashboard import ( "fmt" - "ior/internal/statsengine" "strconv" "time" - "github.com/charmbracelet/bubbles/table" + "ior/internal/statsengine" + + "charm.land/bubbles/v2/table" ) func renderSyscalls(snap *statsengine.Snapshot, width, height int) string { diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index df8f03e..5d15acc 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -2,11 +2,12 @@ package dashboard import ( "fmt" - common "ior/internal/tui/common" "strings" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + common "ior/internal/tui/common" + + "charm.land/lipgloss/v2" ) // Tab is a dashboard tab identifier. @@ -25,9 +26,12 @@ const ( TabLatency // TabStream is the live event stream tab. TabStream + // TabFlame is the live flamegraph tab. + TabFlame ) var allTabs = []Tab{ + TabFlame, TabOverview, TabSyscalls, TabFiles, @@ -50,6 +54,8 @@ func (t Tab) String() string { return "Latency+Gaps" case TabStream: return "Stream" + case TabFlame: + return "Flame" default: return "Unknown" } @@ -192,6 +198,8 @@ func tabLabel(tab Tab, short bool) string { return "Lat" case TabStream: return "Str" + case TabFlame: + return "Flm" default: return "Unk" } diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 1148103..16f8b76 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -11,17 +11,20 @@ func TestTabNavigationWraps(t *testing.T) { if got := nextTab(TabLatency); got != TabStream { t.Fatalf("expected next after latency+gaps to be stream, got %v", got) } - if got := nextTab(TabStream); got != TabOverview { - t.Fatalf("expected wrap to overview from stream, got %v", got) + if got := nextTab(TabStream); got != TabFlame { + t.Fatalf("expected next after stream to be flame, got %v", got) } - if got := prevTab(TabOverview); got != TabStream { - t.Fatalf("expected wrap to stream, got %v", got) + if got := nextTab(TabFlame); got != TabOverview { + t.Fatalf("expected wrap to overview from flame, got %v", got) + } + if got := prevTab(TabOverview); got != TabFlame { + t.Fatalf("expected wrap to flame, got %v", got) } } func TestRenderTabBarContainsLabels(t *testing.T) { out := renderTabBar(TabOverview, 100) - for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream"} { + for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream", "Flame"} { if !strings.Contains(out, label) { t.Fatalf("expected tab label %q in tab bar", label) } @@ -34,7 +37,7 @@ func TestRenderTabBarSmallWidthUsesSingleLine(t *testing.T) { if len(lines) != 1 { t.Fatalf("expected single-line tab bar at width 70, got %d lines", len(lines)) } - if strings.Contains(out, "6:Strea") { + if strings.Contains(out, "7:Flam") { t.Fatalf("tab label should not be wrapped/split in small width output") } } |
