diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index a381ffc..3def00e 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -145,5 +145,13 @@ defineProps<{ + diff --git a/frontend/src/components/layout/AppShell.vue b/frontend/src/components/layout/AppShell.vue index ea14afb..6678f6d 100644 --- a/frontend/src/components/layout/AppShell.vue +++ b/frontend/src/components/layout/AppShell.vue @@ -27,6 +27,7 @@ const items: NavItem[] = [ { label: 'Dashboard', to: '/dashboard', minRole: 'viewer', icon: 'dashboard' }, { label: 'Environment', to: '/environment', minRole: 'viewer', icon: 'environment' }, { label: 'Fleet', to: '/fleet', minRole: 'viewer', icon: 'fleet' }, + { label: 'Monitoring', to: '/monitoring', minRole: 'viewer', icon: 'monitoring' }, { label: 'Suites', to: '/suites', minRole: 'viewer', icon: 'suites' }, { label: 'Logs', to: '/logs', minRole: 'viewer', icon: 'logs' }, { label: 'Workspace', to: '/workspace', minRole: 'viewer', icon: 'workspace' }, diff --git a/frontend/src/lib/monitoring.ts b/frontend/src/lib/monitoring.ts new file mode 100644 index 0000000..0189763 --- /dev/null +++ b/frontend/src/lib/monitoring.ts @@ -0,0 +1,115 @@ +import type { + MonitoringReachability, + MonitoringSuiteHint, + PromQLInstantResponse, + PromQLVectorSample, + SuiteSummary, +} from '../types' + +export const PROMETHEUS_BASE = '/monitoring/prometheus' +export const GRAFANA_BASE = '/monitoring/grafana' + +const DEFAULT_TIMEOUT_MS = 4000 + +function fetchWithTimeout(path: string, timeoutMs = DEFAULT_TIMEOUT_MS, init: RequestInit = {}): Promise { + const controller = new AbortController() + const timer = window.setTimeout(() => controller.abort(), timeoutMs) + return fetch(path, { + credentials: 'include', + ...init, + signal: controller.signal, + }).finally(() => { + window.clearTimeout(timer) + }) +} + +export async function probeMonitoring(): Promise { + const [prom, graf] = await Promise.all([probePrometheus(), probeGrafana()]) + return { prometheus: prom, grafana: graf } +} + +async function probePrometheus(): Promise { + try { + const response = await fetchWithTimeout(`${PROMETHEUS_BASE}/-/healthy`) + if (!response.ok) { + return { reachable: false, error: `HTTP ${response.status}` } + } + let version: string | undefined + try { + const build = await fetchWithTimeout(`${PROMETHEUS_BASE}/api/v1/status/buildinfo`) + if (build.ok) { + const payload = (await build.json()) as { data?: { version?: string } } + version = payload.data?.version + } + } catch { + // buildinfo is best-effort; reachability was already confirmed + } + return { reachable: true, version } + } catch (err) { + return { reachable: false, error: err instanceof Error ? err.message : 'unreachable' } + } +} + +async function probeGrafana(): Promise { + try { + const response = await fetchWithTimeout(`${GRAFANA_BASE}/api/health`) + if (!response.ok) { + return { reachable: false, error: `HTTP ${response.status}` } + } + const payload = (await response.json()) as { version?: string; database?: string } + return { reachable: true, version: payload.version } + } catch (err) { + return { reachable: false, error: err instanceof Error ? err.message : 'unreachable' } + } +} + +export async function promQuery(expr: string): Promise { + const response = await fetchWithTimeout( + `${PROMETHEUS_BASE}/api/v1/query?query=${encodeURIComponent(expr)}`, + DEFAULT_TIMEOUT_MS, + { headers: { Accept: 'application/json' } }, + ) + if (!response.ok) { + return { status: 'error', errorType: 'http', error: `HTTP ${response.status}` } + } + return (await response.json()) as PromQLInstantResponse +} + +export function firstVectorValue(payload: PromQLInstantResponse): number | null { + if (payload.status !== 'success' || !payload.data) { + return null + } + if (payload.data.resultType !== 'vector') { + return null + } + const samples = payload.data.result as PromQLVectorSample[] + if (!samples.length) { + return null + } + const raw = samples[0].value[1] + const value = Number(raw) + return Number.isFinite(value) ? value : null +} + +export function detectMonitoringSuites(suites: SuiteSummary[]): MonitoringSuiteHint[] { + const hints: MonitoringSuiteHint[] = [] + for (const suite of suites) { + const relevant = (suite.containers ?? []).filter((container) => { + const name = container.name.toLowerCase() + return name.includes('prometheus') || name.includes('grafana') + }) + if (relevant.length === 0) { + continue + } + hints.push({ + suite: suite.name, + state: suite.state, + containers: relevant.map((container) => ({ + name: container.name, + role: container.role, + status: container.status, + })), + }) + } + return hints +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ac56a9b..1823699 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,6 +11,7 @@ import CheckView from '../views/CheckView.vue' import LabView from '../views/LabView.vue' import LoginView from '../views/LoginView.vue' import LogsView from '../views/LogsView.vue' +import MonitoringView from '../views/MonitoringView.vue' import SettingsView from '../views/SettingsView.vue' import SuitesView from '../views/SuitesView.vue' import SystemView from '../views/SystemView.vue' @@ -40,6 +41,7 @@ export const router = createRouter({ { path: '/check', component: CheckView, meta: { minRole: 'viewer', title: 'Check' } }, { path: '/doctor', redirect: '/check' }, { path: '/lab', component: LabView, meta: { minRole: 'viewer', title: 'Gradient Lab' } }, + { path: '/monitoring', component: MonitoringView, meta: { minRole: 'viewer', title: 'Monitoring' } }, { path: '/teams', component: TeamsView, meta: { minRole: 'admin', title: 'Teams' } }, { path: '/users', component: UsersView, meta: { minRole: 'admin', title: 'Users' } }, { path: '/system', component: SystemView, meta: { minRole: 'admin', title: 'System' } }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fcee340..ee93330 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -214,4 +214,51 @@ export interface WebSettings { api_base_url: string bind_addr: string port: number + prometheus_url: string + grafana_url: string +} + +export type PromQLResultType = 'vector' | 'scalar' | 'matrix' | 'string' + +export interface PromQLVectorSample { + metric: Record + value: [number, string] +} + +export interface PromQLMatrixSample { + metric: Record + values: Array<[number, string]> +} + +export interface PromQLInstantResponse { + status: 'success' | 'error' + errorType?: string + error?: string + data?: { + resultType: PromQLResultType + result: PromQLVectorSample[] | PromQLMatrixSample[] | [number, string] + } +} + +export interface MonitoringReachability { + prometheus: { + reachable: boolean + version?: string + error?: string + } + grafana: { + reachable: boolean + version?: string + error?: string + } +} + +export interface MonitoringSuiteHint { + suite: string + state: string + containers: Array<{ + name: string + role: string + status: string + }> } diff --git a/frontend/src/views/MonitoringView.vue b/frontend/src/views/MonitoringView.vue new file mode 100644 index 0000000..465acca --- /dev/null +++ b/frontend/src/views/MonitoringView.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index bb4e05a..6145ae0 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -11,6 +11,8 @@ const form = reactive({ api_base_url: '', bind_addr: '', port: 8080, + prometheus_url: '', + grafana_url: '', }) const error = ref('') @@ -76,6 +78,19 @@ onMounted(load) Port +
+ + +

+ Leave Prometheus or Grafana blank to disable the corresponding + /monitoring/* proxy route on the next restart. +

{{ 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)