+ Leave Prometheus or Grafana blank to disable the corresponding
+ /monitoring/* proxy route on the next restart.
+
Save
{{ saved }}
diff --git a/frontend/test/monitoring.integration.test.ts b/frontend/test/monitoring.integration.test.ts
new file mode 100644
index 0000000..f749be4
--- /dev/null
+++ b/frontend/test/monitoring.integration.test.ts
@@ -0,0 +1,151 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createMemoryHistory, createRouter } from 'vue-router'
+
+import MonitoringView from '../src/views/MonitoringView.vue'
+import { detectMonitoringSuites, firstVectorValue } from '../src/lib/monitoring'
+import type { PromQLInstantResponse, SuiteSummary } from '../src/types'
+
+const integration = process.env.CONCAVE_INTEGRATION === '1' ? describe : describe.skip
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+
+const routerStub = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: '
' } },
+ { path: '/suites', component: { template: '
' } },
+ { path: '/monitoring', component: MonitoringView },
+ ],
+})
+
+describe('monitoring helpers', () => {
+ it('firstVectorValue returns the numeric value of a scalar-like vector sample', () => {
+ const payload: PromQLInstantResponse = {
+ status: 'success',
+ data: {
+ resultType: 'vector',
+ result: [{ metric: {}, value: [1711376000, '42.5'] }],
+ },
+ }
+ expect(firstVectorValue(payload)).toBe(42.5)
+ })
+
+ it('firstVectorValue returns null when Prometheus returned an error', () => {
+ const payload: PromQLInstantResponse = { status: 'error', error: 'boom' }
+ expect(firstVectorValue(payload)).toBeNull()
+ })
+
+ it('firstVectorValue returns null when the result set is empty', () => {
+ const payload: PromQLInstantResponse = {
+ status: 'success',
+ data: { resultType: 'vector', result: [] },
+ }
+ expect(firstVectorValue(payload)).toBeNull()
+ })
+
+ it('detectMonitoringSuites surfaces suites that contain prometheus or grafana containers', () => {
+ const suites: SuiteSummary[] = [
+ {
+ name: 'boosting',
+ installed: true,
+ state: 'running',
+ ports: [],
+ containers: [{ name: 'gradient-boost-core', image: 'python:3.12-slim', role: 'Core ML stack', status: 'running' }],
+ gpu_required: false,
+ compose_exists: true,
+ },
+ {
+ name: 'flow',
+ installed: true,
+ state: 'running',
+ ports: [],
+ containers: [
+ { name: 'gradient-flow-prometheus', image: 'prom/prometheus:v2.51.0', role: 'Metrics', status: 'running' },
+ { name: 'gradient-flow-grafana', image: 'grafana/grafana:10.4.0', role: 'Dashboards', status: 'running' },
+ ],
+ gpu_required: false,
+ compose_exists: true,
+ },
+ ]
+ const hints = detectMonitoringSuites(suites)
+ expect(hints).toHaveLength(1)
+ expect(hints[0].suite).toBe('flow')
+ expect(hints[0].containers).toHaveLength(2)
+ })
+})
+
+integration('MonitoringView', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn())
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('renders reachability cards and live PromQL values', async () => {
+ const fetchMock = vi.mocked(fetch)
+ fetchMock.mockImplementation(async (input) => {
+ const url = typeof input === 'string' ? input : input.toString()
+ if (url.endsWith('/monitoring/prometheus/-/healthy')) {
+ return new Response('Prometheus is healthy.', { status: 200 })
+ }
+ if (url.endsWith('/monitoring/prometheus/api/v1/status/buildinfo')) {
+ return json({ status: 'success', data: { version: '2.51.0' } })
+ }
+ if (url.endsWith('/monitoring/grafana/api/health')) {
+ return json({ database: 'ok', version: '10.4.0' })
+ }
+ if (url.includes('/monitoring/prometheus/api/v1/query')) {
+ return json({
+ status: 'success',
+ data: {
+ resultType: 'vector',
+ result: [{ metric: {}, value: [Date.now() / 1000, '3'] }],
+ },
+ })
+ }
+ if (url.endsWith('/api/v1/suites')) {
+ return json({
+ suites: [
+ {
+ name: 'flow',
+ installed: true,
+ state: 'running',
+ ports: [],
+ containers: [
+ { name: 'gradient-flow-prometheus', image: 'prom/prometheus:v2.51.0', role: 'Metrics', status: 'running' },
+ { name: 'gradient-flow-grafana', image: 'grafana/grafana:10.4.0', role: 'Dashboards', status: 'running' },
+ ],
+ gpu_required: false,
+ compose_exists: true,
+ },
+ ],
+ })
+ }
+ throw new Error(`unexpected fetch ${url}`)
+ })
+
+ await routerStub.push('/monitoring')
+ const wrapper = mount(MonitoringView, {
+ global: { plugins: [routerStub] },
+ })
+
+ await flushPromises()
+ // Give the card-refresh Promise.all a tick
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Monitoring')
+ expect(wrapper.text()).toContain('Prometheus')
+ expect(wrapper.text()).toContain('Grafana')
+ expect(wrapper.text()).toContain('flow')
+ expect(wrapper.text()).toContain('gradient-flow-prometheus')
+ expect(wrapper.html()).toContain('reachable')
+ expect(fetchMock).toHaveBeenCalledWith('/api/v1/suites', expect.any(Object))
+ })
+})
diff --git a/http.go b/http.go
index cab8d14..0c2acab 100644
--- a/http.go
+++ b/http.go
@@ -29,6 +29,8 @@ func handleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "api_base_url, bind_addr, and port are required", http.StatusBadRequest)
return
}
+ // Prometheus and Grafana URLs are optional. When empty, the associated
+ // /monitoring/* proxy route is simply not mounted on the next start.
if err := config.Save(cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
diff --git a/internal/config/config.go b/internal/config/config.go
index 12bbbe3..d3d8064 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -10,16 +10,20 @@ import (
)
type Config struct {
- APIBaseURL string
- BindAddr string
- Port int
+ APIBaseURL string `json:"api_base_url"`
+ BindAddr string `json:"bind_addr"`
+ Port int `json:"port"`
+ PrometheusURL string `json:"prometheus_url"`
+ GrafanaURL string `json:"grafana_url"`
}
func Default() Config {
return Config{
- APIBaseURL: "http://127.0.0.1:7777",
- BindAddr: "127.0.0.1",
- Port: 8080,
+ APIBaseURL: "http://127.0.0.1:7777",
+ BindAddr: "127.0.0.1",
+ Port: 8080,
+ PrometheusURL: "http://127.0.0.1:9090",
+ GrafanaURL: "http://127.0.0.1:3000",
}
}
@@ -66,6 +70,10 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("invalid port %q", value)
}
cfg.Port = port
+ case "prometheus_url":
+ cfg.PrometheusURL = value
+ case "grafana_url":
+ cfg.GrafanaURL = value
}
}
return cfg, nil
@@ -80,6 +88,8 @@ func Save(cfg Config) error {
fmt.Fprintf(&buf, "api_base_url = %q\n", cfg.APIBaseURL)
fmt.Fprintf(&buf, "bind_addr = %q\n", cfg.BindAddr)
fmt.Fprintf(&buf, "port = %d\n", cfg.Port)
+ fmt.Fprintf(&buf, "prometheus_url = %q\n", cfg.PrometheusURL)
+ fmt.Fprintf(&buf, "grafana_url = %q\n", cfg.GrafanaURL)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 39b70a8..1365381 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,7 +1,9 @@
package config
import (
+ "encoding/json"
"os"
+ "strings"
"testing"
)
@@ -15,6 +17,12 @@ func TestLoadWritesDefaultsWhenMissing(t *testing.T) {
if cfg.APIBaseURL != "http://127.0.0.1:7777" || cfg.BindAddr != "127.0.0.1" || cfg.Port != 8080 {
t.Fatalf("Load() = %#v, want defaults", cfg)
}
+ if cfg.PrometheusURL != "http://127.0.0.1:9090" {
+ t.Fatalf("Load() prometheus = %q, want default", cfg.PrometheusURL)
+ }
+ if cfg.GrafanaURL != "http://127.0.0.1:3000" {
+ t.Fatalf("Load() grafana = %q, want default", cfg.GrafanaURL)
+ }
if _, err := os.Stat(Path()); err != nil {
t.Fatalf("Stat(%s) error = %v", Path(), err)
}
@@ -24,9 +32,11 @@ func TestSaveRoundTrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
want := Config{
- APIBaseURL: "http://127.0.0.1:9999",
- BindAddr: "0.0.0.0",
- Port: 9090,
+ APIBaseURL: "http://127.0.0.1:9999",
+ BindAddr: "0.0.0.0",
+ Port: 9090,
+ PrometheusURL: "http://127.0.0.1:19090",
+ GrafanaURL: "http://127.0.0.1:13000",
}
if err := Save(want); err != nil {
t.Fatalf("Save() error = %v", err)
@@ -39,3 +49,52 @@ func TestSaveRoundTrip(t *testing.T) {
t.Fatalf("Load() = %#v, want %#v", got, want)
}
}
+
+func TestSaveOptionalMonitoringURLsMayBeEmpty(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ want := Config{
+ APIBaseURL: "http://127.0.0.1:7777",
+ BindAddr: "127.0.0.1",
+ Port: 8080,
+ PrometheusURL: "",
+ GrafanaURL: "",
+ }
+ if err := Save(want); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ got, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got != want {
+ t.Fatalf("Load() = %#v, want %#v", got, want)
+ }
+}
+
+func TestJSONMarshalUsesSnakeCase(t *testing.T) {
+ cfg := Config{
+ APIBaseURL: "http://127.0.0.1:7777",
+ BindAddr: "127.0.0.1",
+ Port: 8080,
+ PrometheusURL: "http://127.0.0.1:9090",
+ GrafanaURL: "http://127.0.0.1:3000",
+ }
+ raw, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("Marshal() error = %v", err)
+ }
+ expected := []string{
+ `"api_base_url"`,
+ `"bind_addr"`,
+ `"port"`,
+ `"prometheus_url"`,
+ `"grafana_url"`,
+ }
+ body := string(raw)
+ for _, key := range expected {
+ if !strings.Contains(body, key) {
+ t.Fatalf("Marshal() = %s, want key %s", body, key)
+ }
+ }
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 1518085..10bb580 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -4,8 +4,12 @@ import (
"net/http"
"net/http/httputil"
"net/url"
+ "strings"
)
+// New returns a reverse proxy that forwards requests to rawTarget verbatim.
+// Cookies, SSE, and WebSocket upgrade headers are preserved by the default
+// httputil.ReverseProxy behavior.
func New(rawTarget string) (http.Handler, error) {
target, err := url.Parse(rawTarget)
if err != nil {
@@ -19,3 +23,39 @@ func New(rawTarget string) (http.Handler, error) {
}
return proxy, nil
}
+
+// NewPath returns a reverse proxy that strips stripPrefix from the incoming
+// request path before forwarding to rawTarget. Useful for mounting an upstream
+// service under a same-origin subpath (for example Prometheus at
+// `/monitoring/prometheus/*`).
+//
+// A trailing slash in stripPrefix is optional. The remaining path is always
+// forwarded with a leading slash so the upstream sees its own root correctly.
+func NewPath(rawTarget, stripPrefix string) (http.Handler, error) {
+ target, err := url.Parse(rawTarget)
+ if err != nil {
+ return nil, err
+ }
+ prefix := strings.TrimRight(stripPrefix, "/")
+ proxy := httputil.NewSingleHostReverseProxy(target)
+ originalDirector := proxy.Director
+ proxy.Director = func(req *http.Request) {
+ remainder := strings.TrimPrefix(req.URL.Path, prefix)
+ if !strings.HasPrefix(remainder, "/") {
+ remainder = "/" + remainder
+ }
+ req.URL.Path = remainder
+ // RawPath carries the un-decoded path when set; keep it consistent so
+ // percent-encoded characters in upstream URLs survive proxying.
+ if req.URL.RawPath != "" {
+ rawRemainder := strings.TrimPrefix(req.URL.RawPath, prefix)
+ if !strings.HasPrefix(rawRemainder, "/") {
+ rawRemainder = "/" + rawRemainder
+ }
+ req.URL.RawPath = rawRemainder
+ }
+ originalDirector(req)
+ req.Host = target.Host
+ }
+ return proxy, nil
+}
diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go
index 6a28a9d..4c94c49 100644
--- a/internal/proxy/proxy_test.go
+++ b/internal/proxy/proxy_test.go
@@ -37,3 +37,61 @@ func TestProxyForwardsRequest(t *testing.T) {
t.Fatalf("body = %q, want backend payload", body)
}
}
+
+func TestNewPathStripsPrefixBeforeForwarding(t *testing.T) {
+ var seenPath, seenQuery string
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenPath = r.URL.Path
+ seenQuery = r.URL.RawQuery
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"status":"success","data":{"result":[]}}`)
+ }))
+ defer backend.Close()
+
+ handler, err := NewPath(backend.URL, "/monitoring/prometheus")
+ if err != nil {
+ t.Fatalf("NewPath() error = %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/monitoring/prometheus/api/v1/query?query=up", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ if seenPath != "/api/v1/query" {
+ t.Fatalf("upstream path = %q, want /api/v1/query", seenPath)
+ }
+ if seenQuery != "query=up" {
+ t.Fatalf("upstream query = %q, want query=up", seenQuery)
+ }
+}
+
+func TestNewPathRootRequest(t *testing.T) {
+ var seenPath string
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenPath = r.URL.Path
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer backend.Close()
+
+ handler, err := NewPath(backend.URL, "/monitoring/grafana")
+ if err != nil {
+ t.Fatalf("NewPath() error = %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/monitoring/grafana/", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if seenPath != "/" {
+ t.Fatalf("upstream path = %q, want /", seenPath)
+ }
+}
+
+func TestNewPathRejectsInvalidTarget(t *testing.T) {
+ if _, err := NewPath("://bad-url", "/monitoring/prometheus"); err == nil {
+ t.Fatal("NewPath() expected error for invalid target, got nil")
+ }
+}
diff --git a/main.go b/main.go
index efc9bd1..2965a71 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,22 @@ func serve() error {
spa := spaHandler(dist)
mux.Handle("/api/v1/", apiProxy)
+
+ if cfg.PrometheusURL != "" {
+ promProxy, err := proxy.NewPath(cfg.PrometheusURL, "/monitoring/prometheus")
+ if err != nil {
+ return fmt.Errorf("prometheus proxy: %w", err)
+ }
+ mux.Handle("/monitoring/prometheus/", promProxy)
+ }
+ if cfg.GrafanaURL != "" {
+ grafProxy, err := proxy.NewPath(cfg.GrafanaURL, "/monitoring/grafana")
+ if err != nil {
+ return fmt.Errorf("grafana proxy: %w", err)
+ }
+ mux.Handle("/monitoring/grafana/", grafProxy)
+ }
+
mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && wantsHTML(r) {
spa.ServeHTTP(w, r)