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}
+
+ void navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ })
+ }
+ >
+ {copied ? : }
+ {copied ? t('metricsConnect.copied', 'Copied') : t('metricsConnect.copy', 'Copy')}
+
+
+ );
+}
+
+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) => (
+
+
{label}
+
{
+ 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) && (
+
void save()} disabled={busy || !otelValid || !promValid}>
+ {busy && }
+ {t('metricsConnect.save', 'Save changes')}
+
+ )}
+
+
+
+
+
+ {t('metricsConnect.prom', 'Let Prometheus collect them')}
+
+ {t('metricsConnect.promHint', 'Prometheus fetches them from this server every so often.')}
+
+
+
+
+ {promOn && (
+
+
+
+ {t('metricsConnect.user', 'User name')}
+ setPromUser(e.target.value)}
+ className="w-48"
+ />
+
+
{
+ setPromPassword(newPassword());
+ setPasswordSaved(false);
+ }}
+ >
+ {promPassword
+ ? t('metricsConnect.newPassword', 'Make another password')
+ : promSaved.on && promSaved.user
+ ? t('metricsConnect.replacePassword', 'Replace the password')
+ : t('metricsConnect.makePassword', 'Make a password')}
+
+
+ {!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 && (
+
+ void tryScrape()}>
+ {t('metricsConnect.check', 'Check it answers')}
+
+ {check && {check} }
+
+ )}
+
+ )}
+
+
+
+
+ {t('metricsConnect.otel', 'Send them to an OpenTelemetry service')}
+ {
+ setProvider(v as OtelProvider);
+ setOtelDirty(true);
+ }}
+ >
+
+
+
+
+ {t('metricsConnect.off', 'Don’t send them')}
+ Honeycomb
+ Grafana Cloud
+
+ {t('metricsConnect.collectorHttp', 'Your own collector (HTTP)')}
+
+
+ {t('metricsConnect.collectorGrpc', 'Your own collector (gRPC)')}
+
+
+
+
+ {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);
+}