diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 10:37:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 10:37:40 +0200 |
| commit | 4302cbf28a9d9efd2416ab6ea95168f9e39c29ec (patch) | |
| tree | 90e4dfb2f9cc71e483396c3465859d1282282348 /internal/tui/eventstream | |
| parent | c661b23f2940e07a1e1cbe16334598d999096f27 (diff) | |
tui: add fd trace drilldown and fd column in stream
Diffstat (limited to 'internal/tui/eventstream')
| -rw-r--r-- | internal/tui/eventstream/model.go | 122 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 67 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 40 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/eventstream/streamevent.go | 7 | ||||
| -rw-r--r-- | internal/tui/eventstream/streamevent_test.go | 9 |
6 files changed, 258 insertions, 11 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index fc423cb..8b162e5 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -24,11 +24,20 @@ type Model struct { scrollOffset int autoScroll bool selectedIdx int + fdTraceView fdTraceViewState width int height int } +type fdTraceViewState struct { + visible bool + pid uint32 + fd int32 + events []StreamEvent + offset int +} + func NewModel(source *RingBuffer) Model { return Model{ source: source, @@ -79,8 +88,42 @@ func (m *Model) HandleKey(keyStr string) bool { } return true } + if m.fdTraceView.visible { + switch keyStr { + case "j", "down": + m.scrollFDTraceByLines(1) + return true + case "k", "up": + m.scrollFDTraceByLines(-1) + return true + case "pgdown", "pgdn", "pagedown": + m.scrollFDTraceByLines(m.pageStep()) + return true + case "pgup", "pageup": + m.scrollFDTraceByLines(-m.pageStep()) + return true + case "g": + m.fdTraceView.offset = 0 + return true + case "G": + m.fdTraceView.offset = m.maxFDTraceOffset() + return true + case "esc", "q": + m.fdTraceView.visible = false + m.fdTraceView.events = nil + m.fdTraceView.offset = 0 + return true + default: + return false + } + } switch keyStr { + case "enter": + if m.paused { + return m.openFDTraceView() + } + return false case " ", "space": m.paused = !m.paused if !m.paused { @@ -165,6 +208,10 @@ func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { return m.HandleKey("pgdown") case tea.KeySpace: return m.HandleKey("space") + case tea.KeyEsc: + return m.HandleKey("esc") + case tea.KeyEnter: + return m.HandleKey("enter") case tea.KeyRunes: if len(msg.Runes) == 1 { return m.HandleKey(string(msg.Runes[0])) @@ -183,6 +230,10 @@ func (m *Model) View(width, height int) string { m.width = width m.height = height + if m.fdTraceView.visible { + return m.viewFDTrace(width) + } + rows := m.visibleRows() start := clamp(m.scrollOffset, 0, m.maxScrollOffset()) end := start + rows @@ -203,7 +254,7 @@ func (m *Model) View(width, height int) string { base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx) status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d | space:pause f:filter G:tail g:top c:clear j/k:select", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered)) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d | enter:fd-trace space:pause f:filter G:tail g:top c:clear j/k:select", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered)) } out := base + "\n" + status @@ -307,6 +358,75 @@ func (m *Model) scrollByLines(delta int) { } } +func (m *Model) openFDTraceView() bool { + if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { + return false + } + selected := m.filtered[m.selectedIdx] + if selected.FD < 0 { + return false + } + + snapshot := m.allEvents + if m.source != nil { + snapshot = m.source.Snapshot() + } + + matches := make([]StreamEvent, 0, len(snapshot)) + for i := range snapshot { + ev := snapshot[i] + if ev.PID == selected.PID && ev.FD == selected.FD { + matches = append(matches, ev) + } + } + if len(matches) == 0 { + return false + } + + m.fdTraceView.visible = true + m.fdTraceView.pid = selected.PID + m.fdTraceView.fd = selected.FD + m.fdTraceView.events = matches + m.fdTraceView.offset = 0 + return true +} + +func (m *Model) viewFDTrace(width int) string { + rows := m.visibleRows() + start := clamp(m.fdTraceView.offset, 0, m.maxFDTraceOffset()) + end := start + rows + if end > len(m.fdTraceView.events) { + end = len(m.fdTraceView.events) + } + visible := m.fdTraceView.events[start:end] + base := RenderFDTraceTable(width, m.fdTraceView.pid, m.fdTraceView.fd, len(m.fdTraceView.events), visible) + status := fmt.Sprintf("FD Trace Row %d/%d | esc:back j/k:scroll", rowNumber(start, len(m.fdTraceView.events)), len(m.fdTraceView.events)) + return base + "\n" + status +} + +func (m *Model) maxFDTraceOffset() int { + rows := m.visibleRows() + if len(m.fdTraceView.events) <= rows { + return 0 + } + return len(m.fdTraceView.events) - rows +} + +func (m *Model) scrollFDTraceByLines(delta int) { + if delta == 0 { + return + } + max := m.maxFDTraceOffset() + next := m.fdTraceView.offset + delta + if next < 0 { + next = 0 + } + if next > max { + next = max + } + m.fdTraceView.offset = next +} + func (m *Model) moveSelectionBy(delta int) { if len(m.filtered) == 0 { m.selectedIdx = -1 diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index ade1010..3dac038 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -1,6 +1,9 @@ package eventstream -import "testing" +import ( + "strings" + "testing" +) func pushEvents(rb *RingBuffer, count int) { for i := 0; i < count; i++ { @@ -16,6 +19,7 @@ func pushEvents(rb *RingBuffer, count int) { FileName: "/tmp/file", RetVal: int64(i), IsError: i%3 == 0, + FD: UnknownFD, }) } } @@ -368,3 +372,64 @@ func TestPausedSelectionMovesAndRecentersWithJKAndArrows(t *testing.T) { t.Fatalf("expected centered viewport after up") } } + +func TestPausedEnterOpensFDTraceViewScopedByPIDAndFD(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) + rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) // same pid/fd, different tid + rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) // different fd + rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) // different pid + + m := NewModel(rb) + m.height = 20 + m.Refresh() + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } + + // Pick the first row (pid=10, fd=3). + m.selectedIdx = 0 + if !m.HandleKey("enter") { + t.Fatalf("enter should open fd trace view") + } + if !m.fdTraceView.visible { + t.Fatalf("expected fd trace view visible") + } + if m.fdTraceView.pid != 10 || m.fdTraceView.fd != 3 { + t.Fatalf("expected pid/fd 10/3, got %d/%d", m.fdTraceView.pid, m.fdTraceView.fd) + } + if len(m.fdTraceView.events) != 2 { + t.Fatalf("expected 2 matching events, got %d", len(m.fdTraceView.events)) + } + for _, ev := range m.fdTraceView.events { + if ev.PID != 10 || ev.FD != 3 { + t.Fatalf("unexpected event in fd trace view: pid=%d fd=%d", ev.PID, ev.FD) + } + } +} + +func TestFDTraceViewRendersAndClosesOnEsc(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 5, Syscall: "read", FileName: "/x"}) + rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 5, Syscall: "write", FileName: "/x"}) + m := NewModel(rb) + m.height = 20 + m.Refresh() + _ = m.HandleKey("space") + m.selectedIdx = 0 + _ = m.HandleKey("enter") + + view := m.View(120, 24) + if !strings.Contains(view, "FD Trace (ring snapshot)") { + t.Fatalf("expected fd trace header in view") + } + if !strings.Contains(view, "PID:10 FD:5 matched:2") { + t.Fatalf("expected pid/fd summary in fd trace view") + } + if !m.HandleKey("esc") { + t.Fatalf("esc should close fd trace view") + } + if m.fdTraceView.visible { + t.Fatalf("expected fd trace view closed") + } +} diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index 92a70be..e9d44f1 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -15,6 +15,7 @@ type columnLayout struct { comm int pidTid int syscall int + fd int ret int bytes int file int @@ -42,6 +43,23 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) } +func RenderFDTraceTable(width int, pid uint32, fd int32, totalCount int, events []StreamEvent) string { + if width <= 0 { + width = 100 + } + contentWidth := panelContentWidth(width) + + lines := make([]string, 0, len(events)+3) + lines = append(lines, common.HeaderStyle.Render("FD Trace (ring snapshot)")) + lines = append(lines, fmt.Sprintf("PID:%d FD:%d matched:%d", pid, fd, totalCount)) + lines = append(lines, renderColumnHeader(contentWidth)) + for _, ev := range events { + lines = append(lines, renderEventRow(ev, contentWidth, false)) + } + + return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) +} + func renderStatusLine(paused bool, totalCount, filteredCount, bufferLen, bufferCap int) string { state := common.HighlightStyle.Render("LIVE") if paused { @@ -64,12 +82,13 @@ func renderFilterLine(filter Filter) string { func renderColumnHeader(width int) string { cols := computeColumnLayout(width) - header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", + header := fmt.Sprintf("%-*s %-*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.fd, "FD", cols.ret, "Ret", cols.bytes, "Bytes", "File", @@ -80,12 +99,17 @@ func renderColumnHeader(width int) string { func renderEventRow(ev StreamEvent, width int, selected bool) string { cols := computeColumnLayout(width) pidTid := fmt.Sprintf("%d.%d", ev.PID, ev.TID) - row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", + fd := "-" + if ev.FD >= 0 { + fd = strconv.FormatInt(int64(ev.FD), 10) + } + row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", cols.gap, fitCell(formatDurationNs(ev.GapNs), cols.gap), cols.latency, fitCell(formatDurationNs(ev.DurationNs), cols.latency), cols.comm, fitCell(ev.Comm, cols.comm), cols.pidTid, fitCell(pidTid, cols.pidTid), cols.syscall, fitCell(ev.Syscall, cols.syscall), + cols.fd, fitCell(fd, cols.fd), cols.ret, fitCell(strconv.FormatInt(ev.RetVal, 10), cols.ret), cols.bytes, fitCell(strconv.FormatUint(ev.Bytes, 10), cols.bytes), fitCell(ev.FileName, cols.file), @@ -110,9 +134,10 @@ func computeColumnLayout(width int) columnLayout { comm := 10 pidTid := 10 syscall := 9 + fd := 4 ret := 5 bytes := 8 - fixed := gap + latency + comm + pidTid + syscall + ret + bytes + 7 + fixed := gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 file := width - fixed if file >= 28 { // On wider terminals, give a little more room back to descriptive columns. @@ -120,24 +145,25 @@ func computeColumnLayout(width int) columnLayout { comm = 12 syscall = 11 pidTid = 11 - fixed = gap + latency + comm + pidTid + syscall + ret + bytes + 7 + fixed = gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 file = width - fixed } - return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, ret: ret, bytes: bytes, file: file} + return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} } // Very narrow widths: compress further but keep file column readable. comm = 8 pidTid = 9 syscall = 8 + fd = 3 ret = 4 bytes = 7 - fixed = gap + latency + comm + pidTid + syscall + ret + bytes + 7 + fixed = gap + latency + comm + pidTid + syscall + fd + ret + bytes + 8 file = width - fixed if file < 12 { file = 12 } - return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, ret: ret, bytes: bytes, file: file} + return columnLayout{gap: gap, latency: latency, comm: comm, pidTid: pidTid, syscall: syscall, fd: fd, ret: ret, bytes: bytes, file: file} } func formatDurationNs(v uint64) string { diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index 8c2d39a..33e5b38 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -34,6 +34,14 @@ func TestRenderPausedAndErrorRow(t *testing.T) { } } +func TestRenderShowsFDWhenPresent(t *testing.T) { + events := []StreamEvent{{Syscall: "read", Comm: "worker", PID: 1, TID: 2, FD: 9, DurationNs: 10, GapNs: 1, Bytes: 8, FileName: "/tmp/b", RetVal: 8}} + out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1) + if !strings.Contains(out, "FD") || !strings.Contains(out, " 9 ") { + t.Fatalf("expected FD column/value in output\n%s", out) + } +} + func TestRenderHeaderAndTruncate(t *testing.T) { events := []StreamEvent{{ Syscall: "very_long_syscall_name", @@ -48,7 +56,7 @@ func TestRenderHeaderAndTruncate(t *testing.T) { }} out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1) - for _, col := range []string{"Gap", "Latency", "Comm", "PID.TID", "Syscall", "Ret", "Bytes", "File"} { + for _, col := range []string{"Gap", "Latency", "Comm", "PID.TID", "Syscall", "FD", "Ret", "Bytes", "File"} { if !strings.Contains(out, col) { t.Fatalf("missing column %q\n%s", col, out) } @@ -98,7 +106,7 @@ func TestRenderEventRowIsSingleLineWithControlCharsAndLongValues(t *testing.T) { func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { cols := computeColumnLayout(120) - if cols.file < 55 { + if cols.file < 50 { t.Fatalf("expected file column to get most width, got %d", cols.file) } } @@ -124,3 +132,15 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { } } } + +func TestRenderFDTraceTableShowsHeaderAndScope(t *testing.T) { + out := RenderFDTraceTable(100, 123, 7, 2, []StreamEvent{ + {Syscall: "read", PID: 123, TID: 1, FD: 7, FileName: "/tmp/a"}, + {Syscall: "write", PID: 123, TID: 2, FD: 7, FileName: "/tmp/a"}, + }) + for _, want := range []string{"FD Trace (ring snapshot)", "PID:123 FD:7 matched:2", "read", "write"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q\n%s", want, out) + } + } +} diff --git a/internal/tui/eventstream/streamevent.go b/internal/tui/eventstream/streamevent.go index 85ea217..9e89488 100644 --- a/internal/tui/eventstream/streamevent.go +++ b/internal/tui/eventstream/streamevent.go @@ -18,8 +18,11 @@ type StreamEvent struct { Bytes uint64 RetVal int64 IsError bool + FD int32 } +const UnknownFD int32 = -1 + func NewStreamEvent(seq uint64, pair *event.Pair) StreamEvent { e := StreamEvent{ Seq: seq, @@ -32,6 +35,10 @@ func NewStreamEvent(seq uint64, pair *event.Pair) StreamEvent { DurationNs: pair.Duration, GapNs: pair.DurationToPrev, Bytes: pair.Bytes, + FD: UnknownFD, + } + if fd, ok := pair.FileDescriptor(); ok { + e.FD = fd } if retEv, ok := pair.ExitEv.(*types.RetEvent); ok { diff --git a/internal/tui/eventstream/streamevent_test.go b/internal/tui/eventstream/streamevent_test.go index d053072..6be7407 100644 --- a/internal/tui/eventstream/streamevent_test.go +++ b/internal/tui/eventstream/streamevent_test.go @@ -41,6 +41,9 @@ func TestNewStreamEventPopulatesFields(t *testing.T) { if got.DurationNs != 66 || got.GapNs != 19 || got.Bytes != 512 { t.Fatalf("DurationNs/GapNs/Bytes = %d/%d/%d, want 66/19/512", got.DurationNs, got.GapNs, got.Bytes) } + if got.FD != 7 { + t.Fatalf("FD = %d, want 7", got.FD) + } if got.RetVal != -2 { t.Fatalf("RetVal = %d, want -2", got.RetVal) } @@ -69,6 +72,9 @@ func TestNewStreamEventCopiesBeforeRecycle(t *testing.T) { if got.RetVal != 8 || got.IsError { t.Fatalf("RetVal/IsError = %d/%v, want 8/false", got.RetVal, got.IsError) } + if got.FD != 3 { + t.Fatalf("FD = %d, want 3", got.FD) + } } func TestNewStreamEventWithoutRetEvent(t *testing.T) { @@ -84,4 +90,7 @@ func TestNewStreamEventWithoutRetEvent(t *testing.T) { if got.IsError { t.Fatalf("IsError = true, want false") } + if got.FD != UnknownFD { + t.Fatalf("FD = %d, want %d", got.FD, UnknownFD) + } } |
