diff options
| -rw-r--r-- | internal/tui/dashboard/model.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 152 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 68 |
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) + } +} |
