summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 08:30:09 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 08:30:09 +0200
commitfb705185b5add84eb1f4b17c01a5e249215d0859 (patch)
treef54610a7ce33d898747958d876f4bb1f3613804d
parent19e15dae40cdbc9402e081760c9c40d29174221e (diff)
Add stream event table renderer
-rw-r--r--internal/tui/eventstream/render.go157
-rw-r--r--internal/tui/eventstream/render_test.go73
2 files changed, 230 insertions, 0 deletions
diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go
new file mode 100644
index 0000000..3fd2d26
--- /dev/null
+++ b/internal/tui/eventstream/render.go
@@ -0,0 +1,157 @@
+package eventstream
+
+import (
+ "fmt"
+ "ior/internal/tui/common"
+ "strconv"
+ "strings"
+)
+
+type columnLayout struct {
+ gap int
+ latency int
+ comm int
+ pidTid int
+ syscall int
+ ret int
+ bytes int
+ file int
+}
+
+func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent) string {
+ if width <= 0 {
+ width = 100
+ }
+
+ lines := make([]string, 0, len(events)+3)
+ lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap))
+ lines = append(lines, renderFilterLine(filter))
+ lines = append(lines, renderColumnHeader(width))
+ for _, ev := range events {
+ lines = append(lines, renderEventRow(ev, width))
+ }
+
+ return common.PanelStyle.Width(width).Render(strings.Join(lines, "\n"))
+}
+
+func renderStatusLine(paused bool, totalCount, filteredCount, bufferLen, bufferCap int) string {
+ state := common.HighlightStyle.Render("LIVE")
+ if paused {
+ state = common.ErrorStyle.Render("PAUSED")
+ }
+ buffer := strconv.Itoa(bufferLen)
+ if bufferCap > 0 {
+ buffer = fmt.Sprintf("%d/%d", bufferLen, bufferCap)
+ }
+ return fmt.Sprintf("%s | total:%d filtered:%d buffer:%s", state, totalCount, filteredCount, buffer)
+}
+
+func renderFilterLine(filter Filter) string {
+ summary := filter.Summary()
+ if summary == "all" {
+ summary = common.HighlightStyle.Render(summary)
+ }
+ return common.HeaderStyle.Render("Filter:") + " " + summary
+}
+
+func renderColumnHeader(width int) string {
+ cols := computeColumnLayout(width)
+ header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %s",
+ cols.gap, "Gap",
+ cols.latency, "Latency",
+ cols.comm, "Comm",
+ cols.pidTid, "PID.TID",
+ cols.syscall, "Syscall",
+ cols.ret, "Ret",
+ cols.bytes, "Bytes",
+ "File",
+ )
+ return common.HelpBarStyle.Render(header)
+}
+
+func renderEventRow(ev StreamEvent, width int) string {
+ cols := computeColumnLayout(width)
+ pidTid := fmt.Sprintf("%d.%d", ev.PID, ev.TID)
+ row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*d %-*d %s",
+ cols.gap, formatDurationNs(ev.GapNs),
+ cols.latency, formatDurationNs(ev.DurationNs),
+ cols.comm, truncateMiddle(ev.Comm, cols.comm),
+ cols.pidTid, truncateMiddle(pidTid, cols.pidTid),
+ cols.syscall, truncateMiddle(ev.Syscall, cols.syscall),
+ cols.ret, ev.RetVal,
+ cols.bytes, ev.Bytes,
+ truncateMiddle(ev.FileName, cols.file),
+ )
+ if ev.IsError {
+ return common.ErrorStyle.Render(row)
+ }
+ return row
+}
+
+func computeColumnLayout(width int) columnLayout {
+ if width <= 0 {
+ width = 100
+ }
+
+ gap := 8
+ latency := 9
+ comm := 14
+ pidTid := 12
+ syscall := 11
+ ret := 6
+ bytes := 10
+ fixed := gap + latency + comm + pidTid + syscall + ret + bytes + 7
+ file := width - fixed
+ if file >= 18 {
+ return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, ret: ret, bytes: bytes, file: file}
+ }
+
+ if width < 90 {
+ comm = 10
+ syscall = 9
+ } else {
+ comm = 12
+ syscall = 10
+ }
+ fixed = gap + latency + comm + pidTid + syscall + ret + bytes + 7
+ file = width - fixed
+ if file < 8 {
+ file = 8
+ }
+ return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, ret: ret, bytes: bytes, file: file}
+}
+
+func formatDurationNs(v uint64) string {
+ if v < 1000 {
+ return fmt.Sprintf("%dns", v)
+ }
+ us := float64(v) / 1000
+ if us < 1000 {
+ return fmt.Sprintf("%.1fus", us)
+ }
+ ms := us / 1000
+ if ms < 1000 {
+ return fmt.Sprintf("%.1fms", ms)
+ }
+ s := ms / 1000
+ return fmt.Sprintf("%.2fs", s)
+}
+
+func truncateMiddle(path string, limit int) string {
+ if limit <= 0 {
+ return ""
+ }
+ if len(path) <= limit {
+ return path
+ }
+ if limit <= 3 {
+ return path[:limit]
+ }
+
+ head := (limit - 3) / 2
+ tail := limit - 3 - head
+ if tail <= 0 {
+ return path[:limit]
+ }
+ return path[:head] + "..." + path[len(path)-tail:]
+}
diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go
new file mode 100644
index 0000000..5f037ab
--- /dev/null
+++ b/internal/tui/eventstream/render_test.go
@@ -0,0 +1,73 @@
+package eventstream
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderStatusAndFilterLines(t *testing.T) {
+ events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}}
+ f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}}
+ out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events)
+
+ for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q\n%s", want, out)
+ }
+ }
+}
+
+func TestRenderPausedAndErrorRow(t *testing.T) {
+ events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}}
+ out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events)
+
+ if !strings.Contains(out, "PAUSED") {
+ t.Fatalf("expected PAUSED indicator\n%s", out)
+ }
+ if !strings.Contains(out, "-1") {
+ t.Fatalf("expected return value in row\n%s", out)
+ }
+ if !strings.Contains(out, "worker") || !strings.Contains(out, "write") {
+ t.Fatalf("expected event row in output\n%s", out)
+ }
+}
+
+func TestRenderHeaderAndTruncate(t *testing.T) {
+ events := []StreamEvent{{
+ Syscall: "very_long_syscall_name",
+ Comm: "very-long-command-name",
+ PID: 1,
+ TID: 2,
+ DurationNs: 2_000_000,
+ GapNs: 100,
+ Bytes: 4096,
+ FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log",
+ RetVal: 1,
+ }}
+ out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events)
+
+ for _, col := range []string{"Gap", "Latency", "Comm", "PID.TID", "Syscall", "Ret", "Bytes", "File"} {
+ if !strings.Contains(out, col) {
+ t.Fatalf("missing column %q\n%s", col, out)
+ }
+ }
+ if !strings.Contains(out, "...") {
+ t.Fatalf("expected truncated field with ellipsis\n%s", out)
+ }
+}
+
+func TestFormatDurationNs(t *testing.T) {
+ cases := []struct {
+ in uint64
+ want string
+ }{
+ {in: 999, want: "999ns"},
+ {in: 1500, want: "1.5us"},
+ {in: 2_000_000, want: "2.0ms"},
+ }
+ for _, tc := range cases {
+ if got := formatDurationNs(tc.in); got != tc.want {
+ t.Fatalf("formatDurationNs(%d) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}