diff options
| -rw-r--r-- | internal/tui/eventstream/render.go | 157 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 73 |
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) + } + } +} |
