summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-26 10:37:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-26 10:37:40 +0200
commit4302cbf28a9d9efd2416ab6ea95168f9e39c29ec (patch)
tree90e4dfb2f9cc71e483396c3465859d1282282348 /internal/tui/eventstream
parentc661b23f2940e07a1e1cbe16334598d999096f27 (diff)
tui: add fd trace drilldown and fd column in stream
Diffstat (limited to 'internal/tui/eventstream')
-rw-r--r--internal/tui/eventstream/model.go122
-rw-r--r--internal/tui/eventstream/model_test.go67
-rw-r--r--internal/tui/eventstream/render.go40
-rw-r--r--internal/tui/eventstream/render_test.go24
-rw-r--r--internal/tui/eventstream/streamevent.go7
-rw-r--r--internal/tui/eventstream/streamevent_test.go9
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)
+ }
}