diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 09:45:02 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 09:45:02 +0200 |
| commit | f2d79f6459bbe1aa9bae2946e9773141cb184463 (patch) | |
| tree | e683b901d2432ac7e28cd6e80f468da38edc280b /internal/tui/dashboard | |
| parent | 7fc16d6c98feae7aaee58666dc552384ceb4895e (diff) | |
tui: wire full dashboard tabs and improve overview summaries
Diffstat (limited to 'internal/tui/dashboard')
| -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 |
6 files changed, 148 insertions, 35 deletions
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, " • ")) } |
