summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/dashboard')
-rw-r--r--internal/tui/dashboard/doc.go2
-rw-r--r--internal/tui/dashboard/files.go5
-rw-r--r--internal/tui/dashboard/histogram.go14
-rw-r--r--internal/tui/dashboard/histogram_test.go2
-rw-r--r--internal/tui/dashboard/layout.go4
-rw-r--r--internal/tui/dashboard/model.go218
-rw-r--r--internal/tui/dashboard/model_test.go234
-rw-r--r--internal/tui/dashboard/overview.go45
-rw-r--r--internal/tui/dashboard/overview_test.go33
-rw-r--r--internal/tui/dashboard/processes.go5
-rw-r--r--internal/tui/dashboard/sparkline.go85
-rw-r--r--internal/tui/dashboard/sparkline_test.go82
-rw-r--r--internal/tui/dashboard/syscalls.go5
-rw-r--r--internal/tui/dashboard/tabs.go12
-rw-r--r--internal/tui/dashboard/tabs_test.go15
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")
}
}