summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 08:36:04 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 08:36:04 +0200
commit24b401ac9c6a1f80b5ba7f446f1fd3e3ddf02b5c (patch)
tree0bcadba2fd4b4e17cec2f6407b6d55920e82b5e9
parent89ba8287d490a0fef15a80a34ea4efe0c5ecf2e8 (diff)
tui: add dashboard overview tab renderer
-rw-r--r--internal/tui/dashboard/model.go2
-rw-r--r--internal/tui/dashboard/overview.go152
-rw-r--r--internal/tui/dashboard/overview_test.go68
3 files changed, 221 insertions, 1 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 3c4d9b8..1178dc9 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -134,7 +134,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height int) str
switch tab {
case TabOverview:
- return tui.PanelStyle.Render(fmt.Sprintf("Overview: %d syscalls", snap.TotalSyscalls))
+ return renderOverview(snap, width, height)
case TabSyscalls:
return tui.PanelStyle.Render(fmt.Sprintf("Syscalls: %d rows", len(snap.Syscalls())))
case TabFiles:
diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go
new file mode 100644
index 0000000..3f563d4
--- /dev/null
+++ b/internal/tui/dashboard/overview.go
@@ -0,0 +1,152 @@
+package dashboard
+
+import (
+ "fmt"
+ "ior/internal/statsengine"
+ "ior/internal/tui"
+ "strings"
+ "time"
+)
+
+func renderOverview(snap *statsengine.Snapshot, width, height int) string {
+ _ = height
+ if snap == nil {
+ return tui.PanelStyle.Render("Overview: waiting for stats...")
+ }
+
+ boxWidth := summaryBoxWidth(width)
+ box1 := renderSyscallBox(snap, boxWidth)
+ box2 := renderBytesBox(snap, boxWidth)
+ box3 := renderErrorBox(snap, boxWidth)
+
+ row := strings.Join([]string{box1, box2, box3}, "\n")
+ trends := fmt.Sprintf(
+ "Trends: latency %s gap %s throughput %s",
+ trendWithArrow(snap.LatencyTrend),
+ trendWithArrow(snap.GapTrend),
+ trendWithArrow(snap.ThroughputTrend),
+ )
+
+ latencySpark := "Latency: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width))
+ throughputSpark := "Throughput: " + renderSparkline(snap.ThroughputSeriesB(), sparklineWidth(width))
+ topSyscalls := "Top syscalls: " + summarizeTopSyscalls(snap)
+
+ return strings.Join(
+ []string{
+ row,
+ tui.HighlightStyle.Render(trends),
+ tui.PanelStyle.Render(latencySpark),
+ tui.PanelStyle.Render(throughputSpark),
+ tui.PanelStyle.Render(topSyscalls),
+ },
+ "\n",
+ )
+}
+
+func renderSyscallBox(snap *statsengine.Snapshot, width int) string {
+ content := fmt.Sprintf(
+ "Elapsed: %s\nSyscalls: %d\nRate: %.1f/s",
+ formatElapsed(snap.Elapsed),
+ snap.TotalSyscalls,
+ snap.SyscallRatePerSec,
+ )
+ return tui.PanelStyle.Width(width).Render(content)
+}
+
+func renderBytesBox(snap *statsengine.Snapshot, width int) string {
+ content := fmt.Sprintf(
+ "Read/s: %s\nWrite/s: %s\nTotal: %s",
+ formatBytes(snap.ReadBytesPerSec),
+ formatBytes(snap.WriteBytesPerSec),
+ formatBytes(float64(snap.TotalBytes)),
+ )
+ return tui.PanelStyle.Width(width).Render(content)
+}
+
+func renderErrorBox(snap *statsengine.Snapshot, width int) string {
+ errPercent := 0.0
+ if snap.TotalSyscalls > 0 {
+ errPercent = float64(snap.TotalErrors) / float64(snap.TotalSyscalls) * 100
+ }
+ content := fmt.Sprintf(
+ "Errors: %d\nError rate: %.2f%%\nLatency mean: %.0fns",
+ snap.TotalErrors,
+ errPercent,
+ snap.LatencyMeanNs,
+ )
+ return tui.PanelStyle.Width(width).Render(content)
+}
+
+func trendWithArrow(trend statsengine.Trend) string {
+ switch trend.Direction {
+ case statsengine.TrendRising:
+ return fmt.Sprintf("↑ %.1f%%", trend.DeltaPercent)
+ case statsengine.TrendFalling:
+ return fmt.Sprintf("↓ %.1f%%", trend.DeltaPercent)
+ default:
+ return fmt.Sprintf("→ %.1f%%", trend.DeltaPercent)
+ }
+}
+
+func summarizeTopSyscalls(snap *statsengine.Snapshot) string {
+ syscalls := snap.Syscalls()
+ if len(syscalls) == 0 {
+ return "none"
+ }
+
+ limit := 3
+ if len(syscalls) < limit {
+ limit = len(syscalls)
+ }
+
+ parts := make([]string, 0, limit)
+ for _, syscall := range syscalls[:limit] {
+ parts = append(parts, fmt.Sprintf("%s(%d)", syscall.Name, syscall.Count))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func formatElapsed(elapsed time.Duration) string {
+ if elapsed <= 0 {
+ return "0s"
+ }
+ return elapsed.Round(time.Second).String()
+}
+
+func formatBytes(value float64) string {
+ units := []string{"B", "KB", "MB", "GB", "TB"}
+ unit := 0
+ for value >= 1024 && unit < len(units)-1 {
+ value /= 1024
+ unit++
+ }
+ if unit == 0 {
+ return fmt.Sprintf("%.0f%s", value, units[unit])
+ }
+ return fmt.Sprintf("%.1f%s", value, units[unit])
+}
+
+func summaryBoxWidth(width int) int {
+ if width <= 0 {
+ return 24
+ }
+ w := (width - 4) / 3
+ if w < 18 {
+ return 18
+ }
+ return w
+}
+
+func sparklineWidth(width int) int {
+ if width <= 0 {
+ return 20
+ }
+ w := width - 14
+ if w < 8 {
+ return 8
+ }
+ if w > 80 {
+ return 80
+ }
+ return w
+}
diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go
new file mode 100644
index 0000000..ca1544c
--- /dev/null
+++ b/internal/tui/dashboard/overview_test.go
@@ -0,0 +1,68 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "ior/internal/statsengine"
+)
+
+func TestRenderOverviewIncludesCoreMetrics(t *testing.T) {
+ snap := &statsengine.Snapshot{
+ Elapsed: 95 * time.Second,
+ TotalSyscalls: 1200,
+ SyscallRatePerSec: 12.5,
+ TotalBytes: 10 * 1024 * 1024,
+ ReadBytesPerSec: 4096,
+ WriteBytesPerSec: 8192,
+ TotalErrors: 12,
+ LatencyMeanNs: 5000,
+ LatencyTrend: statsengine.Trend{Direction: statsengine.TrendRising, DeltaPercent: 12.5},
+ GapTrend: statsengine.Trend{Direction: statsengine.TrendFalling, DeltaPercent: -7.4},
+ ThroughputTrend: statsengine.Trend{Direction: statsengine.TrendStable, DeltaPercent: 0},
+ }
+
+ out := renderOverview(snap, 120, 40)
+ for _, token := range []string{
+ "Elapsed:",
+ "Syscalls:",
+ "Read/s:",
+ "Errors:",
+ "Trends:",
+ "Latency:",
+ "Throughput:",
+ "Top syscalls:",
+ } {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected token %q in overview output", token)
+ }
+ }
+}
+
+func TestSummarizeTopSyscalls(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ nil, nil, nil,
+ []statsengine.SyscallSnapshot{
+ {Name: "read", Count: 50},
+ {Name: "write", Count: 20},
+ {Name: "openat", Count: 10},
+ {Name: "close", Count: 5},
+ },
+ nil, nil,
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ got := summarizeTopSyscalls(&snap)
+ if got != "read(50), write(20), openat(10)" {
+ t.Fatalf("unexpected top syscall summary: %q", got)
+ }
+}
+
+func TestRenderOverviewWithoutSnapshot(t *testing.T) {
+ out := renderOverview(nil, 80, 24)
+ if !strings.Contains(out, "waiting for stats") {
+ t.Fatalf("expected waiting placeholder, got %q", out)
+ }
+}