diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:27:06 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:27:06 +0200 |
| commit | 270c4b422cfc5e7588b7045276588e9f043f85e3 (patch) | |
| tree | 86b9b90f4154a95268f3391d29a23982f25f8025 | |
| parent | 6f678299369d46b40aa412c7340eca9b18fc4dd1 (diff) | |
task 354: wire dashboard flame tab to LiveTrie
| -rw-r--r-- | internal/tui/dashboard/model.go | 83 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 21 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 40 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 21 | ||||
| -rw-r--r-- | internal/tui/tui.go | 1 |
5 files changed, 145 insertions, 21 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0a9915b..e8c1cb2 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,9 +1,11 @@ package dashboard import ( + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" + flamegraphtui "ior/internal/tui/flamegraph" "ior/internal/tui/messages" "strings" "time" @@ -14,6 +16,7 @@ import ( const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const flameRefreshMs = 200 const streamChromeRows = 4 // SnapshotSource is the dashboard data source. @@ -23,6 +26,7 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type flameTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -31,8 +35,9 @@ type streamEditorDoneMsg struct { type Model struct { activeTab Tab - engine SnapshotSource - latest *statsengine.Snapshot + engine SnapshotSource + latest *statsengine.Snapshot + liveTrie *coreflamegraph.LiveTrie width int height int @@ -46,6 +51,7 @@ type Model struct { filesDirOffset int processesOffset int streamModel eventstream.Model + flamegraphModel flamegraphtui.Model showHelp bool isDark bool focused bool @@ -62,14 +68,15 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf refreshMs = defaultRefreshMs } m := Model{ - activeTab: TabOverview, - engine: engine, - refreshEvery: time.Duration(refreshMs) * time.Millisecond, - keys: keys, - pidFilter: -1, - streamModel: eventstream.NewModel(streamSource), - isDark: true, - focused: true, + activeTab: TabOverview, + engine: engine, + refreshEvery: time.Duration(refreshMs) * time.Millisecond, + keys: keys, + pidFilter: -1, + streamModel: eventstream.NewModel(streamSource), + flamegraphModel: flamegraphtui.NewModel(nil), + isDark: true, + focused: true, } m.SetDarkMode(true) return m @@ -88,6 +95,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) m.streamModel.SetViewport(streamWidth, streamHeight) + m.flamegraphModel.SetViewport(msg.Width, msg.Height) return m, nil case refreshTickMsg: if !m.focused { @@ -104,6 +112,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.streamModel.Refresh() return m, streamTickCmd() + case flameTickMsg: + if m.activeTab != TabFlame { + return m, nil + } + if m.liveTrie != nil && m.liveTrie.Version() != m.flamegraphModel.LastVersion() { + m.flamegraphModel.RefreshFromLiveTrie() + } + return m, flameTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -182,13 +198,24 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !handled { return m, nil } + batch := make([]tea.Cmd, 0, 3) + if cmd != nil { + batch = append(batch, cmd) + } if prevActiveTab != TabStream && m.activeTab == TabStream { - if cmd == nil { - return m, streamTickCmd() - } - return m, tea.Batch(cmd, streamTickCmd()) + batch = append(batch, streamTickCmd()) + } + if prevActiveTab != TabFlame && m.activeTab == TabFlame { + batch = append(batch, flameTickCmd()) + } + switch len(batch) { + case 0: + return m, nil + case 1: + return m, batch[0] + default: + return m, tea.Batch(batch...) } - return m, cmd } func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { @@ -291,10 +318,20 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { m.streamModel.SetSource(source) } +// SetLiveTrie updates the live trie source used by the flamegraph tab. +func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { + m.liveTrie = liveTrie + m.flamegraphModel.SetLiveTrie(liveTrie) + if m.width > 0 && m.height > 0 { + m.flamegraphModel.SetViewport(m.width, m.height) + } +} + // SetDarkMode updates dashboard child models for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark m.streamModel.SetDarkMode(isDark) + m.flamegraphModel.SetDarkMode(isDark) } // SetFocused controls whether periodic refresh ticks are processed. @@ -330,6 +367,7 @@ func (m Model) View() tea.View { m.activeTab, m.latest, &streamModel, + &m.flamegraphModel, width, activeHeight, m.pidFilter, @@ -352,13 +390,20 @@ func tickCmd(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} }) } -func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { if tab == TabStream { if streamModel == nil { return common.PanelStyle.Render("Stream: waiting for source...") } return streamModel.View(width, height) } + if tab == TabFlame { + if flameModel == nil { + return common.PanelStyle.Render("Flame: waiting for model...") + } + flameModel.SetViewport(width, height) + return flameModel.View().Content + } if snap == nil { return common.PanelStyle.Render(tab.String() + ": waiting for stats...") @@ -378,8 +423,6 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter) case TabLatency: return renderLatencyGapsTab(snap, width, height) - case TabFlame: - return common.PanelStyle.Render("Flame: waiting for model...") default: return common.PanelStyle.Render("Unknown tab") } @@ -389,6 +432,10 @@ func streamTickCmd() tea.Cmd { return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} }) } +func flameTickCmd() tea.Cmd { + return tea.Tick(flameRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return flameTickMsg{} }) +} + func streamViewport(width, height int) (int, int) { width, height = common.EffectiveViewport(width, height) height -= streamChromeRows diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index c9a1cb9..be31297 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" @@ -178,6 +179,24 @@ func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { } } +func TestFlameTickRefreshesFlamegraphModel(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + liveTrie.Reset() + + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabFlame + + next, cmd := m.Update(flameTickMsg{}) + model := next.(Model) + if cmd == nil { + t.Fatalf("expected flame tick to schedule next tick command") + } + if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want { + t.Fatalf("expected flame model version %d, got %d", want, got) + } +} + func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) { rb := eventstream.NewRingBuffer() for i := 0; i < 300; i++ { @@ -386,7 +405,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}, ) - out := renderActiveTab(TabFiles, &snap, nil, 120, 30, -1, 0, 0, true, 0, 0) + out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0) if !strings.Contains(out, "Directory") { t.Fatalf("expected grouped directory files view header, got %q", out) } diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index dd77201..ac9b5af 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -1,6 +1,8 @@ package flamegraph import ( + "encoding/json" + "fmt" "image/color" coreflamegraph "ior/internal/flamegraph" common "ior/internal/tui/common" @@ -88,10 +90,46 @@ func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) { // View renders the flamegraph viewport. func (m Model) View() tea.View { - content := common.PanelStyle.Render("Flame: model scaffold") + content := "Flame: waiting for data..." + if m.snapshot != nil { + content = fmt.Sprintf("Flame: live snapshot v%d", m.lastVersion) + } + content = common.PanelStyle.Render(content) return tea.NewView(content) } +// SetLiveTrie updates the data source used by the flamegraph model. +func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { + m.liveTrie = liveTrie + m.lastVersion = 0 + m.snapshot = nil +} + +// RefreshFromLiveTrie loads a new snapshot when the source version changes. +func (m *Model) RefreshFromLiveTrie() bool { + if m.liveTrie == nil { + return false + } + version := m.liveTrie.Version() + if version == m.lastVersion && m.snapshot != nil { + return false + } + + payload, version := m.liveTrie.SnapshotJSON() + var snapshot snapshotNode + if err := json.Unmarshal(payload, &snapshot); err != nil { + return false + } + m.snapshot = &snapshot + m.lastVersion = version + return true +} + +// LastVersion returns the latest snapshot version loaded into the model. +func (m Model) LastVersion() uint64 { + return m.lastVersion +} + // SetViewport updates model render dimensions. func (m *Model) SetViewport(width, height int) { m.width = width diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 42729bb..1e472ae 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -1,6 +1,9 @@ package flamegraph -import "testing" +import ( + coreflamegraph "ior/internal/flamegraph" + "testing" +) func TestNewModelDefaults(t *testing.T) { m := NewModel(nil) @@ -29,3 +32,19 @@ func TestSetViewportAndDarkMode(t *testing.T) { t.Fatalf("expected dark mode to be disabled") } } + +func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected first refresh to load baseline snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be populated after refresh") + } + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected no refresh when version is unchanged") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index cc627da..ab719fb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -340,6 +340,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case TracingStartedMsg: m.attaching = false m.dashboard.SetStreamSource(m.runtime.eventStreamSource()) + m.dashboard.SetLiveTrie(m.runtime.liveTrie()) return m, m.dashboard.Init() case TracingErrorMsg: m.attaching = false |
