diff --git a/cmd/concave-tui/config/config.go b/cmd/concave-tui/config/config.go index f1304da..0ea1971 100644 --- a/cmd/concave-tui/config/config.go +++ b/cmd/concave-tui/config/config.go @@ -20,8 +20,14 @@ type DisplayConfig struct { RefreshIntervalMs int `toml:"refresh_interval_ms"` } +type MonitoringConfig struct { + PrometheusURL string `toml:"prometheus_url"` + GrafanaURL string `toml:"grafana_url"` +} + type Config struct { Display DisplayConfig + Monitoring MonitoringConfig ActivePreset string Presets []Preset } @@ -57,6 +63,10 @@ func DefaultConfig() Config { SidebarDefault: "expanded", RefreshIntervalMs: 1000, }, + Monitoring: MonitoringConfig{ + PrometheusURL: "", + GrafanaURL: "", + }, ActivePreset: "default", Presets: []Preset{ { @@ -195,6 +205,9 @@ func marshalXDG(c Config) []byte { fmt.Fprintf(&buf, "graph_auto_height_threshold = %d\n", max(1, c.Display.GraphAutoHeightThreshold)) fmt.Fprintf(&buf, "sidebar_default = %q\n", normalizedSidebar(c.Display.SidebarDefault)) fmt.Fprintf(&buf, "refresh_interval_ms = %d\n", max(100, c.Display.RefreshIntervalMs)) + buf.WriteString("\n[monitoring]\n") + fmt.Fprintf(&buf, "prometheus_url = %q\n", c.Monitoring.PrometheusURL) + fmt.Fprintf(&buf, "grafana_url = %q\n", c.Monitoring.GrafanaURL) buf.WriteString("\n[layout]\n") fmt.Fprintf(&buf, "active_preset = %q\n", activePresetName(c)) return buf.Bytes() @@ -232,6 +245,9 @@ func parseXDGInto(cfg *Config, data []byte) { case "[display]": section = "display" continue + case "[monitoring]": + section = "monitoring" + continue case "[layout]": section = "layout" continue @@ -254,6 +270,13 @@ func parseXDGInto(cfg *Config, data []byte) { case "refresh_interval_ms": cfg.Display.RefreshIntervalMs = parseInt(value, cfg.Display.RefreshIntervalMs) } + case "monitoring": + switch key { + case "prometheus_url": + cfg.Monitoring.PrometheusURL = parseString(value, cfg.Monitoring.PrometheusURL) + case "grafana_url": + cfg.Monitoring.GrafanaURL = parseString(value, cfg.Monitoring.GrafanaURL) + } case "layout": if key == "active_preset" { cfg.ActivePreset = parseString(value, cfg.ActivePreset) diff --git a/cmd/concave-tui/config/config_test.go b/cmd/concave-tui/config/config_test.go index 7b9c4c6..a51f2a7 100644 --- a/cmd/concave-tui/config/config_test.go +++ b/cmd/concave-tui/config/config_test.go @@ -132,6 +132,31 @@ func TestSaveWritesAtomically(t *testing.T) { } } +func TestMonitoringURLsRoundTrip(t *testing.T) { + home := t.TempDir() + xdg := filepath.Join(home, ".config") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdg) + + cfg := DefaultConfig() + cfg.Monitoring.PrometheusURL = "http://127.0.0.1:9090" + cfg.Monitoring.GrafanaURL = "http://grafana.internal" + if err := Save(cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.Monitoring.PrometheusURL != cfg.Monitoring.PrometheusURL { + t.Fatalf("PrometheusURL = %q", loaded.Monitoring.PrometheusURL) + } + if loaded.Monitoring.GrafanaURL != cfg.Monitoring.GrafanaURL { + t.Fatalf("GrafanaURL = %q", loaded.Monitoring.GrafanaURL) + } +} + func TestResolveGraphStyleBoundaries(t *testing.T) { cfg := DefaultConfig() if got := ResolveGraphStyle(cfg, 119, 40); got != "bar" { diff --git a/cmd/concave-tui/model/monitoring.go b/cmd/concave-tui/model/monitoring.go new file mode 100644 index 0000000..93bee48 --- /dev/null +++ b/cmd/concave-tui/model/monitoring.go @@ -0,0 +1,252 @@ +package model + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + tuiauth "github.com/Gradient-Linux/concave-tui/internal/auth" + "github.com/Gradient-Linux/concave-tui/internal/monitoring" +) + +const monitoringRefreshInterval = 15 * time.Second + +type monitoringProbe struct { + Reachability monitoring.Reachability + Samples map[string]monitoring.Sample +} + +type monitoringLoadedMsg struct { + token int + probe monitoringProbe + err error +} + +type monitoringTickMsg struct { + token int +} + +type monitoringMetric struct { + id string + label string + query string + unit string +} + +var monitoringMetrics = []monitoringMetric{ + {id: "up", label: "Scrape targets up", query: "count(up == 1)", unit: "count"}, + {id: "cpu", label: "CPU busy", query: `100 - (avg by() (rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)`, unit: "percent"}, + {id: "mem", label: "Memory available", query: `node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100`, unit: "percent"}, + {id: "fs", label: "Root FS free", query: `node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100`, unit: "percent"}, + {id: "gpu", label: "GPU utilisation", query: "avg(DCGM_FI_DEV_GPU_UTIL)", unit: "percent"}, +} + +// MonitoringModel renders Prometheus + Grafana reachability and a small +// snapshot of PromQL samples. It mirrors concave-web's MonitoringView. +type MonitoringModel struct { + width int + height int + active bool + role tuiauth.Role + token int + loading bool + cfg monitoringSettings + probe monitoringProbe + lastErr error +} + +type monitoringSettings struct { + PrometheusURL string + GrafanaURL string +} + +// NewMonitoringModel builds an unconfigured monitoring model. +func NewMonitoringModel() MonitoringModel { + return MonitoringModel{} +} + +// SetRole records the active viewer role; Monitoring requires Viewer or above. +func (m *MonitoringModel) SetRole(role tuiauth.Role) { m.role = role } + +// SetSize resizes the content area. +func (m *MonitoringModel) SetSize(width, height int) { + m.width = width + m.height = height +} + +// SetConfig updates the upstream URLs without triggering a refresh. +func (m *MonitoringModel) SetConfig(prometheusURL, grafanaURL string) { + m.cfg = monitoringSettings{ + PrometheusURL: strings.TrimSpace(prometheusURL), + GrafanaURL: strings.TrimSpace(grafanaURL), + } +} + +// Activate starts the refresh loop. +func (m *MonitoringModel) Activate() tea.Cmd { + m.active = true + m.loading = true + m.token++ + token := m.token + return tea.Batch(loadMonitoringCmd(token, m.cfg), monitoringTickCmd(token, monitoringRefreshInterval)) +} + +// Deactivate stops refresh ticks. +func (m *MonitoringModel) Deactivate() { + m.active = false + m.token++ +} + +// Update processes Bubble Tea messages for the monitoring view. +func (m MonitoringModel) Update(msg tea.Msg) (MonitoringModel, tea.Cmd) { + if m.role < tuiauth.RoleViewer { + return m, nil + } + switch msg := msg.(type) { + case monitoringLoadedMsg: + if msg.token != m.token { + return m, nil + } + m.loading = false + m.probe = msg.probe + m.lastErr = msg.err + case monitoringTickMsg: + if m.active && msg.token == m.token { + return m, tea.Batch(loadMonitoringCmd(msg.token, m.cfg), monitoringTickCmd(msg.token, monitoringRefreshInterval)) + } + case tea.KeyMsg: + if msg.String() == "r" { + m.loading = true + m.token++ + token := m.token + return m, tea.Batch(loadMonitoringCmd(token, m.cfg), monitoringTickCmd(token, monitoringRefreshInterval)) + } + } + return m, nil +} + +// View renders the monitoring panel. +func (m MonitoringModel) View() string { + if m.role < tuiauth.RoleViewer { + return mutedText("Monitoring view is available to Viewer and above") + } + if m.cfg.PrometheusURL == "" && m.cfg.GrafanaURL == "" { + return strings.Join([]string{ + "Monitoring", + "", + warnText("No monitoring URLs configured"), + "", + mutedText("Set prometheus_url and grafana_url under [monitoring] in ~/.config/concave-tui/concave-tui.toml."), + mutedText("The Flow suite ships Prometheus on :9090 and Grafana on :3000; the Forge suite makes them optional."), + }, "\n") + } + if m.loading && m.probe.Samples == nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGold)).Render("Loading monitoring…") + } + + lines := []string{ + "Monitoring [r] refresh", + "", + m.renderReachability(), + "", + } + lines = append(lines, "PromQL snapshot") + lines = append(lines, m.renderSamples()...) + if m.lastErr != nil { + lines = append(lines, "", errorText(m.lastErr.Error())) + } + return strings.Join(lines, "\n") +} + +// HelpView returns a short key legend for the help overlay. +func (m MonitoringModel) HelpView() string { + return "Monitoring\nr refresh" +} + +func (m MonitoringModel) renderReachability() string { + prom := formatReachability("Prometheus", m.cfg.PrometheusURL, m.probe.Reachability.Prometheus) + graf := formatReachability("Grafana", m.cfg.GrafanaURL, m.probe.Reachability.Grafana) + return strings.Join([]string{prom, graf}, "\n") +} + +func (m MonitoringModel) renderSamples() []string { + if m.probe.Samples == nil { + return []string{mutedText("No samples yet")} + } + lines := make([]string, 0, len(monitoringMetrics)) + for _, metric := range monitoringMetrics { + sample, ok := m.probe.Samples[metric.id] + switch { + case !ok: + lines = append(lines, fmt.Sprintf("%-20s %s", metric.label, mutedText("-"))) + case sample.Error != "": + lines = append(lines, fmt.Sprintf("%-20s %s", metric.label, warnText(sample.Error))) + default: + lines = append(lines, fmt.Sprintf("%-20s %s", metric.label, successText(formatMonitoringValue(metric.unit, sample.Value)))) + } + } + return lines +} + +func formatReachability(label, rawURL string, status monitoring.ServiceStatus) string { + if !status.Configured { + return fmt.Sprintf("%s: %s", label, mutedText("not configured")) + } + target := fallbackText(rawURL, "unknown") + if status.Reachable { + version := fallbackText(status.Version, "unknown version") + return fmt.Sprintf("%s: %s %s %s", label, successText("reachable"), mutedText(target), mutedText(version)) + } + reason := fallbackText(status.Error, "unreachable") + return fmt.Sprintf("%s: %s %s %s", label, warnText("unreachable"), mutedText(target), mutedText(reason)) +} + +func formatMonitoringValue(unit string, value float64) string { + switch unit { + case "percent": + return fmt.Sprintf("%.1f%%", value) + case "count": + return fmt.Sprintf("%.0f", value) + default: + return fmt.Sprintf("%.3f", value) + } +} + +var newMonitoringProberFn = func(cfg monitoringSettings) monitoringProber { + return monitoring.NewProber(cfg.PrometheusURL, cfg.GrafanaURL) +} + +type monitoringProber interface { + Probe(ctx context.Context) monitoring.Reachability + Query(ctx context.Context, expr string) monitoring.Sample +} + +func loadMonitoringCmd(token int, cfg monitoringSettings) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + prober := newMonitoringProberFn(cfg) + probe := monitoringProbe{ + Reachability: prober.Probe(ctx), + Samples: make(map[string]monitoring.Sample, len(monitoringMetrics)), + } + if probe.Reachability.Prometheus.Reachable { + for _, metric := range monitoringMetrics { + probe.Samples[metric.id] = prober.Query(ctx, metric.query) + } + } else { + for _, metric := range monitoringMetrics { + probe.Samples[metric.id] = monitoring.Sample{Query: metric.query, Error: "Prometheus unreachable"} + } + } + return monitoringLoadedMsg{token: token, probe: probe} + } +} + +func monitoringTickCmd(token int, delay time.Duration) tea.Cmd { + return tea.Tick(delay, func(time.Time) tea.Msg { return monitoringTickMsg{token: token} }) +} diff --git a/cmd/concave-tui/model/monitoring_test.go b/cmd/concave-tui/model/monitoring_test.go new file mode 100644 index 0000000..54afdc0 --- /dev/null +++ b/cmd/concave-tui/model/monitoring_test.go @@ -0,0 +1,119 @@ +package model + +import ( + "context" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + tuiauth "github.com/Gradient-Linux/concave-tui/internal/auth" + "github.com/Gradient-Linux/concave-tui/internal/monitoring" +) + +type stubMonitoringProber struct { + reachability monitoring.Reachability + samples map[string]monitoring.Sample +} + +func (s stubMonitoringProber) Probe(ctx context.Context) monitoring.Reachability { + return s.reachability +} + +func (s stubMonitoringProber) Query(ctx context.Context, expr string) monitoring.Sample { + if sample, ok := s.samples[expr]; ok { + return sample + } + return monitoring.Sample{Query: expr, Error: "no stub"} +} + +func TestMonitoringModel_NoURLsConfigured(t *testing.T) { + restoreModelDeps(t) + m := NewMonitoringModel() + m.SetRole(tuiauth.RoleViewer) + if view := m.View(); !strings.Contains(view, "No monitoring URLs configured") { + t.Fatalf("View() = %q", view) + } +} + +func TestMonitoringModel_RendersReachabilityAndSamples(t *testing.T) { + restoreModelDeps(t) + + reach := monitoring.Reachability{ + Prometheus: monitoring.ServiceStatus{Configured: true, Reachable: true, Version: "2.51.0"}, + Grafana: monitoring.ServiceStatus{Configured: true, Reachable: true, Version: "10.4.0"}, + } + samples := map[string]monitoring.Sample{} + for _, metric := range monitoringMetrics { + samples[metric.query] = monitoring.Sample{Query: metric.query, Value: 42} + } + newMonitoringProberFn = func(_ monitoringSettings) monitoringProber { + return stubMonitoringProber{reachability: reach, samples: samples} + } + + m := NewMonitoringModel() + m.SetRole(tuiauth.RoleViewer) + m.SetConfig("http://prom", "http://graf") + cmd := m.Activate() + if cmd == nil { + t.Fatal("Activate() returned nil cmd") + } + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("Activate cmd returned %T, want BatchMsg", msg) + } + var loaded monitoringLoadedMsg + for _, sub := range batch { + if sub == nil { + continue + } + if l, ok := sub().(monitoringLoadedMsg); ok { + loaded = l + break + } + } + if loaded.probe.Samples == nil { + t.Fatal("no loaded probe returned from activate") + } + model, _ := m.Update(loaded) + view := model.View() + for _, expected := range []string{"Prometheus:", "Grafana:", "reachable", "Scrape targets up", "CPU busy", "GPU utilisation"} { + if !strings.Contains(view, expected) { + t.Errorf("View() missing %q:\n%s", expected, view) + } + } +} + +func TestMonitoringModel_ShowsUnreachable(t *testing.T) { + restoreModelDeps(t) + + newMonitoringProberFn = func(_ monitoringSettings) monitoringProber { + return stubMonitoringProber{ + reachability: monitoring.Reachability{ + Prometheus: monitoring.ServiceStatus{Configured: true, Reachable: false, Error: "connection refused"}, + Grafana: monitoring.ServiceStatus{Configured: true, Reachable: false, Error: "connection refused"}, + }, + } + } + + m := NewMonitoringModel() + m.SetRole(tuiauth.RoleViewer) + m.SetConfig("http://prom", "http://graf") + cmd := m.Activate() + msg := cmd().(tea.BatchMsg) + var loaded monitoringLoadedMsg + for _, sub := range msg { + if l, ok := sub().(monitoringLoadedMsg); ok { + loaded = l + } + } + model, _ := m.Update(loaded) + view := model.View() + if !strings.Contains(view, "unreachable") || !strings.Contains(view, "connection refused") { + t.Fatalf("expected unreachable error in view, got:\n%s", view) + } + if !strings.Contains(view, "Prometheus unreachable") { + t.Fatalf("expected per-metric error, got:\n%s", view) + } +} diff --git a/cmd/concave-tui/model/root.go b/cmd/concave-tui/model/root.go index 7bac563..7a2f4c0 100644 --- a/cmd/concave-tui/model/root.go +++ b/cmd/concave-tui/model/root.go @@ -101,6 +101,7 @@ const ( ViewDoctor ViewEnvironment ViewFleet + ViewMonitoring ViewTeams ViewSystem ViewUsers @@ -127,6 +128,7 @@ type RootModel struct { doctor DoctorModel environment EnvironmentModel fleet FleetModel + monitoring MonitoringModel teams TeamsModel system SystemModel users UsersModel @@ -182,6 +184,7 @@ func NewRootModel(version string, cfgs ...any) *RootModel { doctor: NewDoctorModel(), environment: NewEnvironmentModel(), fleet: NewFleetModel(), + monitoring: NewMonitoringModel(), teams: NewTeamsModel(), system: NewSystemModel(), users: NewUsersModel(), @@ -225,6 +228,7 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showSettings = false m.settings.SetConfig(m.cfg) m.workspace.SetConfig(m.cfg) + m.monitoring.SetConfig(m.cfg.Monitoring.PrometheusURL, m.cfg.Monitoring.GrafanaURL) m.applyLayout() return m, saveTUIConfigCmd(m.cfg) case settingsDiscardedMsg: @@ -267,6 +271,8 @@ func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.environment, cmd = m.environment.Update(msg) case ViewFleet: m.fleet, cmd = m.fleet.Update(msg) + case ViewMonitoring: + m.monitoring, cmd = m.monitoring.Update(msg) case ViewTeams: m.teams, cmd = m.teams.Update(msg) case ViewSystem: @@ -306,6 +312,7 @@ func (m *RootModel) applySession(session tuiauth.Session) { m.doctor.SetRole(session.Role) m.environment.SetRole(session.Role) m.fleet.SetRole(session.Role) + m.monitoring.SetRole(session.Role) m.teams.SetRole(session.Role) m.system.SetRole(session.Role) m.users.SetRole(session.Role) @@ -434,6 +441,8 @@ func (m *RootModel) deactivateView(view View) { m.environment.Deactivate() case ViewFleet: m.fleet.Deactivate() + case ViewMonitoring: + m.monitoring.Deactivate() case ViewTeams: m.teams.Deactivate() case ViewSystem: @@ -457,6 +466,8 @@ func (m *RootModel) activateView(view View) tea.Cmd { return m.environment.Activate() case ViewFleet: return m.fleet.Activate() + case ViewMonitoring: + return m.monitoring.Activate() case ViewTeams: return m.teams.Activate() case ViewSystem: @@ -473,6 +484,7 @@ func (m *RootModel) applyConfig(cfg tuiconfig.Config) { m.sidebar = sidebarStateFromConfig(cfg) m.settings.SetConfig(cfg) m.workspace.SetConfig(cfg) + m.monitoring.SetConfig(cfg.Monitoring.PrometheusURL, cfg.Monitoring.GrafanaURL) } func (m *RootModel) applyLayout() { @@ -484,6 +496,7 @@ func (m *RootModel) applyLayout() { m.doctor.SetSize(contentWidth, contentHeight) m.environment.SetSize(contentWidth, contentHeight) m.fleet.SetSize(contentWidth, contentHeight) + m.monitoring.SetSize(contentWidth, contentHeight) m.teams.SetSize(contentWidth, contentHeight) m.system.SetSize(contentWidth, contentHeight) m.users.SetSize(contentWidth, contentHeight) @@ -572,6 +585,8 @@ func (m *RootModel) activeContent() string { return m.environment.View() case ViewFleet: return m.fleet.View() + case ViewMonitoring: + return m.monitoring.View() case ViewTeams: return m.teams.View() case ViewSystem: @@ -679,6 +694,10 @@ func (m *RootModel) activeHelpActions() []string { "r refresh fleet", "j / k move peer selection", } + case ViewMonitoring: + return []string{ + "r refresh monitoring", + } case ViewTeams: return []string{ "r refresh teams", @@ -829,7 +848,7 @@ func sidebarStateFromConfig(cfg tuiconfig.Config) SidebarState { } func (m RootModel) visibleViews() []View { - views := []View{ViewWorkspace, ViewSuites, ViewLogs, ViewDoctor, ViewEnvironment, ViewFleet} + views := []View{ViewWorkspace, ViewSuites, ViewLogs, ViewDoctor, ViewEnvironment, ViewFleet, ViewMonitoring} if m.session.Role >= tuiauth.RoleAdmin { views = append(views, ViewTeams, ViewSystem, ViewUsers) } @@ -883,6 +902,8 @@ func sidebarIcon(view View) string { return "EN" case ViewFleet: return "FL" + case ViewMonitoring: + return "MN" case ViewTeams: return "TM" case ViewSystem: @@ -908,6 +929,8 @@ func sidebarLabel(view View) string { return "Environment" case ViewFleet: return "Fleet" + case ViewMonitoring: + return "Monitoring" case ViewTeams: return "Teams" case ViewSystem: diff --git a/cmd/concave-tui/model/root_test.go b/cmd/concave-tui/model/root_test.go index 729feab..90ebd6c 100644 --- a/cmd/concave-tui/model/root_test.go +++ b/cmd/concave-tui/model/root_test.go @@ -39,6 +39,7 @@ func restoreModelDeps(t *testing.T) { oldAPILabURL := apiLabURLFn oldAPIChangelog := apiChangelogFn oldAPILogsDial := apiLogsDialFn + oldNewMonitoringProber := newMonitoringProberFn t.Cleanup(func() { saveTUIConfigFn = oldSaveTUIConfig @@ -67,6 +68,7 @@ func restoreModelDeps(t *testing.T) { apiLabURLFn = oldAPILabURL apiChangelogFn = oldAPIChangelog apiLogsDialFn = oldAPILogsDial + newMonitoringProberFn = oldNewMonitoringProber }) } @@ -107,7 +109,7 @@ func TestRootSwitchesViewsWhenAuthenticated(t *testing.T) { t.Fatalf("activeView = %v, want %v", root.activeView, ViewSuites) } - updated, _ = root.Update(keyRunes("8")) + updated, _ = root.Update(keyRunes("9")) root = updated.(*RootModel) if root.activeView != ViewSystem { t.Fatalf("activeView = %v, want %v", root.activeView, ViewSystem) @@ -175,7 +177,7 @@ func TestHelpOverlayShowsRoleFilteredActions(t *testing.T) { func TestAdminVisibleViewsIncludeSystemAndUsers(t *testing.T) { m := NewRootModel("dev", testConfig(), authSession(tuiauth.RoleAdmin)) views := m.visibleViews() - if len(views) != 9 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewTeams || views[7] != ViewSystem || views[8] != ViewUsers { + if len(views) != 10 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewMonitoring || views[7] != ViewTeams || views[8] != ViewSystem || views[9] != ViewUsers { t.Fatalf("visibleViews() = %#v", views) } } @@ -183,7 +185,7 @@ func TestAdminVisibleViewsIncludeSystemAndUsers(t *testing.T) { func TestViewerVisibleViewsIncludeMonitoringScreens(t *testing.T) { m := NewRootModel("dev", testConfig(), authSession(tuiauth.RoleViewer)) views := m.visibleViews() - if len(views) != 6 || views[4] != ViewEnvironment || views[5] != ViewFleet { + if len(views) != 7 || views[4] != ViewEnvironment || views[5] != ViewFleet || views[6] != ViewMonitoring { t.Fatalf("visibleViews() = %#v", views) } } diff --git a/internal/monitoring/monitoring.go b/internal/monitoring/monitoring.go new file mode 100644 index 0000000..d195dec --- /dev/null +++ b/internal/monitoring/monitoring.go @@ -0,0 +1,217 @@ +// Package monitoring provides lightweight probes of the Prometheus and Grafana +// instances shipped with the Flow and Forge suites. It is consumed by the +// Monitoring view in the Bubble Tea TUI. The TUI does not own monitoring +// business logic — these helpers only read from upstream HTTP endpoints. +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const defaultTimeout = 4 * time.Second + +// ServiceStatus represents the reachability of a single monitoring service. +type ServiceStatus struct { + Configured bool + Reachable bool + Version string + Error string +} + +// Reachability bundles the health of both Prometheus and Grafana. +type Reachability struct { + Prometheus ServiceStatus + Grafana ServiceStatus +} + +// Sample is a single PromQL instant-vector result reduced to a scalar. +type Sample struct { + Query string + Value float64 + Raw string + Error string +} + +// Prober issues reachability and PromQL probes against configured URLs. +type Prober struct { + client *http.Client + prometheusURL string + grafanaURL string +} + +// NewProber builds a prober with sensible defaults. Either URL may be empty, +// in which case the corresponding probes short-circuit with Configured=false. +func NewProber(prometheusURL, grafanaURL string) *Prober { + return &Prober{ + client: &http.Client{Timeout: defaultTimeout}, + prometheusURL: strings.TrimRight(strings.TrimSpace(prometheusURL), "/"), + grafanaURL: strings.TrimRight(strings.TrimSpace(grafanaURL), "/"), + } +} + +// PrometheusURL returns the configured Prometheus base URL (without trailing slash). +func (p *Prober) PrometheusURL() string { return p.prometheusURL } + +// GrafanaURL returns the configured Grafana base URL (without trailing slash). +func (p *Prober) GrafanaURL() string { return p.grafanaURL } + +// Probe performs reachability checks against Prometheus and Grafana in +// parallel. A nil ctx defaults to context.Background(). +func (p *Prober) Probe(ctx context.Context) Reachability { + if ctx == nil { + ctx = context.Background() + } + promCh := make(chan ServiceStatus, 1) + grafCh := make(chan ServiceStatus, 1) + go func() { promCh <- p.probePrometheus(ctx) }() + go func() { grafCh <- p.probeGrafana(ctx) }() + return Reachability{Prometheus: <-promCh, Grafana: <-grafCh} +} + +func (p *Prober) probePrometheus(ctx context.Context) ServiceStatus { + if p.prometheusURL == "" { + return ServiceStatus{Configured: false} + } + status := ServiceStatus{Configured: true} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.prometheusURL+"/-/healthy", nil) + if err != nil { + status.Error = err.Error() + return status + } + resp, err := p.client.Do(req) + if err != nil { + status.Error = err.Error() + return status + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + status.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return status + } + status.Reachable = true + + buildReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p.prometheusURL+"/api/v1/status/buildinfo", nil) + if err == nil { + if buildResp, err := p.client.Do(buildReq); err == nil { + defer buildResp.Body.Close() + if buildResp.StatusCode < 400 { + var payload struct { + Data struct { + Version string `json:"version"` + } `json:"data"` + } + _ = json.NewDecoder(buildResp.Body).Decode(&payload) + status.Version = payload.Data.Version + } + } + } + return status +} + +func (p *Prober) probeGrafana(ctx context.Context) ServiceStatus { + if p.grafanaURL == "" { + return ServiceStatus{Configured: false} + } + status := ServiceStatus{Configured: true} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.grafanaURL+"/api/health", nil) + if err != nil { + status.Error = err.Error() + return status + } + resp, err := p.client.Do(req) + if err != nil { + status.Error = err.Error() + return status + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + status.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return status + } + var payload struct { + Database string `json:"database"` + Version string `json:"version"` + } + _ = json.NewDecoder(resp.Body).Decode(&payload) + status.Reachable = true + status.Version = payload.Version + return status +} + +// Query issues a Prometheus instant query and returns the first scalar-valued +// sample in the vector result. Errors are reported on the Sample.Error field +// so call sites can render them inline. +func (p *Prober) Query(ctx context.Context, expr string) Sample { + sample := Sample{Query: expr} + if p.prometheusURL == "" { + sample.Error = "Prometheus not configured" + return sample + } + if ctx == nil { + ctx = context.Background() + } + endpoint := p.prometheusURL + "/api/v1/query?query=" + url.QueryEscape(expr) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + sample.Error = err.Error() + return sample + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + sample.Error = err.Error() + return sample + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + sample.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return sample + } + var payload struct { + Status string `json:"status"` + Error string `json:"error"` + ErrorType string `json:"errorType"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Value [2]any `json:"value"` + } `json:"result"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + sample.Error = "decode: " + err.Error() + return sample + } + if payload.Status == "error" { + if payload.Error != "" { + sample.Error = payload.Error + } else { + sample.Error = payload.ErrorType + } + return sample + } + if len(payload.Data.Result) == 0 { + sample.Error = "no samples" + return sample + } + raw, ok := payload.Data.Result[0].Value[1].(string) + if !ok { + sample.Error = "unexpected value shape" + return sample + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + sample.Error = "parse: " + err.Error() + return sample + } + sample.Value = value + sample.Raw = raw + return sample +} diff --git a/internal/monitoring/monitoring_test.go b/internal/monitoring/monitoring_test.go new file mode 100644 index 0000000..4ccd073 --- /dev/null +++ b/internal/monitoring/monitoring_test.go @@ -0,0 +1,97 @@ +package monitoring + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProberShortCircuitsWhenURLIsEmpty(t *testing.T) { + p := NewProber("", "") + r := p.Probe(context.Background()) + if r.Prometheus.Configured || r.Grafana.Configured { + t.Fatalf("Probe() configured = %+v, want both false", r) + } + if r.Prometheus.Reachable || r.Grafana.Reachable { + t.Fatalf("Probe() reachable = %+v, want both false", r) + } +} + +func TestProberReachabilityAndVersions(t *testing.T) { + prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/-/healthy": + _, _ = w.Write([]byte("Prometheus is healthy.\n")) + case "/api/v1/status/buildinfo": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"status":"success","data":{"version":"2.51.0"}}`) + default: + http.NotFound(w, r) + } + })) + defer prom.Close() + + graf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"database":"ok","version":"10.4.0"}`) + })) + defer graf.Close() + + p := NewProber(prom.URL, graf.URL) + r := p.Probe(context.Background()) + if !r.Prometheus.Reachable || r.Prometheus.Version != "2.51.0" { + t.Fatalf("Prometheus = %+v, want reachable 2.51.0", r.Prometheus) + } + if !r.Grafana.Reachable || r.Grafana.Version != "10.4.0" { + t.Fatalf("Grafana = %+v, want reachable 10.4.0", r.Grafana) + } +} + +func TestProberQueryReturnsFirstScalar(t *testing.T) { + prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/query" { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("query") != "up" { + t.Errorf("query = %q, want up", r.URL.Query().Get("query")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[1711376000,"3"]}]}}`) + })) + defer prom.Close() + + sample := NewProber(prom.URL, "").Query(context.Background(), "up") + if sample.Error != "" { + t.Fatalf("Query() error = %q", sample.Error) + } + if sample.Value != 3 { + t.Fatalf("Query() value = %v, want 3", sample.Value) + } +} + +func TestProberQueryReportsPrometheusErrors(t *testing.T) { + prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"status":"error","errorType":"bad_data","error":"boom"}`) + })) + defer prom.Close() + + sample := NewProber(prom.URL, "").Query(context.Background(), "broken") + if sample.Error != "boom" { + t.Fatalf("Query() error = %q, want boom", sample.Error) + } +} + +func TestProberQueryShortCircuitsWhenURLMissing(t *testing.T) { + sample := NewProber("", "").Query(context.Background(), "up") + if sample.Error == "" { + t.Fatal("Query() expected error, got none") + } +}