summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:27:06 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:27:06 +0200
commit270c4b422cfc5e7588b7045276588e9f043f85e3 (patch)
tree86b9b90f4154a95268f3391d29a23982f25f8025
parent6f678299369d46b40aa412c7340eca9b18fc4dd1 (diff)
task 354: wire dashboard flame tab to LiveTrie
-rw-r--r--internal/tui/dashboard/model.go83
-rw-r--r--internal/tui/dashboard/model_test.go21
-rw-r--r--internal/tui/flamegraph/model.go40
-rw-r--r--internal/tui/flamegraph/model_test.go21
-rw-r--r--internal/tui/tui.go1
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