summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard
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 /internal/tui/dashboard
parent7fc16d6c98feae7aaee58666dc552384ceb4895e (diff)
tui: wire full dashboard tabs and improve overview summaries
Diffstat (limited to 'internal/tui/dashboard')
-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
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, " • "))
}