orama/pkg/cli/monitor/tui/model.go

227 lines
5.0 KiB
Go

package tui
import (
"context"
"fmt"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
)
const (
tabOverview = iota
tabNodes
tabServices
tabMesh
tabDNS
tabNamespaces
tabAlerts
tabCount
)
var tabNames = []string{"Overview", "Nodes", "Services", "WG Mesh", "DNS", "Namespaces", "Alerts"}
// snapshotMsg carries the result of a background collection.
type snapshotMsg struct {
snap *monitor.ClusterSnapshot
err error
}
// tickMsg fires on each refresh interval.
type tickMsg time.Time
// model is the root Bubbletea model for the Orama monitor TUI.
type model struct {
cfg monitor.CollectorConfig
interval time.Duration
activeTab int
viewport viewport.Model
width int
height int
snapshot *monitor.ClusterSnapshot
loading bool
lastError error
lastUpdate time.Time
quitting bool
}
// newModel creates a fresh model with default viewport dimensions.
func newModel(cfg monitor.CollectorConfig, interval time.Duration) model {
vp := viewport.New(80, 24)
return model{
cfg: cfg,
interval: interval,
viewport: vp,
loading: true,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(doCollect(m.cfg), tickCmd(m.interval))
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case msg.String() == "q" || msg.String() == "ctrl+c":
m.quitting = true
return m, tea.Quit
case msg.String() == "tab" || msg.String() == "l":
m.activeTab = (m.activeTab + 1) % tabCount
m.updateContent()
m.viewport.GotoTop()
return m, nil
case msg.String() == "shift+tab" || msg.String() == "h":
m.activeTab = (m.activeTab - 1 + tabCount) % tabCount
m.updateContent()
m.viewport.GotoTop()
return m, nil
case msg.String() == "r":
if !m.loading {
m.loading = true
return m, doCollect(m.cfg)
}
return m, nil
default:
// Delegate scrolling to viewport
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Reserve 4 lines: header, tab bar, blank separator, footer
vpHeight := msg.Height - 4
if vpHeight < 1 {
vpHeight = 1
}
m.viewport.Width = msg.Width
m.viewport.Height = vpHeight
m.updateContent()
return m, nil
case snapshotMsg:
m.loading = false
if msg.err != nil {
m.lastError = msg.err
} else {
m.snapshot = msg.snap
m.lastError = nil
m.lastUpdate = time.Now()
}
m.updateContent()
return m, nil
case tickMsg:
if !m.loading {
m.loading = true
cmds = append(cmds, doCollect(m.cfg))
}
cmds = append(cmds, tickCmd(m.interval))
return m, tea.Batch(cmds...)
}
return m, nil
}
func (m model) View() string {
if m.quitting {
return ""
}
// Header
var header string
if m.snapshot != nil {
ago := time.Since(m.lastUpdate).Truncate(time.Second)
header = headerStyle.Render(fmt.Sprintf(
"Orama Monitor — %s — Last: %s (%s ago)",
m.snapshot.Environment,
m.lastUpdate.Format("15:04:05"),
ago,
))
} else if m.loading {
header = headerStyle.Render("Orama Monitor — collecting...")
} else if m.lastError != nil {
header = headerStyle.Render(fmt.Sprintf("Orama Monitor — error: %v", m.lastError))
} else {
header = headerStyle.Render("Orama Monitor")
}
if m.loading && m.snapshot != nil {
header += styleMuted.Render(" (refreshing...)")
}
// Tab bar
tabs := renderTabBar(m.activeTab, m.width)
// Footer
footer := footerStyle.Render("tab: switch | j/k: scroll | r: refresh | q: quit")
return header + "\n" + tabs + "\n" + m.viewport.View() + "\n" + footer
}
// updateContent renders the active tab and sets it on the viewport.
func (m *model) updateContent() {
w := m.width
if w == 0 {
w = 80
}
var content string
switch m.activeTab {
case tabOverview:
content = renderOverview(m.snapshot, w)
case tabNodes:
content = renderNodes(m.snapshot, w)
case tabServices:
content = renderServicesTab(m.snapshot, w)
case tabMesh:
content = renderWGMesh(m.snapshot, w)
case tabDNS:
content = renderDNSTab(m.snapshot, w)
case tabNamespaces:
content = renderNamespacesTab(m.snapshot, w)
case tabAlerts:
content = renderAlertsTab(m.snapshot, w)
}
m.viewport.SetContent(content)
}
// doCollect returns a tea.Cmd that runs monitor.CollectOnce in a goroutine.
func doCollect(cfg monitor.CollectorConfig) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
snap, err := monitor.CollectOnce(ctx, cfg)
return snapshotMsg{snap: snap, err: err}
}
}
// tickCmd returns a tea.Cmd that fires a tickMsg after the given interval.
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Run starts the TUI program with the given collector config.
func Run(cfg monitor.CollectorConfig) error {
m := newModel(cfg, 30*time.Second)
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}