summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 09:45:02 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 09:45:02 +0200
commitf2d79f6459bbe1aa9bae2946e9773141cb184463 (patch)
treee683b901d2432ac7e28cd6e80f468da38edc280b
parent7fc16d6c98feae7aaee58666dc552384ceb4895e (diff)
tui: wire full dashboard tabs and improve overview summaries
-rw-r--r--Magefile.go4
-rw-r--r--internal/flags/flags.go7
-rw-r--r--internal/ior.go43
-rw-r--r--internal/ior_test.go6
-rw-r--r--internal/tui/common/keys.go62
-rw-r--r--internal/tui/common/styles.go61
-rw-r--r--internal/tui/dashboard/histogram.go14
-rw-r--r--internal/tui/dashboard/model.go21
-rw-r--r--internal/tui/dashboard/model_test.go17
-rw-r--r--internal/tui/dashboard/overview.go94
-rw-r--r--internal/tui/dashboard/overview_test.go27
-rw-r--r--internal/tui/dashboard/tabs.go10
-rw-r--r--internal/tui/keys.go56
-rw-r--r--internal/tui/styles.go53
-rw-r--r--internal/tui/tui.go75
-rw-r--r--internal/tui/tui_test.go39
16 files changed, 376 insertions, 213 deletions
diff --git a/Magefile.go b/Magefile.go
index 50d2bea..edc106c 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -51,19 +51,21 @@ func Default() {
// Build compiles the binary.
func Build() error {
+ mg.Deps(BpfBuild)
return sh.RunWithV(goEnv(), "go", "build", "-tags", "netgo", "-ldflags", "-w -extldflags \"-static\"",
"-o", binaryName, "./cmd/ior/main.go")
}
// GoBuildRace compiles the binary with the race detector enabled.
func GoBuildRace() error {
+ mg.Deps(BpfBuild)
return sh.RunWithV(goEnv(), "go", "build", "-tags", "netgo", "-ldflags", "-w -extldflags \"-static\"",
"-race", "-o", binaryName, "./cmd/ior/main.go")
}
// All builds the BPF object and the Go binary.
func All() error {
- mg.SerialDeps(BpfBuild, Build)
+ mg.SerialDeps(Build)
return nil
}
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 1909b4a..729b1b6 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -160,25 +160,20 @@ func extractTracepointFlags(tracepoints string) (regexes []*regexp.Regexp) {
}
func (flags Flags) ShouldIAttachTracepoint(tracepointName string) bool {
- fmt.Println("ShouldIAttachTracepoint called with", tracepointName)
for _, re := range flags.TracepointsToExclude {
if re.MatchString(tracepointName) {
- fmt.Println("Not attaching", tracepointName, "as excluded")
return false
}
}
if len(flags.TracepointsToAttach) == 0 {
- fmt.Println("Attaching", tracepointName, "as none are explicitly incluced")
return true
}
for _, re := range flags.TracepointsToAttach {
if re.MatchString(tracepointName) {
- fmt.Println("Attaching", tracepointName, "as included")
return true
}
}
- fmt.Println("Not attaching", tracepointName, "as not includedd")
return false
}
@@ -188,12 +183,10 @@ func (flags Flags) SetBPF(bpfModule *bpf.Module) error {
return fmt.Errorf("unable set IOR_PID_FILTER: %w", err)
}
- fmt.Println("Setting PID_FILTER to", flags.PidFilter)
if err := bpfModule.InitGlobalVariable("PID_FILTER", uint32(flags.PidFilter)); err != nil {
return fmt.Errorf("unable to set up PID_FILTER global variable: %w", err)
}
- fmt.Println("Setting TID_FILTER to", flags.TidFilter)
if err := bpfModule.InitGlobalVariable("TID_FILTER", uint32(flags.TidFilter)); err != nil {
return fmt.Errorf("unable to set up TID_FILTER global variable: %w", err)
}
diff --git a/internal/ior.go b/internal/ior.go
index 1d67892..e46796b 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -57,28 +57,35 @@ func (m libbpfTracepointModule) getProgram(progName string) (tracepointProgram,
}
func attachTracepoints(bpfModule *bpf.Module) error {
- return attachTracepointsWith(libbpfTracepointModule{module: bpfModule}, flags.Get().ShouldIAttachTracepoint, tracepoints.List)
+ return attachTracepointsWith(libbpfTracepointModule{module: bpfModule}, flags.Get().ShouldIAttachTracepoint, tracepoints.List, true)
}
-func attachTracepointsWith(module tracepointModule, shouldAttach func(string) bool, tracepointNames []string) error {
+func attachTracepointsWith(module tracepointModule, shouldAttach func(string) bool, tracepointNames []string, verbose bool) error {
+ logln := func(...any) {}
+ logf := func(string, ...any) {}
+ if verbose {
+ logln = func(args ...any) { _, _ = fmt.Println(args...) }
+ logf = func(format string, args ...any) { _, _ = fmt.Printf(format, args...) }
+ }
+
for _, name := range tracepointNames {
if !shouldAttach(name) {
continue
}
- fmt.Println("Attaching tracepoint", name)
+ logln("Attaching tracepoint", name)
prog, err := module.getProgram(fmt.Sprintf("handle_%s", name))
if err != nil {
return fmt.Errorf("Failed to get BPF program handle_%s: %v", name, err)
}
- fmt.Println("Attached prog handle_", name)
+ logln("Attached prog handle_", name)
if err = prog.attachTracepoint("syscalls", name); err != nil {
// OK, older Kernel versions may not have this tracepoint!
- fmt.Printf("Failed to attach to %s tracepoint: %v, kernel version may be too old, skipping", name, err)
+ logf("Failed to attach to %s tracepoint: %v, kernel version may be too old, skipping", name, err)
continue
}
- fmt.Println("Attached tracepoint ", name)
+ logln("Attached tracepoint ", name)
}
return nil
@@ -128,6 +135,10 @@ func tuiTraceStarterFromRunTrace(
startTrace func(context.Context, chan<- struct{}, func(*eventLoop)) error,
) tui.TraceStarter {
return func(ctx context.Context) error {
+ bpf.SetLoggerCbs(bpf.Callbacks{
+ Log: func(int, string) {},
+ })
+
engine := statsengine.NewEngine(64)
tui.SetDashboardSnapshotSource(engine)
@@ -160,6 +171,12 @@ func runTrace() error {
}
func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, configure func(*eventLoop)) error {
+ verbose := started == nil
+ logln := func(...any) {}
+ if verbose {
+ logln = func(args ...any) { _, _ = fmt.Println(args...) }
+ }
+
bpfModule, err := bpf.NewModuleFromFile("ior.bpf.o")
if err != nil {
return err
@@ -178,7 +195,7 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
return err
}
- if err := attachTracepoints(bpfModule); err != nil {
+ if err := attachTracepointsWith(libbpfTracepointModule{module: bpfModule}, flags.Get().ShouldIAttachTracepoint, tracepoints.List, verbose); err != nil {
return err
}
@@ -211,7 +228,7 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
configure(el)
}
duration := time.Duration(flags.Get().Duration) * time.Second
- fmt.Println("Probing for", duration)
+ logln("Probing for", duration)
ctx, cancel := context.WithTimeout(parentCtx, duration)
defer cancel()
@@ -222,7 +239,7 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
go func() {
select {
case <-signalCh:
- fmt.Println("Received signal, shutting down...")
+ logln("Received signal, shutting down...")
cancel()
case <-ctx.Done():
return
@@ -231,9 +248,11 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
go func() {
<-ctx.Done()
- fmt.Println(el.stats())
+ if verbose {
+ fmt.Println(el.stats())
+ }
if flags.Get().PprofEnable {
- fmt.Println("Stoppig profiling, writing ior.cpuprofile and ior.memprofile")
+ logln("Stoppig profiling, writing ior.cpuprofile and ior.memprofile")
pprof.StopCPUProfile()
pprof.WriteHeapProfile(memProfile)
close(pprofDone)
@@ -244,7 +263,7 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
el.run(ctx, ch)
totalDuration := time.Since(startTime)
<-pprofDone
- fmt.Println("Good bye... (unloading BPF tracepoints will take a few seconds...) after", totalDuration)
+ logln("Good bye... (unloading BPF tracepoints will take a few seconds...) after", totalDuration)
return nil
}
diff --git a/internal/ior_test.go b/internal/ior_test.go
index 6495e76..7f7eb20 100644
--- a/internal/ior_test.go
+++ b/internal/ior_test.go
@@ -44,7 +44,7 @@ func TestAttachTracepointsWithSkipsFilteredTracepoints(t *testing.T) {
err := attachTracepointsWith(module, func(tracepoint string) bool {
return tracepoint == "sys_enter_read"
- }, []string{"sys_enter_read", "sys_enter_write"})
+ }, []string{"sys_enter_read", "sys_enter_write"}, false)
if err != nil {
t.Fatalf("attachTracepointsWith returned error: %v", err)
}
@@ -69,7 +69,7 @@ func TestAttachTracepointsWithReturnsErrorWhenProgramMissing(t *testing.T) {
},
}
- err := attachTracepointsWith(module, func(string) bool { return true }, []string{"sys_enter_read"})
+ err := attachTracepointsWith(module, func(string) bool { return true }, []string{"sys_enter_read"}, false)
if err == nil {
t.Fatal("attachTracepointsWith returned nil error, want non-nil")
}
@@ -87,7 +87,7 @@ func TestAttachTracepointsWithAttachFailureContinues(t *testing.T) {
getProgramErrs: map[string]error{},
}
- err := attachTracepointsWith(module, func(string) bool { return true }, []string{"sys_enter_read", "sys_enter_write"})
+ err := attachTracepointsWith(module, func(string) bool { return true }, []string{"sys_enter_read", "sys_enter_write"}, false)
if err != nil {
t.Fatalf("attachTracepointsWith returned error: %v", err)
}
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
new file mode 100644
index 0000000..0dae542
--- /dev/null
+++ b/internal/tui/common/keys.go
@@ -0,0 +1,62 @@
+package common
+
+import "github.com/charmbracelet/bubbles/key"
+
+// KeyMap groups all key bindings shared by TUI screens.
+type KeyMap struct {
+ Tab key.Binding
+ ShiftTab key.Binding
+ One key.Binding
+ Two key.Binding
+ Three key.Binding
+ Four key.Binding
+ Five key.Binding
+ Six key.Binding
+ Export key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Enter key.Binding
+ Esc key.Binding
+ Refresh key.Binding
+}
+
+// Keys contains the default shared key map.
+var Keys = DefaultKeyMap()
+
+// DefaultKeyMap builds the default key bindings used by models.
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")),
+ ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")),
+ One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "overview")),
+ Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "syscalls")),
+ Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "files")),
+ Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "processes")),
+ Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "latency")),
+ Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "gaps")),
+ Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "export")),
+ Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
+ Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
+ Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
+ Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
+ Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
+ }
+}
+
+// DashboardShortHelp returns compact bindings for dashboard help bars.
+func (k KeyMap) DashboardShortHelp() []key.Binding {
+ return []key.Binding{k.Tab, k.ShiftTab, k.Export, k.Help, k.Quit}
+}
+
+// DashboardFullHelp returns grouped bindings for dashboard overlays.
+func (k KeyMap) DashboardFullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six},
+ {k.Tab, k.ShiftTab, k.Export, k.Help, k.Quit},
+ }
+}
+
+// PickerShortHelp returns compact bindings for the PID picker.
+func (k KeyMap) PickerShortHelp() []key.Binding {
+ return []key.Binding{k.Enter, k.Refresh, k.Esc}
+}
diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go
new file mode 100644
index 0000000..ed6a191
--- /dev/null
+++ b/internal/tui/common/styles.go
@@ -0,0 +1,61 @@
+package common
+
+import "github.com/charmbracelet/lipgloss"
+
+var (
+ // Palette colors shared across the TUI package.
+ ColorBackground = lipgloss.Color("235")
+ ColorPanel = lipgloss.Color("238")
+ ColorPrimary = lipgloss.Color("75")
+ ColorAccent = lipgloss.Color("222")
+ ColorMuted = lipgloss.Color("246")
+ ColorText = lipgloss.Color("255")
+ ColorDanger = lipgloss.Color("203")
+)
+
+var (
+ // ScreenStyle is the base style for full-screen models.
+ ScreenStyle = lipgloss.NewStyle().
+ Foreground(ColorText).
+ Background(ColorBackground)
+
+ // HeaderStyle is used by top-level titles and screen headers.
+ HeaderStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorPrimary)
+
+ // TabActiveStyle is applied to the currently-selected tab.
+ TabActiveStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorBackground).
+ Background(ColorPrimary).
+ Padding(0, 1)
+
+ // TabInactiveStyle is applied to non-selected tabs.
+ TabInactiveStyle = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ Background(ColorPanel).
+ Padding(0, 1)
+
+ // PanelStyle is used for boxed sections.
+ PanelStyle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder()).
+ BorderForeground(ColorPanel).
+ Padding(0, 1)
+
+ // HelpBarStyle is used for keybinding hints at the bottom.
+ HelpBarStyle = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ BorderTop(true).
+ BorderForeground(ColorPanel)
+
+ // HighlightStyle emphasizes inline values.
+ HighlightStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorAccent)
+
+ // ErrorStyle is used for fatal or warning messages.
+ ErrorStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorDanger)
+)
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go
index a95159a..b2bb88e 100644
--- a/internal/tui/dashboard/histogram.go
+++ b/internal/tui/dashboard/histogram.go
@@ -3,7 +3,7 @@ package dashboard
import (
"fmt"
"ior/internal/statsengine"
- "ior/internal/tui"
+ common "ior/internal/tui/common"
"math"
"strconv"
"strings"
@@ -11,28 +11,28 @@ import (
func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string {
if snap == nil {
- return tui.PanelStyle.Render("Latency: waiting for stats...")
+ return common.PanelStyle.Render("Latency: waiting for stats...")
}
hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height)
- spark := tui.PanelStyle.Render("Latency sparkline: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width)))
+ spark := common.PanelStyle.Render("Latency sparkline: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width)))
return strings.Join([]string{hist, spark}, "\n")
}
func renderGapsTab(snap *statsengine.Snapshot, width, height int) string {
if snap == nil {
- return tui.PanelStyle.Render("Gaps: waiting for stats...")
+ return common.PanelStyle.Render("Gaps: waiting for stats...")
}
hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height)
- spark := tui.PanelStyle.Render("Gap sparkline: " + renderSparkline(snap.GapSeriesNs(), sparklineWidth(width)))
+ spark := common.PanelStyle.Render("Gap sparkline: " + renderSparkline(snap.GapSeriesNs(), sparklineWidth(width)))
return strings.Join([]string{hist, spark}, "\n")
}
func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, height int) string {
buckets := hist.Buckets()
if len(buckets) == 0 {
- return tui.PanelStyle.Render(title + ": no data")
+ return common.PanelStyle.Render(title + ": no data")
}
if width <= 0 {
@@ -77,7 +77,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he
}
lines = append(lines, "Scale: █▓▒░")
- return tui.PanelStyle.Render(strings.Join(lines, "\n"))
+ return common.PanelStyle.Render(strings.Join(lines, "\n"))
}
func renderHistogramBar(count, maxCount uint64, width int) string {
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index ae5c60f..9c47f4b 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -2,7 +2,7 @@ package dashboard
import (
"ior/internal/statsengine"
- "ior/internal/tui"
+ common "ior/internal/tui/common"
"ior/internal/tui/messages"
"strings"
"time"
@@ -31,7 +31,7 @@ type Model struct {
height int
refreshEvery time.Duration
- keys tui.KeyMap
+ keys common.KeyMap
syscallsOffset int
filesOffset int
processesOffset int
@@ -39,11 +39,11 @@ type Model struct {
// NewModel creates a dashboard model with default refresh cadence.
func NewModel(engine SnapshotSource) Model {
- return NewModelWithConfig(engine, defaultRefreshMs, tui.Keys)
+ return NewModelWithConfig(engine, defaultRefreshMs, common.Keys)
}
// NewModelWithConfig creates a dashboard model with explicit refresh and keys.
-func NewModelWithConfig(engine SnapshotSource, refreshMs int, keys tui.KeyMap) Model {
+func NewModelWithConfig(engine SnapshotSource, refreshMs int, keys common.KeyMap) Model {
if refreshMs <= 0 {
refreshMs = defaultRefreshMs
}
@@ -148,6 +148,11 @@ func (m Model) snapshot() *statsengine.Snapshot {
return m.engine.Snapshot()
}
+// LatestSnapshot returns the most recently received snapshot.
+func (m Model) LatestSnapshot() *statsengine.Snapshot {
+ return m.latest
+}
+
// View renders the tab bar, active tab scaffold, and help bar.
func (m Model) View() string {
var b strings.Builder
@@ -155,8 +160,10 @@ func (m Model) View() string {
b.WriteString("\n")
b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset, m.filesOffset, m.processesOffset))
b.WriteString("\n")
+ b.WriteString(common.HighlightStyle.Render("Press ? for help"))
+ b.WriteString("\n")
b.WriteString(renderHelpBar(m.keys))
- return tui.ScreenStyle.Render(b.String())
+ return common.ScreenStyle.Render(b.String())
}
func tickCmd(d time.Duration) tea.Cmd {
@@ -168,7 +175,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall
_ = height
if snap == nil {
- return tui.PanelStyle.Render(tab.String() + ": waiting for stats...")
+ return common.PanelStyle.Render(tab.String() + ": waiting for stats...")
}
switch tab {
@@ -185,6 +192,6 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall
case TabGaps:
return renderGapsTab(snap, width, height)
default:
- return tui.PanelStyle.Render("Unknown tab")
+ return common.PanelStyle.Render("Unknown tab")
}
}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 1d2329d..11cfc2b 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"ior/internal/statsengine"
- "ior/internal/tui"
+ common "ior/internal/tui/common"
"ior/internal/tui/messages"
tea "github.com/charmbracelet/bubbletea"
@@ -22,7 +22,7 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot {
}
func TestKeySwitchingChangesActiveTab(t *testing.T) {
- m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m := NewModelWithConfig(nil, 250, common.DefaultKeyMap())
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}})
model := next.(Model)
@@ -44,7 +44,7 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) {
}
func TestSyscallsTabScrollsWithJK(t *testing.T) {
- m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m := NewModelWithConfig(nil, 250, common.DefaultKeyMap())
m.activeTab = TabSyscalls
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
@@ -61,7 +61,7 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) {
}
func TestProcessesTabScrollsWithJK(t *testing.T) {
- m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m := NewModelWithConfig(nil, 250, common.DefaultKeyMap())
m.activeTab = TabProcesses
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
@@ -78,7 +78,7 @@ func TestProcessesTabScrollsWithJK(t *testing.T) {
}
func TestFilesTabScrollsWithJK(t *testing.T) {
- m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m := NewModelWithConfig(nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
@@ -97,7 +97,7 @@ func TestFilesTabScrollsWithJK(t *testing.T) {
func TestRefreshTickEmitsStatsTickMsg(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 9}
engine := &fakeSnapshotSource{snap: snap}
- m := NewModelWithConfig(engine, 100, tui.DefaultKeyMap())
+ m := NewModelWithConfig(engine, 100, common.DefaultKeyMap())
next, cmd := m.Update(refreshTickMsg{})
if cmd == nil {
@@ -139,11 +139,14 @@ func TestStatsTickMsgUpdatesLatestSnapshot(t *testing.T) {
}
func TestViewRendersTabBarAndHelp(t *testing.T) {
- m := NewModelWithConfig(nil, 1000, tui.DefaultKeyMap())
+ m := NewModelWithConfig(nil, 1000, common.DefaultKeyMap())
out := m.View()
if !strings.Contains(out, "Overview") {
t.Fatalf("expected overview label in view")
}
+ if !strings.Contains(out, "Press ? for help") {
+ t.Fatalf("expected inline help hint in view")
+ }
if !strings.Contains(out, "tab next tab") {
t.Fatalf("expected help bar text in view")
}
diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go
index 3f563d4..8b6b13c 100644
--- a/internal/tui/dashboard/overview.go
+++ b/internal/tui/dashboard/overview.go
@@ -3,7 +3,7 @@ package dashboard
import (
"fmt"
"ior/internal/statsengine"
- "ior/internal/tui"
+ common "ior/internal/tui/common"
"strings"
"time"
)
@@ -11,7 +11,7 @@ import (
func renderOverview(snap *statsengine.Snapshot, width, height int) string {
_ = height
if snap == nil {
- return tui.PanelStyle.Render("Overview: waiting for stats...")
+ return common.PanelStyle.Render("Overview: waiting for stats...")
}
boxWidth := summaryBoxWidth(width)
@@ -30,14 +30,22 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string {
latencySpark := "Latency: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width))
throughputSpark := "Throughput: " + renderSparkline(snap.ThroughputSeriesB(), sparklineWidth(width))
topSyscalls := "Top syscalls: " + summarizeTopSyscalls(snap)
+ topFiles := "Top files: " + summarizeTopFiles(snap)
+ topProcesses := "Top processes: " + summarizeTopProcesses(snap)
+ latencyHist := "Latency buckets: " + summarizeHistogramBrief(snap.LatencyHistogram)
+ gapHist := "Gap buckets: " + summarizeHistogramBrief(snap.GapHistogram)
return strings.Join(
[]string{
row,
- tui.HighlightStyle.Render(trends),
- tui.PanelStyle.Render(latencySpark),
- tui.PanelStyle.Render(throughputSpark),
- tui.PanelStyle.Render(topSyscalls),
+ common.HighlightStyle.Render(trends),
+ common.PanelStyle.Render(latencySpark),
+ common.PanelStyle.Render(throughputSpark),
+ common.PanelStyle.Render(topSyscalls),
+ common.PanelStyle.Render(topFiles),
+ common.PanelStyle.Render(topProcesses),
+ common.PanelStyle.Render(latencyHist),
+ common.PanelStyle.Render(gapHist),
},
"\n",
)
@@ -50,7 +58,7 @@ func renderSyscallBox(snap *statsengine.Snapshot, width int) string {
snap.TotalSyscalls,
snap.SyscallRatePerSec,
)
- return tui.PanelStyle.Width(width).Render(content)
+ return common.PanelStyle.Width(width).Render(content)
}
func renderBytesBox(snap *statsengine.Snapshot, width int) string {
@@ -60,7 +68,7 @@ func renderBytesBox(snap *statsengine.Snapshot, width int) string {
formatBytes(snap.WriteBytesPerSec),
formatBytes(float64(snap.TotalBytes)),
)
- return tui.PanelStyle.Width(width).Render(content)
+ return common.PanelStyle.Width(width).Render(content)
}
func renderErrorBox(snap *statsengine.Snapshot, width int) string {
@@ -74,7 +82,7 @@ func renderErrorBox(snap *statsengine.Snapshot, width int) string {
errPercent,
snap.LatencyMeanNs,
)
- return tui.PanelStyle.Width(width).Render(content)
+ return common.PanelStyle.Width(width).Render(content)
}
func trendWithArrow(trend statsengine.Trend) string {
@@ -106,6 +114,74 @@ func summarizeTopSyscalls(snap *statsengine.Snapshot) string {
return strings.Join(parts, ", ")
}
+func summarizeTopFiles(snap *statsengine.Snapshot) string {
+ files := snap.Files()
+ if len(files) == 0 {
+ return "none"
+ }
+
+ limit := 3
+ if len(files) < limit {
+ limit = len(files)
+ }
+
+ parts := make([]string, 0, limit)
+ for _, f := range files[:limit] {
+ parts = append(parts, fmt.Sprintf("%s(%d)", trimPathTail(f.Path, 24), f.Accesses))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func summarizeTopProcesses(snap *statsengine.Snapshot) string {
+ processes := snap.Processes()
+ if len(processes) == 0 {
+ return "none"
+ }
+
+ limit := 3
+ if len(processes) < limit {
+ limit = len(processes)
+ }
+
+ parts := make([]string, 0, limit)
+ for _, p := range processes[:limit] {
+ parts = append(parts, fmt.Sprintf("%s/%d(%d)", p.Comm, p.PID, p.Syscalls))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func summarizeHistogramBrief(hist statsengine.HistogramSnapshot) string {
+ buckets := hist.Buckets()
+ if len(buckets) == 0 || hist.Total == 0 {
+ return "none"
+ }
+
+ parts := make([]string, 0, 3)
+ for _, b := range buckets {
+ if b.Count == 0 {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s:%d", b.Label, b.Count))
+ if len(parts) == 3 {
+ break
+ }
+ }
+ if len(parts) == 0 {
+ return "none"
+ }
+ return strings.Join(parts, ", ")
+}
+
+func trimPathTail(path string, max int) string {
+ if len(path) <= max {
+ return path
+ }
+ if max <= 3 {
+ return path[len(path)-max:]
+ }
+ return "..." + path[len(path)-max+3:]
+}
+
func formatElapsed(elapsed time.Duration) string {
if elapsed <= 0 {
return "0s"
diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go
index ca1544c..e44b015 100644
--- a/internal/tui/dashboard/overview_test.go
+++ b/internal/tui/dashboard/overview_test.go
@@ -33,6 +33,10 @@ func TestRenderOverviewIncludesCoreMetrics(t *testing.T) {
"Latency:",
"Throughput:",
"Top syscalls:",
+ "Top files:",
+ "Top processes:",
+ "Latency buckets:",
+ "Gap buckets:",
} {
if !strings.Contains(out, token) {
t.Fatalf("expected token %q in overview output", token)
@@ -66,3 +70,26 @@ func TestRenderOverviewWithoutSnapshot(t *testing.T) {
t.Fatalf("expected waiting placeholder, got %q", out)
}
}
+
+func TestOverviewSummariesIncludeFilesProcessesAndHistograms(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ nil, nil, nil,
+ []statsengine.SyscallSnapshot{{Name: "read", Count: 2}},
+ []statsengine.FileSnapshot{{Path: "/tmp/very/long/path/file.log", Accesses: 4}},
+ []statsengine.ProcessSnapshot{{PID: 12, Comm: "proc", Syscalls: 7}},
+ statsengine.NewHistogramSnapshot(3, []statsengine.HistogramBucketSnapshot{
+ {Label: "[0,1us)", Count: 2},
+ {Label: "[1us,10us)", Count: 1},
+ }),
+ statsengine.NewHistogramSnapshot(1, []statsengine.HistogramBucketSnapshot{
+ {Label: "[10us,100us)", Count: 1},
+ }),
+ )
+
+ out := renderOverview(&snap, 120, 40)
+ for _, token := range []string{"Top files:", "Top processes:", "Latency buckets:", "Gap buckets:"} {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected %q in overview output", token)
+ }
+ }
+}
diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go
index d456b44..9965d1f 100644
--- a/internal/tui/dashboard/tabs.go
+++ b/internal/tui/dashboard/tabs.go
@@ -1,7 +1,7 @@
package dashboard
import (
- "ior/internal/tui"
+ common "ior/internal/tui/common"
"strings"
"github.com/charmbracelet/lipgloss"
@@ -80,9 +80,9 @@ func renderTabBar(active Tab, width int) string {
for _, tab := range allTabs {
label := tab.String()
if tab == active {
- parts = append(parts, tui.TabActiveStyle.Render(label))
+ parts = append(parts, common.TabActiveStyle.Render(label))
} else {
- parts = append(parts, tui.TabInactiveStyle.Render(label))
+ parts = append(parts, common.TabInactiveStyle.Render(label))
}
}
@@ -93,11 +93,11 @@ func renderTabBar(active Tab, width int) string {
return lipgloss.NewStyle().Width(width).Render(bar)
}
-func renderHelpBar(keys tui.KeyMap) string {
+func renderHelpBar(keys common.KeyMap) string {
parts := make([]string, 0, len(keys.DashboardShortHelp()))
for _, binding := range keys.DashboardShortHelp() {
help := binding.Help()
parts = append(parts, help.Key+" "+help.Desc)
}
- return tui.HelpBarStyle.Render(strings.Join(parts, " • "))
+ return common.HelpBarStyle.Render(strings.Join(parts, " • "))
}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 3173fff..38383dd 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -1,62 +1,14 @@
package tui
-import "github.com/charmbracelet/bubbles/key"
+import common "ior/internal/tui/common"
// KeyMap groups all key bindings shared by TUI screens.
-type KeyMap struct {
- Tab key.Binding
- ShiftTab key.Binding
- One key.Binding
- Two key.Binding
- Three key.Binding
- Four key.Binding
- Five key.Binding
- Six key.Binding
- Export key.Binding
- Quit key.Binding
- Help key.Binding
- Enter key.Binding
- Esc key.Binding
- Refresh key.Binding
-}
+type KeyMap = common.KeyMap
// Keys contains the default shared key map.
-var Keys = DefaultKeyMap()
+var Keys = common.Keys
// DefaultKeyMap builds the default key bindings used by models.
func DefaultKeyMap() KeyMap {
- return KeyMap{
- Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")),
- ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")),
- One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "overview")),
- Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "syscalls")),
- Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "files")),
- Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "processes")),
- Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "latency")),
- Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "gaps")),
- Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "export")),
- Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
- Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
- Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
- Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
- Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
- }
-}
-
-// DashboardShortHelp returns compact bindings for dashboard help bars.
-func (k KeyMap) DashboardShortHelp() []key.Binding {
- return []key.Binding{k.Tab, k.ShiftTab, k.Export, k.Help, k.Quit}
-}
-
-// DashboardFullHelp returns grouped bindings for dashboard overlays.
-func (k KeyMap) DashboardFullHelp() [][]key.Binding {
- return [][]key.Binding{
- {k.One, k.Two, k.Three, k.Four, k.Five, k.Six},
- {k.Tab, k.ShiftTab, k.Export, k.Help, k.Quit},
- }
-}
-
-// PickerShortHelp returns compact bindings for the PID picker.
-func (k KeyMap) PickerShortHelp() []key.Binding {
- return []key.Binding{k.Enter, k.Refresh, k.Esc}
+ return common.DefaultKeyMap()
}
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
index 7bbe836..3bf69f7 100644
--- a/internal/tui/styles.go
+++ b/internal/tui/styles.go
@@ -1,61 +1,40 @@
package tui
-import "github.com/charmbracelet/lipgloss"
+import common "ior/internal/tui/common"
var (
// Palette colors shared across the TUI package.
- ColorBackground = lipgloss.Color("235")
- ColorPanel = lipgloss.Color("238")
- ColorPrimary = lipgloss.Color("75")
- ColorAccent = lipgloss.Color("222")
- ColorMuted = lipgloss.Color("246")
- ColorText = lipgloss.Color("255")
- ColorDanger = lipgloss.Color("203")
+ ColorBackground = common.ColorBackground
+ ColorPanel = common.ColorPanel
+ ColorPrimary = common.ColorPrimary
+ ColorAccent = common.ColorAccent
+ ColorMuted = common.ColorMuted
+ ColorText = common.ColorText
+ ColorDanger = common.ColorDanger
)
var (
// ScreenStyle is the base style for full-screen models.
- ScreenStyle = lipgloss.NewStyle().
- Foreground(ColorText).
- Background(ColorBackground)
+ ScreenStyle = common.ScreenStyle
// HeaderStyle is used by top-level titles and screen headers.
- HeaderStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(ColorPrimary)
+ HeaderStyle = common.HeaderStyle
// TabActiveStyle is applied to the currently-selected tab.
- TabActiveStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(ColorBackground).
- Background(ColorPrimary).
- Padding(0, 1)
+ TabActiveStyle = common.TabActiveStyle
// TabInactiveStyle is applied to non-selected tabs.
- TabInactiveStyle = lipgloss.NewStyle().
- Foreground(ColorMuted).
- Background(ColorPanel).
- Padding(0, 1)
+ TabInactiveStyle = common.TabInactiveStyle
// PanelStyle is used for boxed sections.
- PanelStyle = lipgloss.NewStyle().
- Border(lipgloss.NormalBorder()).
- BorderForeground(ColorPanel).
- Padding(0, 1)
+ PanelStyle = common.PanelStyle
// HelpBarStyle is used for keybinding hints at the bottom.
- HelpBarStyle = lipgloss.NewStyle().
- Foreground(ColorMuted).
- BorderTop(true).
- BorderForeground(ColorPanel)
+ HelpBarStyle = common.HelpBarStyle
// HighlightStyle emphasizes inline values.
- HighlightStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(ColorAccent)
+ HighlightStyle = common.HighlightStyle
// ErrorStyle is used for fatal or warning messages.
- ErrorStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(ColorDanger)
+ ErrorStyle = common.ErrorStyle
)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 4d7a7dc..5bc7bf9 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -7,6 +7,7 @@ import (
"fmt"
"ior/internal/flags"
"ior/internal/statsengine"
+ dashboardui "ior/internal/tui/dashboard"
tuiexport "ior/internal/tui/export"
"ior/internal/tui/pidpicker"
"os"
@@ -38,8 +39,6 @@ type snapshotSource interface {
Snapshot() *statsengine.Snapshot
}
-type dashboardTickMsg struct{}
-
var dashboardSourceState struct {
mu sync.RWMutex
source snapshotSource
@@ -75,7 +74,7 @@ func RunWithTraceStarter(starter TraceStarter) error {
type Model struct {
screen Screen
pidPicker pidpicker.Model
- dashboard dashboardModel
+ dashboard dashboardui.Model
exporter tuiexport.Model
keys KeyMap
@@ -104,7 +103,7 @@ func NewModel(initialPID int, startTrace TraceStarter) Model {
model := Model{
screen: ScreenPIDPicker,
pidPicker: pidpicker.New(),
- dashboard: newDashboardModel(getDashboardSnapshotSource()),
+ dashboard: dashboardui.NewModel(lateBoundDashboardSource{}),
exporter: tuiexport.NewModel(),
keys: Keys,
spin: spin,
@@ -113,7 +112,6 @@ func NewModel(initialPID int, startTrace TraceStarter) Model {
if initialPID > 0 {
flags.SetPidFilter(initialPID)
- model.dashboard.selectedPID = initialPID
model.screen = ScreenDashboard
model.attaching = true
}
@@ -158,7 +156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case tuiexport.RequestMsg:
- return m, runExportCmd(msg.Option, m.dashboard.latest)
+ return m, runExportCmd(msg.Option, m.dashboard.LatestSnapshot())
case tuiexport.CompletedMsg:
var cmd tea.Cmd
m.exporter, cmd = m.exporter.Update(msg)
@@ -171,14 +169,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handlePidSelected(msg)
case TracingStartedMsg:
m.attaching = false
- return m, dashboardTickCmd()
+ return m, m.dashboard.Init()
case TracingErrorMsg:
m.attaching = false
m.lastErr = msg.Err
return m, nil
- case dashboardTickMsg:
- m.dashboard.refresh()
- return m, dashboardTickCmd()
}
if m.attaching {
@@ -203,7 +198,7 @@ func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case ScreenDashboard:
next, cmd := m.dashboard.Update(msg)
- m.dashboard = next.(dashboardModel)
+ m.dashboard = next.(dashboardui.Model)
return m, cmd
default:
return m, nil
@@ -214,7 +209,6 @@ func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
pid := selectedPIDFilter(msg.Pid)
m.stopTrace()
flags.SetPidFilter(pid)
- m.dashboard.selectedPID = pid
m.screen = ScreenDashboard
m.attaching = true
m.lastErr = nil
@@ -296,53 +290,6 @@ func (m Model) View() string {
}
}
-type dashboardModel struct {
- selectedPID int
- source snapshotSource
- latest *statsengine.Snapshot
-}
-
-func newDashboardModel(source snapshotSource) dashboardModel {
- return dashboardModel{
- selectedPID: -1,
- source: source,
- }
-}
-
-func (d dashboardModel) Init() tea.Cmd {
- return nil
-}
-
-func (d dashboardModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
- return d, nil
-}
-
-func (d dashboardModel) View() string {
- if d.latest != nil {
- return PanelStyle.Render(
- fmt.Sprintf("Dashboard (%d syscalls, %.1f/s)", d.latest.TotalSyscalls, d.latest.SyscallRatePerSec),
- )
- }
- if d.selectedPID > 0 {
- return PanelStyle.Render(fmt.Sprintf("Dashboard (PID %d)", d.selectedPID))
- }
- return PanelStyle.Render("Dashboard (All PIDs)")
-}
-
-func (d *dashboardModel) refresh() {
- if source := getDashboardSnapshotSource(); source != nil {
- d.source = source
- }
- if d.source == nil {
- return
- }
- d.latest = d.source.Snapshot()
-}
-
-func dashboardTickCmd() tea.Cmd {
- return tea.Tick(time.Second, func(time.Time) tea.Msg { return dashboardTickMsg{} })
-}
-
func runExportCmd(option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd {
return func() tea.Msg {
switch option {
@@ -364,6 +311,16 @@ func runExportCmd(option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd {
}
}
+type lateBoundDashboardSource struct{}
+
+func (lateBoundDashboardSource) Snapshot() *statsengine.Snapshot {
+ source := getDashboardSnapshotSource()
+ if source == nil {
+ return nil
+ }
+ return source.Snapshot()
+}
+
func exportSnapshotCSV(snap *statsengine.Snapshot) (string, error) {
filename := fmt.Sprintf("ior-snapshot-%s.csv", time.Now().Format("20060102-150405"))
f, err := os.Create(filename)
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 98b249f..d62d283 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -62,9 +62,7 @@ func TestPidSelectedAllSetsNoFilter(t *testing.T) {
if got := flags.Get().PidFilter; got != -1 {
t.Fatalf("expected pid filter -1 for all pids, got %d", got)
}
- if updated.dashboard.selectedPID != -1 {
- t.Fatalf("expected dashboard selected pid -1, got %d", updated.dashboard.selectedPID)
- }
+ _ = updated
}
func TestTracingErrorMessageClearsAttachingState(t *testing.T) {
@@ -182,14 +180,14 @@ func TestDashboardRefreshPicksLateBoundSource(t *testing.T) {
defer SetDashboardSnapshotSource(orig)
SetDashboardSnapshotSource(nil)
- d := newDashboardModel(nil)
+ source := lateBoundDashboardSource{}
want := &statsengine.Snapshot{TotalSyscalls: 77}
SetDashboardSnapshotSource(fakeDashboardSource{snap: want})
- d.refresh()
- if d.latest != want {
- t.Fatalf("expected dashboard refresh to bind and use latest global source")
+ got := source.Snapshot()
+ if got != want {
+ t.Fatalf("expected late-bound source to use latest global source")
}
}
@@ -315,3 +313,30 @@ func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) {
t.Fatalf("expected esc to close export modal")
}
}
+
+func TestDashboardTabKeysChangeActiveView(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ out := m.View()
+ if !strings.Contains(out, "Overview: waiting for stats") {
+ t.Fatalf("expected overview waiting view by default")
+ }
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}})
+ updated := next.(Model)
+ out = updated.View()
+ if !strings.Contains(out, "Syscalls: waiting for stats") {
+ t.Fatalf("expected syscalls waiting view after pressing 2")
+ }
+
+ next, _ = updated.Update(tea.KeyMsg{Type: tea.KeyTab})
+ updated = next.(Model)
+ out = updated.View()
+ if !strings.Contains(out, "Files: waiting for stats") {
+ t.Fatalf("expected files waiting view after tab")
+ }
+}