diff options
Diffstat (limited to 'internal/tui/dashboard/model.go')
| -rw-r--r-- | internal/tui/dashboard/model.go | 218 |
1 files changed, 177 insertions, 41 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index fc9caf6..d10a91a 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,20 +1,26 @@ package dashboard import ( + "strings" + "time" + "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" + flamegraphtui "ior/internal/tui/flamegraph" "ior/internal/tui/messages" - "strings" - "time" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" ) const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const flameRefreshMs = 200 const streamChromeRows = 4 +const dashboardHelpHintRows = 1 +const dashboardExpandedHelpRows = 2 +const dashboardTabBarRows = 1 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -23,6 +29,7 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type flameTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -31,8 +38,9 @@ type streamEditorDoneMsg struct { type Model struct { activeTab Tab - engine SnapshotSource - latest *statsengine.Snapshot + engine SnapshotSource + latest *statsengine.Snapshot + liveTrie flamegraphtui.LiveTrieSource width int height int @@ -46,32 +54,50 @@ type Model struct { filesDirOffset int processesOffset int streamModel eventstream.Model + flamegraphModel flamegraphtui.Model showHelp bool + isDark bool + focused bool } // NewModel creates a dashboard model with default refresh cadence. -func NewModel(engine SnapshotSource, streamSource *eventstream.RingBuffer) Model { +func NewModel(engine SnapshotSource, streamSource eventstream.Source) Model { return NewModelWithConfig(engine, streamSource, defaultRefreshMs, common.Keys) } // NewModelWithConfig creates a dashboard model with explicit refresh and keys. -func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuffer, refreshMs int, keys common.KeyMap) Model { +func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source, refreshMs int, keys common.KeyMap) Model { if refreshMs <= 0 { refreshMs = defaultRefreshMs } - return Model{ - activeTab: TabOverview, - engine: engine, - refreshEvery: time.Duration(refreshMs) * time.Millisecond, - keys: keys, - pidFilter: -1, - streamModel: eventstream.NewModel(streamSource), + m := Model{ + activeTab: TabFlame, + 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 } // Init starts periodic refresh ticks. func (m Model) Init() tea.Cmd { - return tickCmd(m.refreshEvery) + cmds := []tea.Cmd{tickCmd(m.refreshEvery)} + switch m.activeTab { + case TabStream: + cmds = append(cmds, streamTickCmd()) + case TabFlame: + cmds = append(cmds, flameTickCmd()) + } + if len(cmds) == 1 { + return cmds[0] + } + return tea.Batch(cmds...) } // Update handles ticks, snapshots, tab changes, and resize events. @@ -82,19 +108,42 @@ 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) + flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil case refreshTickMsg: + if !m.focused { + return m, nil + } snap := m.snapshot() return m, tea.Batch( tickCmd(m.refreshEvery), func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }, ) case streamTickMsg: + if !m.focused { + return m, nil + } if m.activeTab != TabStream { return m, nil } m.streamModel.Refresh() return m, streamTickCmd() + case flameTickMsg: + if !m.focused { + return m, nil + } + if m.activeTab != TabFlame { + return m, nil + } + var animCmd tea.Cmd + if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() { + animCmd = m.flamegraphModel.AnimationCmd() + } + if animCmd != nil { + return m, tea.Batch(flameTickCmd(), animCmd) + } + return m, flameTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -103,7 +152,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) m.streamModel.Refresh() return m, nil - case tea.KeyMsg: + case tea.KeyPressMsg: return m.handleKey(msg) case streamEditorDoneMsg: if msg.err != nil { @@ -111,17 +160,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.activeTab == TabFlame { + next, cmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, cmd + } return m, nil } -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() if keyStr == "H" { m.showHelp = !m.showHelp + flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp) + m.flamegraphModel.SetViewport(flameWidth, flameHeight) return m, nil } + if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } handled, scrollCmd := m.handleScrollKey(msg) if scrollCmd != nil { cmd = scrollCmd @@ -132,29 +193,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !handled { switch { + case key.Matches(msg, m.keys.One): + m.activeTab = TabFlame + handled = true case key.Matches(msg, m.keys.Tab): m.activeTab = nextTab(m.activeTab) handled = true case key.Matches(msg, m.keys.ShiftTab): m.activeTab = prevTab(m.activeTab) handled = true - case key.Matches(msg, m.keys.One): - m.activeTab = TabOverview - handled = true case key.Matches(msg, m.keys.Two): - m.activeTab = TabSyscalls + m.activeTab = TabOverview handled = true case key.Matches(msg, m.keys.Three): - m.activeTab = TabFiles + m.activeTab = TabSyscalls handled = true case key.Matches(msg, m.keys.Four): - m.activeTab = TabProcesses + m.activeTab = TabFiles handled = true case key.Matches(msg, m.keys.Five): - m.activeTab = TabLatency + m.activeTab = TabProcesses handled = true case key.Matches(msg, m.keys.Six): - m.activeTab = TabStream + m.activeTab = TabLatency handled = true case key.Matches(msg, m.keys.Seven): m.activeTab = TabStream @@ -171,18 +232,34 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } if !handled { + if m.activeTab == TabFlame { + next, flameCmd := m.flamegraphModel.Update(msg) + m.flamegraphModel = next.(flamegraphtui.Model) + return m, flameCmd + } 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.KeyMsg) (bool, tea.Cmd) { +func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { keyStr := msg.String() switch m.activeTab { case TabSyscalls: @@ -271,26 +348,60 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } -// BlocksGlobalShortcuts reports whether modal UI in the active tab should -// suppress top-level shortcuts (for example global export key handling). -func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()) +// BlocksGlobalShortcuts reports whether the active tab should suppress a +// top-level shortcut for the given key press. +func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { + if m.activeTab == TabStream { + return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible() + } + if m.activeTab == TabFlame { + return m.flamegraphModel.ConsumesKey(msg) + } + return false } // SetStreamSource updates the live stream source used by the stream tab. -func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { +func (m *Model) SetStreamSource(source eventstream.Source) { m.streamModel.SetSource(source) } +// SetLiveTrie updates the live trie source used by the flamegraph tab. +func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { + m.liveTrie = liveTrie + m.flamegraphModel.SetLiveTrie(liveTrie) + if m.width > 0 && m.height > 0 { + m.flamegraphModel.SetViewport(m.width, m.height) + } + m.flamegraphModel.RefreshFromLiveTrie() +} + +// 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. +func (m *Model) SetFocused(focused bool) { + m.focused = focused +} + +// SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot. +func (m Model) SnapshotCmd() tea.Cmd { + snap := m.snapshot() + return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } +} + // SetPidFilter updates the active PID filter used by tab render hints. func (m *Model) SetPidFilter(pid int) { m.pidFilter = pid } // View renders the tab bar, active tab scaffold, and help bar. -func (m Model) View() string { +func (m Model) View() tea.View { width, height := common.EffectiveViewport(m.width, m.height) - activeHeight := height + _, activeHeight := flameViewport(width, height, m.showHelp) streamModel := m.streamModel streamModel.SetFooterVisible(m.showHelp) if m.activeTab == TabStream { @@ -304,6 +415,7 @@ func (m Model) View() string { m.activeTab, m.latest, &streamModel, + &m.flamegraphModel, width, activeHeight, m.pidFilter, @@ -319,20 +431,27 @@ func (m Model) View() string { } else { b.WriteString(renderHelpHint(width)) } - return common.ScreenStyle.Render(b.String()) + return tea.NewView(common.ScreenStyle.Render(b.String())) } 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...") @@ -361,6 +480,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 @@ -369,3 +492,16 @@ func streamViewport(width, height int) (int, int) { } return width, height } + +func flameViewport(width, height int, showHelp bool) (int, int) { + width, height = common.EffectiveViewport(width, height) + chromeRows := dashboardTabBarRows + dashboardHelpHintRows + if showHelp { + chromeRows = dashboardTabBarRows + dashboardExpandedHelpRows + } + height -= chromeRows + if height < 1 { + height = 1 + } + return width, height +} |
