diff options
| -rw-r--r-- | Magefile.go | 4 | ||||
| -rw-r--r-- | internal/flags/flags.go | 7 | ||||
| -rw-r--r-- | internal/ior.go | 43 | ||||
| -rw-r--r-- | internal/ior_test.go | 6 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 62 | ||||
| -rw-r--r-- | internal/tui/common/styles.go | 61 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 14 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 21 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 94 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 27 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 10 | ||||
| -rw-r--r-- | internal/tui/keys.go | 56 | ||||
| -rw-r--r-- | internal/tui/styles.go | 53 | ||||
| -rw-r--r-- | internal/tui/tui.go | 75 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 39 |
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") + } +} |
