From bdbefd831d9a4574a00fa03555647986cebb3be3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 14:46:25 -0700 Subject: [PATCH] Metrics: connect Prometheus or an OpenTelemetry service in words The metrics pages open with where the server's numbers go. Prometheus: a switch, a user name and a password made in the browser (shown once), then the scrape address (/metrics/prometheus on the server's web address), a scrape job to paste into prometheus.yml, and a check that the endpoint answers. Without a user name it warns that anyone who can reach the server can read the numbers. OpenTelemetry: Honeycomb (key and dataset as headers), Grafana Cloud (OTLP endpoint, instance ID and token as Basic auth, with a note that counters go out as deltas), or your own collector over HTTP or gRPC. An HTTP endpoint in the settings is used exactly as given, so /v1/metrics is added to a bare collector address. A saved Honeycomb key is kept when the field is left empty; Grafana's token, which the server never sends back, is asked for again when its settings change. The page's own settings (which metrics) are folded underneath. --- src/components/layout/MainContent.tsx | 5 + .../metricsConnect/MetricsConnect.tsx | 384 ++++++++++++++++++ src/features/metricsConnect/MetricsPage.tsx | 32 ++ src/features/metricsConnect/connect.test.ts | 70 ++++ src/features/metricsConnect/connect.ts | 111 +++++ 5 files changed, 602 insertions(+) create mode 100644 src/features/metricsConnect/MetricsConnect.tsx create mode 100644 src/features/metricsConnect/MetricsPage.tsx create mode 100644 src/features/metricsConnect/connect.test.ts create mode 100644 src/features/metricsConnect/connect.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 5774400..d1cdd3d 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { MetricsPage } from '@/features/metricsConnect/MetricsPage'; import { AlertsPage } from '@/features/alerts/AlertsPage'; import { ReportsPage } from '@/features/reports/ReportsPage'; import { RetentionPage } from '@/features/retention/RetentionPage'; @@ -269,6 +270,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti ) { return ; } + // inbuxa: the metrics pages open with where the numbers go (settings-reorg). + if (resolved.objectName === 'x:Metrics') { + return ; + } if (resolved.objectName === 'x:Security') { return (
diff --git a/src/features/metricsConnect/MetricsConnect.tsx b/src/features/metricsConnect/MetricsConnect.tsx new file mode 100644 index 0000000..8ee6d10 --- /dev/null +++ b/src/features/metricsConnect/MetricsConnect.tsx @@ -0,0 +1,384 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Monitoring › Metrics, where the server's numbers go + * (settings-reorg, second wave): Prometheus scraping, with the address and + * a scrape job to paste, and pushing to an OpenTelemetry endpoint, with + * Honeycomb, Grafana Cloud or your own collector filled in. + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy, Loader2, Radio } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { getApiBaseUrl } from '@/services/api'; +import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { toast } from '@/hooks/use-toast'; +import { + newPassword, + otelValue, + providerOf, + scrapeConfig, + scrapeUrl, + type OtelInput, + type OtelProvider, +} from './connect'; + +const OBJECT = 'x:Metrics'; + +function CopyBlock({ text }: { text: string }) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + return ( +
+
{text}
+ +
+ ); +} + +export function MetricsConnect({ onSaved }: { onSaved?: () => void }) { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysMetrics', 'Update')); + const base = getApiBaseUrl() || window.location.origin; + const [loaded, setLoaded] = useState(false); + const [promOn, setPromOn] = useState(false); + const [promUser, setPromUser] = useState('prometheus'); + const [promPassword, setPromPassword] = useState(null); + // The new password stays on screen for copying after it's saved. + const [passwordSaved, setPasswordSaved] = useState(false); + const [promSaved, setPromSaved] = useState<{ on: boolean; user: string } | null>(null); + const [provider, setProvider] = useState('off'); + const [savedProvider, setSavedProvider] = useState('off'); + const [otel, setOtel] = useState({ endpoint: '', key: '', extra: '' }); + const [otelDirty, setOtelDirty] = useState(false); + // Honeycomb's key travels as a plain header the server returns, so an empty field can keep it. + const [savedHcKey, setSavedHcKey] = useState(''); + const [busy, setBusy] = useState(false); + const [check, setCheck] = useState(null); + + useEffect(() => { + let live = true; + jmapGet(OBJECT, getAccountId(OBJECT), ['singleton'], ['prometheus', 'openTelemetry']) + .then(([res]) => { + const data = ((res?.[1] as { list?: Record[] })?.list ?? [])[0] ?? {}; + if (!live) return; + const prom = (data.prometheus ?? {}) as Record; + const on = prom['@type'] === 'Enabled'; + const user = (prom.authUsername as string | null) ?? ''; + setPromOn(on); + if (on) setPromUser(user); + setPromSaved({ on, user }); + const o = (data.openTelemetry ?? {}) as Record; + const p = providerOf(o); + setProvider(p); + setSavedProvider(p); + const headers = (o.httpHeaders ?? {}) as Record; + const auth = (o.httpAuth ?? {}) as Record; + setSavedHcKey(p === 'honeycomb' ? (headers['x-honeycomb-team'] ?? '') : ''); + setOtel({ + endpoint: String(o.endpoint ?? ''), + key: '', + extra: p === 'honeycomb' ? (headers['x-honeycomb-dataset'] ?? '') : String(auth.username ?? ''), + }); + setLoaded(true); + }) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + if (!loaded || !promSaved) return null; + + const newPasswordPending = promPassword !== null && !passwordSaved; + const promChanged = promOn !== promSaved.on || (promOn && (promUser !== promSaved.user || newPasswordPending)); + const otelChanged = provider !== savedProvider || otelDirty; + // Grafana's token is a secret the server never hands back, so any change there asks for it again. + const needsKey = + (provider === 'honeycomb' && !(savedProvider === 'honeycomb' && savedHcKey)) || + (provider === 'grafana' && (provider !== savedProvider || otelDirty)); + const otelValid = + provider === 'off' || + ((provider === 'honeycomb' || /^https?:\/\//.test(otel.endpoint.trim())) && (!needsKey || otel.key.trim() !== '')); + const promValid = !promOn || promUser.trim() === '' || promSaved.user === promUser.trim() || newPasswordPending; + + const save = async () => { + setBusy(true); + try { + const patch: Record = {}; + if (promChanged) { + patch.prometheus = promOn + ? { + '@type': 'Enabled', + authUsername: promUser.trim() || null, + ...(promUser.trim() === '' + ? { authSecret: { '@type': 'None' } } + : newPasswordPending + ? { authSecret: { '@type': 'Value', secret: promPassword } } + : {}), + } + : { '@type': 'Disabled' }; + } + if (otelChanged) { + const key = provider === 'honeycomb' && !otel.key.trim() ? savedHcKey : otel.key; + patch.openTelemetry = otelValue(provider, { ...otel, key }); + } + const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { update: { singleton: patch } }); + const body = res?.[1] as unknown as JmapSetResponse | undefined; + const bad = body?.notUpdated?.singleton; + if (bad) throw new Error(bad.description ?? bad.type); + setPromSaved({ on: promOn, user: promUser.trim() }); + if (promPassword !== null) setPasswordSaved(true); + setSavedProvider(provider); + setOtelDirty(false); + onSaved?.(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('metricsConnect.failed', 'Not saved'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + const tryScrape = async () => { + setCheck(t('metricsConnect.checking', 'Checking…')); + try { + const headers: Record = {}; + if (promUser && promPassword) headers.Authorization = `Basic ${btoa(`${promUser}:${promPassword}`)}`; + const res = await fetch(scrapeUrl(base), { headers, cache: 'no-store' }); + if (res.status === 401) { + setCheck(t('metricsConnect.needsLogin', 'It answers, and asks for the user name and password: as it should.')); + } else if (res.ok) { + const text = await res.text(); + const lines = text.split('\n').filter((l) => l && !l.startsWith('#')).length; + setCheck(t('metricsConnect.works', 'It answers with {{count}} measurements.', { count: lines })); + } else { + setCheck(t('metricsConnect.status', 'It answered {{status}}.', { status: res.status })); + } + } catch { + setCheck( + t( + 'metricsConnect.cantCheck', + 'The browser couldn’t read it (usually cross-site rules). Check the target on your Prometheus instead.', + ), + ); + } + }; + + const field = (key: keyof OtelInput, label: string, placeholder: string, hint?: string, secret = false) => ( +
+ + { + setOtel((o) => ({ ...o, [key]: e.target.value })); + setOtelDirty(true); + }} + className="max-w-lg" + /> + {hint &&

{hint}

} +
+ ); + + return ( +
+
+ +

{t('metricsConnect.title', 'Where the server’s numbers go')}

+ {(promChanged || otelChanged) && ( + + )} +
+ +
+ + {promOn && ( +
+
+
+ + setPromUser(e.target.value)} + className="w-48" + /> +
+ +
+ {!promUser.trim() && ( +

+ {t( + 'metricsConnect.open', + 'Without a user name anyone who can reach the server can read these numbers, which show how busy it is.', + )} +

+ )} + {promPassword && ( +

+ {t( + 'metricsConnect.shownOnce', + 'The new password is in the scrape job below. It’s shown only now: copy it before you leave.', + )} +

+ )} +

+ {t('metricsConnect.address', 'Address:')}{' '} + {scrapeUrl(base)} +

+ + {!promChanged && promSaved.on && ( +
+ + {check && {check}} +
+ )} +
+ )} +
+ +
+
+ + +
+ {provider === 'honeycomb' && ( + <> + {field( + 'key', + t('metricsConnect.hcKey', 'API key'), + savedProvider === 'honeycomb' ? t('metricsConnect.keep', 'Leave empty to keep the saved one') : '', + t('metricsConnect.hcKeyHint', 'An ingest key from your Honeycomb environment’s API keys.'), + true, + )} + {field('extra', t('metricsConnect.hcDataset', 'Dataset'), 'inbuxa')} + + )} + {provider === 'grafana' && ( + <> + {field( + 'endpoint', + t('metricsConnect.gcEndpoint', 'OTLP endpoint'), + 'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp', + t('metricsConnect.gcEndpointHint', 'From your stack’s OpenTelemetry page in the Grafana Cloud portal.'), + )} + {field('extra', t('metricsConnect.gcInstance', 'Instance ID'), '123456')} + {field( + 'key', + t('metricsConnect.gcToken', 'Token'), + '', + t( + 'metricsConnect.gcTokenHint', + 'An access policy token with metrics:write. Needed again whenever these settings change.', + ), + true, + )} +

+ {t( + 'metricsConnect.delta', + 'This server sends counters as deltas. If some counters don’t show up, put an OpenTelemetry Collector with the deltatocumulative processor in between.', + )} +

+ + )} + {provider === 'collectorHttp' && + field( + 'endpoint', + t('metricsConnect.collectorUrl', 'Collector address'), + 'http://collector.internal:4318', + t('metricsConnect.collectorHttpHint', '/v1/metrics is added if it isn’t there.'), + )} + {provider === 'collectorGrpc' && + field('endpoint', t('metricsConnect.collectorUrl', 'Collector address'), 'http://collector.internal:4317')} + {provider !== 'off' && ( +

+ {t('metricsConnect.pushEvery', 'The server sends them once a minute.')} +

+ )} +
+
+ ); +} diff --git a/src/features/metricsConnect/MetricsPage.tsx b/src/features/metricsConnect/MetricsPage.tsx new file mode 100644 index 0000000..1b56aba --- /dev/null +++ b/src/features/metricsConnect/MetricsPage.tsx @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { DynamicForm } from '@/components/forms/DynamicForm'; +import { MetricsConnect } from './MetricsConnect'; + +/** A metrics page: where the numbers go, then this page's own settings folded underneath. */ +export function MetricsPage({ viewName }: { viewName: string }) { + const { t } = useTranslation(); + const [version, setVersion] = useState(0); + return ( +
+ setVersion((v) => v + 1)} /> + + + + {t('metricsConnect.more', 'Which metrics, and every setting on this page')} + + + + + +
+ ); +} diff --git a/src/features/metricsConnect/connect.test.ts b/src/features/metricsConnect/connect.test.ts new file mode 100644 index 0000000..4909cc7 --- /dev/null +++ b/src/features/metricsConnect/connect.test.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { httpMetricsEndpoint, newPassword, otelValue, providerOf, scrapeConfig, scrapeUrl } from './connect'; + +describe('prometheus', () => { + it('scrapes /metrics/prometheus on the server', () => { + expect(scrapeUrl('https://mail.example.org/')).toBe('https://mail.example.org/metrics/prometheus'); + }); + + it('writes a scrape job, with basic auth when there is a user', () => { + expect(scrapeConfig('https://mail.example.org', 'prometheus', 's3cret')).toBe( + [ + 'scrape_configs:', + ' - job_name: inbuxa', + ' scheme: https', + ' metrics_path: /metrics/prometheus', + ' static_configs:', + " - targets: ['mail.example.org']", + ' basic_auth:', + " username: 'prometheus'", + " password: 's3cret'", + '', + ].join('\n'), + ); + expect(scrapeConfig('http://localhost:8080', '', null)).not.toContain('basic_auth'); + expect(scrapeConfig('http://localhost:8080', '', null)).toContain("targets: ['localhost:8080']"); + }); + + it('makes long random passwords', () => { + const a = newPassword(); + expect(a).toMatch(/^[A-Za-z0-9]{20,24}$/); + expect(newPassword()).not.toBe(a); + }); +}); + +describe('opentelemetry', () => { + it('adds the signal path to a bare HTTP collector address', () => { + expect(httpMetricsEndpoint('http://collector:4318')).toBe('http://collector:4318/v1/metrics'); + expect(httpMetricsEndpoint('http://collector:4318/v1/metrics/')).toBe('http://collector:4318/v1/metrics'); + }); + + it('builds each provider’s settings, and reads them back', () => { + const hc = otelValue('honeycomb', { endpoint: '', key: 'abc', extra: '' }); + expect(hc).toMatchObject({ + '@type': 'Http', + endpoint: 'https://api.honeycomb.io/v1/metrics', + httpHeaders: { 'x-honeycomb-team': 'abc', 'x-honeycomb-dataset': 'inbuxa' }, + }); + const gc = otelValue('grafana', { + endpoint: 'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp', + key: 'tok', + extra: '123456', + }); + expect(gc).toMatchObject({ + endpoint: 'https://otlp-gateway-prod-eu-west-2.grafana.net/otlp/v1/metrics', + httpAuth: { '@type': 'Basic', username: '123456', secret: 'tok' }, + }); + expect(providerOf(hc)).toBe('honeycomb'); + expect(providerOf(gc)).toBe('grafana'); + expect(providerOf(otelValue('collectorGrpc', { endpoint: 'http://c:4317', key: '', extra: '' }))).toBe( + 'collectorGrpc', + ); + expect(providerOf({ '@type': 'Disabled' })).toBe('off'); + }); +}); diff --git a/src/features/metricsConnect/connect.ts b/src/features/metricsConnect/connect.ts new file mode 100644 index 0000000..7687fae --- /dev/null +++ b/src/features/metricsConnect/connect.ts @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: sending the server's metrics somewhere (settings-reorg, second + * wave). Two ways, both on x:Metrics: + * + * - Prometheus scrapes GET /metrics/prometheus on the server's own web + * address, optionally behind Basic authentication (http/src/request.rs). + * - The server pushes to an OpenTelemetry endpoint over HTTP or gRPC. An + * HTTP endpoint set in the settings is used exactly as given + * (opentelemetry-otlp resolve_http_endpoint), so it must end in + * /v1/metrics; the guide adds that to a bare collector address. Counters + * go out as deltas (Temporality::Delta). + */ + +export type OtelProvider = 'off' | 'honeycomb' | 'grafana' | 'collectorHttp' | 'collectorGrpc'; + +export interface OtelInput { + endpoint: string; + /** Honeycomb API key, or Grafana Cloud token. */ + key: string; + /** Honeycomb dataset, or Grafana Cloud instance ID. */ + extra: string; +} + +export function scrapeUrl(base: string): string { + return `${base.replace(/\/+$/, '')}/metrics/prometheus`; +} + +/** A Prometheus scrape job for this server, ready to paste into prometheus.yml. */ +export function scrapeConfig(base: string, username: string, password: string | null): string { + const url = new URL(scrapeUrl(base)); + const lines = [ + 'scrape_configs:', + ' - job_name: inbuxa', + ` scheme: ${url.protocol.replace(':', '')}`, + ` metrics_path: ${url.pathname}`, + ' static_configs:', + ` - targets: ['${url.host}']`, + ]; + if (username) { + lines.push( + ' basic_auth:', + ` username: '${username}'`, + ` password: '${password ?? ''}'`, + ); + } + return lines.join('\n') + '\n'; +} + +/** An OTLP/HTTP metrics URL: a bare collector address gets the signal path. */ +export function httpMetricsEndpoint(raw: string): string { + const s = raw.trim().replace(/\/+$/, ''); + if (/\/v1\/metrics$/.test(s)) return s; + return `${s}/v1/metrics`; +} + +/** What x:Metrics.openTelemetry becomes for a provider. */ +export function otelValue(provider: OtelProvider, input: OtelInput): Record { + switch (provider) { + case 'off': + return { '@type': 'Disabled' }; + case 'honeycomb': + return { + '@type': 'Http', + endpoint: 'https://api.honeycomb.io/v1/metrics', + httpAuth: { '@type': 'Unauthenticated' }, + httpHeaders: { 'x-honeycomb-team': input.key.trim(), 'x-honeycomb-dataset': input.extra.trim() || 'inbuxa' }, + }; + case 'grafana': + return { + '@type': 'Http', + endpoint: httpMetricsEndpoint(input.endpoint.replace(/\/otlp\/?$/, '') + '/otlp'), + httpAuth: { '@type': 'Basic', username: input.extra.trim(), secret: input.key }, + httpHeaders: {}, + }; + case 'collectorHttp': + return { + '@type': 'Http', + endpoint: httpMetricsEndpoint(input.endpoint), + httpAuth: { '@type': 'Unauthenticated' }, + httpHeaders: {}, + }; + case 'collectorGrpc': + return { '@type': 'Grpc', endpoint: input.endpoint.trim() || null }; + } +} + +/** Which provider a saved value most likely is. */ +export function providerOf(value: Record | null | undefined): OtelProvider { + const type = value?.['@type']; + if (type === 'Grpc') return 'collectorGrpc'; + if (type !== 'Http') return 'off'; + const endpoint = String(value?.endpoint ?? ''); + if (endpoint.includes('honeycomb.io')) return 'honeycomb'; + if (endpoint.includes('grafana.net')) return 'grafana'; + return 'collectorHttp'; +} + +/** A password for the scrape endpoint, made in the browser and shown once. */ +export function newPassword(bytes = 18): string { + const a = new Uint8Array(bytes); + crypto.getRandomValues(a); + return btoa(String.fromCharCode(...a)) + .replace(/[+/=]/g, '') + .slice(0, 24); +} -- 2.54.0