diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml
index ccdde1faee..14e5c24f70 100644
--- a/.github/workflows/ui-ci.yml
+++ b/.github/workflows/ui-ci.yml
@@ -8,6 +8,7 @@ on:
- '.github/workflows/ui-ci.yml'
- '.nvmrc'
- 'Makefile'
+ - 'proto/api/**'
- 'ui/**'
concurrency:
diff --git a/ui/mantine-ui/Makefile b/ui/mantine-ui/Makefile
index 8a4d2d72e7..4bede4e24f 100644
--- a/ui/mantine-ui/Makefile
+++ b/ui/mantine-ui/Makefile
@@ -1,4 +1,5 @@
SRC_FILES = $(shell find src test-utils -type f)
+PROTO_FILES = $(shell find ../../proto/api -type f -name '*.proto')
node_modules: package.json package-lock.json
@echo ">> installing Mantine UI dependencies"
@@ -9,7 +10,7 @@ build: ../app/dist/mantine/.build_stamp
# .build_stamp allows skipping the build inside `promu crossbuild`, since the
# artifact copies the work tree into the build container.
-../app/dist/mantine/.build_stamp: $(SRC_FILES) index.html vite.config.mjs postcss.config.cjs tsconfig.json package-lock.json
+../app/dist/mantine/.build_stamp: $(SRC_FILES) $(PROTO_FILES) buf.gen.yaml index.html vite.config.mjs postcss.config.cjs tsconfig.json package-lock.json
@echo ">> building Mantine UI"
$(MAKE) node_modules
npm run build
diff --git a/ui/mantine-ui/biome.json b/ui/mantine-ui/biome.json
index f4e4bcf48f..83707f5cb1 100644
--- a/ui/mantine-ui/biome.json
+++ b/ui/mantine-ui/biome.json
@@ -6,7 +6,7 @@
"useIgnoreFile": true
},
"files": {
- "includes": ["**", "!**/dist", "!**/node_modules", "!**/*.d.ts", "!**/*.d.mts"]
+ "includes": ["**", "!**/dist", "!**/node_modules", "!src/gen", "!**/*.d.ts", "!**/*.d.mts"]
},
"formatter": {
"enabled": true,
diff --git a/ui/mantine-ui/buf.gen.yaml b/ui/mantine-ui/buf.gen.yaml
new file mode 100644
index 0000000000..26c3094a75
--- /dev/null
+++ b/ui/mantine-ui/buf.gen.yaml
@@ -0,0 +1,8 @@
+version: v2
+inputs:
+ - directory: ../../proto/api
+plugins:
+ - local: protoc-gen-es
+ out: src/gen
+ opt:
+ - target=ts
diff --git a/ui/mantine-ui/package-lock.json b/ui/mantine-ui/package-lock.json
index 147bfd140f..dd80b908a2 100644
--- a/ui/mantine-ui/package-lock.json
+++ b/ui/mantine-ui/package-lock.json
@@ -8,6 +8,10 @@
"name": "alertmanager",
"version": "0.0.0",
"dependencies": {
+ "@bufbuild/protobuf": "^2.14.0",
+ "@connectrpc/connect": "^2.1.2",
+ "@connectrpc/connect-query": "^2.3.1",
+ "@connectrpc/connect-web": "^2.1.2",
"@mantine/code-highlight": "^9.5.2",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
@@ -19,6 +23,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.11",
+ "@bufbuild/buf": "^1.72.0",
+ "@bufbuild/protoc-gen-es": "^2.14.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
@@ -302,6 +308,253 @@
"specificity": "bin/cli.js"
}
},
+ "node_modules/@bufbuild/buf": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.72.0.tgz",
+ "integrity": "sha512-BwBKTX/WXkhAhqWJGrEKnqU03/4tK1O0OozSlwUMBCOEo8pLL3xu3M24RT3+umExEeM0wjlANO6axqGWMqtt4Q==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "buf": "bin/buf",
+ "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
+ "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@bufbuild/buf-darwin-arm64": "1.72.0",
+ "@bufbuild/buf-darwin-x64": "1.72.0",
+ "@bufbuild/buf-linux-aarch64": "1.72.0",
+ "@bufbuild/buf-linux-armv7": "1.72.0",
+ "@bufbuild/buf-linux-x64": "1.72.0",
+ "@bufbuild/buf-win32-arm64": "1.72.0",
+ "@bufbuild/buf-win32-x64": "1.72.0"
+ }
+ },
+ "node_modules/@bufbuild/buf-darwin-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.72.0.tgz",
+ "integrity": "sha512-rKHRvjwAThapxIoOn92vIoTjYSz5FmRemDRLU4BYT4T6QWMEC13PM3/pPnqVgsNKZ5aW7iYDm9ztnisEqSi5yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-darwin-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.72.0.tgz",
+ "integrity": "sha512-4TQ1AGft8sGspNg9NMsEjsKKis7nGaVV8tZLnNa3cKUBmx22gwOnB6VRhgKWwjf+BDqr85lUEzQ6wHCboNUutg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-linux-aarch64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.72.0.tgz",
+ "integrity": "sha512-cbIsUcgM5bHhbZWcDaAXqaYOAi8N0c0u+NiDydwVmZ04Et3s1EZ3TDqfQDRzwvoBPDP+lsO6YuTRXX6nI28x4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-linux-armv7": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.72.0.tgz",
+ "integrity": "sha512-v/bXVsFL8YNm2HgosGb9r3+nAt4jQiUc3r3JipYuiVY3DAJZAjoEvcak6/BkxQMTEQz9Zb8gRRlule9IFkbc5g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-linux-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.72.0.tgz",
+ "integrity": "sha512-4xHGXEjqFxo1wX1zMGq4CzhYt5++nrj4C7k30j+YmGtvqCnipfdSe+V6kknBYRfYswVZEUwUbQOh6pnMTcGcrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-win32-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.72.0.tgz",
+ "integrity": "sha512-WH7ClsoB9A0e/5fFhx0DLqLzillYPRdHBhlwzihgvjGci0bBdyJVHSQGf0B9uspCMU6sn6W/N1S9/2vvQBNMug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/buf-win32-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.72.0.tgz",
+ "integrity": "sha512-X3eWqFzhDmu8CYQZz+Fu7i+PgH+yUl8UwJ5+x+bhZRYAIdcijikthodk60c5u/qq42m1Z2XAnAGyp/mTf7IffA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.0.tgz",
+ "integrity": "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
+ "node_modules/@bufbuild/protoc-gen-es": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.14.0.tgz",
+ "integrity": "sha512-/Rrui0BJWSRONiKGYzqWq9ivGN3+cYkmc47QWITufr7K6yDb8HfssuJUSQZeptyg/rHbHrfDzCUjzoJ2pwuafg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "2.14.0",
+ "@bufbuild/protoplugin": "2.14.0"
+ },
+ "bin": {
+ "protoc-gen-es": "bin/protoc-gen-es"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@bufbuild/protobuf": "2.14.0"
+ },
+ "peerDependenciesMeta": {
+ "@bufbuild/protobuf": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@bufbuild/protoplugin": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.14.0.tgz",
+ "integrity": "sha512-S0TvZhVtTEkZQRj2JsXBCXiib/b0+bLQtJCcn+hj9O/usgbcgxHn4D+ii22Ao3qj0/kKxVYeo7BtAI2dMk+iig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "2.14.0",
+ "@typescript/vfs": "^1.6.2",
+ "typescript": "5.4.5"
+ }
+ },
+ "node_modules/@bufbuild/protoplugin/node_modules/typescript": {
+ "version": "5.4.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+ "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/@connectrpc/connect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz",
+ "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@bufbuild/protobuf": "^2.7.0"
+ }
+ },
+ "node_modules/@connectrpc/connect-query": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@connectrpc/connect-query/-/connect-query-2.3.1.tgz",
+ "integrity": "sha512-ck1erTsLwD0nhagrK3AhghTz3t8IVVaLkAWMLu30tw/hGJGxEvjkSUM6YwW1mCwt2d6MCl/h1P1as+oQWmnFRg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@connectrpc/connect-query-core": "^2.3.1"
+ },
+ "peerDependencies": {
+ "@bufbuild/protobuf": "2.x",
+ "@connectrpc/connect": "^2.0.1",
+ "@tanstack/react-query": ">=5.62.0",
+ "react": "^18 || ^19",
+ "react-dom": "^18 || ^19"
+ }
+ },
+ "node_modules/@connectrpc/connect-query-core": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@connectrpc/connect-query-core/-/connect-query-core-2.3.1.tgz",
+ "integrity": "sha512-zFnNzvX+ao9/w1GoDOSRtKcejfAS1bF1XH7HpDPnnzINoXQ/wVz5lhLIdgekhYgkbsw8to6whw4/tUxMKwlaiA==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@bufbuild/protobuf": "2.x",
+ "@connectrpc/connect": "^2.0.1",
+ "@tanstack/query-core": ">=5.62.0"
+ }
+ },
+ "node_modules/@connectrpc/connect-web": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.2.tgz",
+ "integrity": "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@bufbuild/protobuf": "^2.7.0",
+ "@connectrpc/connect": "2.1.2"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
@@ -1818,6 +2071,19 @@
"node": ">=16.20.0"
}
},
+ "node_modules/@typescript/vfs": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
+ "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -2122,6 +2388,24 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2684,6 +2968,13 @@
"node": "*"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
diff --git a/ui/mantine-ui/package.json b/ui/mantine-ui/package.json
index 73a7ec71be..353f588cd2 100644
--- a/ui/mantine-ui/package.json
+++ b/ui/mantine-ui/package.json
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite --host",
- "build": "tsc && vite build",
+ "generate": "buf generate --template buf.gen.yaml",
+ "build": "npm run generate && tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
@@ -18,6 +19,10 @@
"test": "npm run check && npm run vitest && npm run build"
},
"dependencies": {
+ "@bufbuild/protobuf": "^2.14.0",
+ "@connectrpc/connect": "^2.1.2",
+ "@connectrpc/connect-query": "^2.3.1",
+ "@connectrpc/connect-web": "^2.1.2",
"@mantine/code-highlight": "^9.5.2",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
@@ -29,6 +34,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.11",
+ "@bufbuild/buf": "^1.72.0",
+ "@bufbuild/protoc-gen-es": "^2.14.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
diff --git a/ui/mantine-ui/src/App.tsx b/ui/mantine-ui/src/App.tsx
index eef9ff8cb7..e2fff870f2 100644
--- a/ui/mantine-ui/src/App.tsx
+++ b/ui/mantine-ui/src/App.tsx
@@ -3,6 +3,7 @@ import '@mantine/code-highlight/styles.css';
import { Suspense } from 'react';
+import { TransportProvider } from '@connectrpc/connect-query';
import { CodeHighlightAdapterProvider, createHighlightJsAdapter } from '@mantine/code-highlight';
import { AppShell, Box, MantineProvider, Skeleton } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -11,6 +12,7 @@ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import ErrorBoundary from './components/ErrorBoundary';
import { Header } from './components/Header';
+import { apiTransport } from './data/connect';
import { AlertsPage } from './pages/Alerts.page';
import { ConfigPage } from './pages/Config.page';
import { SilencesPage } from './pages/Silences.page';
@@ -32,33 +34,35 @@ export default function App() {
-
-
-
-
-
- {Array.from(Array(10), (_, i) => (
-
- ))}
-
- }
- >
- {/* Main content will be rendered here by the Router */}
-
- {/* Redirect the root path to the alerts page */}
- {/* TODO(@sysadmind): This should take the fact that previous UI used /#/routeName */}
- } />
- } />
- } />
- } />
- } />
-
-
-
-
-
+
+
+
+
+
+
+ {Array.from(Array(10), (_, i) => (
+
+ ))}
+
+ }
+ >
+ {/* Main content will be rendered here by the Router */}
+
+ {/* Redirect the root path to the alerts page */}
+ {/* TODO(@sysadmind): This should take the fact that previous UI used /#/routeName */}
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
diff --git a/ui/mantine-ui/src/data/connect.test.ts b/ui/mantine-ui/src/data/connect.test.ts
new file mode 100644
index 0000000000..c19eb25213
--- /dev/null
+++ b/ui/mantine-ui/src/data/connect.test.ts
@@ -0,0 +1,45 @@
+import { createClient } from '@connectrpc/connect';
+
+import { StatusService } from '@/gen/status/v3alpha/status_pb';
+
+import { createAPITransport, getAPIBaseURL } from './connect';
+
+describe('getAPIBaseURL', () => {
+ it.each([
+ ['https://example.com/ui/#/status', 'https://example.com/api'],
+ ['https://example.com/alertmanager/ui/#/status', 'https://example.com/alertmanager/api'],
+ [
+ 'https://example.com/monitoring/alertmanager/ui/#/status',
+ 'https://example.com/monitoring/alertmanager/api',
+ ],
+ ])('resolves %s to %s', (baseURI, expected) => {
+ expect(getAPIBaseURL(baseURI)).toBe(expected);
+ });
+});
+
+describe('createAPITransport', () => {
+ it('calls GetStatus with Connect HTTP GET under the route prefix', async () => {
+ let request: Request | undefined;
+ const mockFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ request = new Request(input, init);
+ return new Response('{}', {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ };
+ const client = createClient(
+ StatusService,
+ createAPITransport('https://example.com/alertmanager/ui/#/status', mockFetch)
+ );
+
+ await client.getStatus({});
+
+ expect(request).toBeDefined();
+ expect(request?.method).toBe('GET');
+ const url = new URL(request!.url);
+ expect(url.pathname).toBe('/alertmanager/api/status.v3alpha.StatusService/GetStatus');
+ expect(url.searchParams.get('connect')).toBe('v1');
+ expect(url.searchParams.get('encoding')).toBe('json');
+ expect(url.searchParams.get('message')).toBe('{}');
+ });
+});
diff --git a/ui/mantine-ui/src/data/connect.ts b/ui/mantine-ui/src/data/connect.ts
new file mode 100644
index 0000000000..813a92bd15
--- /dev/null
+++ b/ui/mantine-ui/src/data/connect.ts
@@ -0,0 +1,16 @@
+import { createConnectTransport } from '@connectrpc/connect-web';
+
+export const getAPIBaseURL = (baseURI: string | URL = document.baseURI) =>
+ new URL('../api', baseURI).toString();
+
+export const createAPITransport = (
+ baseURI: string | URL = document.baseURI,
+ fetch?: typeof globalThis.fetch
+) =>
+ createConnectTransport({
+ baseUrl: getAPIBaseURL(baseURI),
+ useHttpGet: true,
+ ...(fetch === undefined ? {} : { fetch }),
+ });
+
+export const apiTransport = createAPITransport();
diff --git a/ui/mantine-ui/src/data/status.test.ts b/ui/mantine-ui/src/data/status.test.ts
new file mode 100644
index 0000000000..16f370f863
--- /dev/null
+++ b/ui/mantine-ui/src/data/status.test.ts
@@ -0,0 +1,65 @@
+import { create } from '@bufbuild/protobuf';
+import { timestampFromDate } from '@bufbuild/protobuf/wkt';
+
+import { ClusterStatus_State, GetStatusResponseSchema } from '@/gen/status/v3alpha/status_pb';
+
+import { formatClusterState, statusFromResponse } from './status';
+
+const createStatusResponse = () =>
+ create(GetStatusResponseSchema, {
+ status: {
+ versionInfo: {
+ version: '1.2.3',
+ revision: 'abc123',
+ branch: 'main',
+ buildUser: 'builder',
+ buildDate: '2026-01-02',
+ goVersion: 'go1.26',
+ },
+ config: { original: 'route:\n receiver: default\n' },
+ cluster: {
+ name: 'test-cluster',
+ state: ClusterStatus_State.READY,
+ peers: [],
+ },
+ startTime: timestampFromDate(new Date('2026-01-02T03:04:05.000Z')),
+ },
+ });
+
+describe('statusFromResponse', () => {
+ it('unwraps a complete status response', () => {
+ const response = createStatusResponse();
+
+ expect(statusFromResponse(response)).toBe(response.status);
+ });
+
+ it('rejects a missing status', () => {
+ expect(() => statusFromResponse(create(GetStatusResponseSchema))).toThrow(
+ 'GetStatus response is missing required status fields'
+ );
+ });
+
+ it.each(['versionInfo', 'config', 'cluster', 'startTime'] as const)(
+ 'rejects a missing %s field',
+ (field) => {
+ const response = createStatusResponse();
+ response.status![field] = undefined;
+
+ expect(() => statusFromResponse(response)).toThrow(
+ 'GetStatus response is missing required status fields'
+ );
+ }
+ );
+});
+
+describe('formatClusterState', () => {
+ it.each([
+ [ClusterStatus_State.DISABLED, 'disabled'],
+ [ClusterStatus_State.SETTLING, 'settling'],
+ [ClusterStatus_State.READY, 'ready'],
+ [ClusterStatus_State.UNSPECIFIED, 'unspecified'],
+ [99 as ClusterStatus_State, 'unknown'],
+ ])('formats %s as %s', (state, expected) => {
+ expect(formatClusterState(state)).toBe(expected);
+ });
+});
diff --git a/ui/mantine-ui/src/data/status.ts b/ui/mantine-ui/src/data/status.ts
index 3964d4a5c7..d2da8e67b0 100644
--- a/ui/mantine-ui/src/data/status.ts
+++ b/ui/mantine-ui/src/data/status.ts
@@ -1,30 +1,57 @@
-import { useSuspenseAPIQuery } from '@/data/api';
+import { useSuspenseQuery } from '@connectrpc/connect-query';
-type Status = {
- cluster: {
- name: string;
- peers: Array<{
- name: string;
- address: string;
- }>;
- status: 'ready' | 'not_ready';
- };
- config: {
- original: string;
- };
- uptime: string;
- versionInfo: {
- branch: string;
- buildDate: string;
- buildUser: string;
- goVersion: string;
- version: string;
- revision: string;
- };
+import {
+ type AlertmanagerStatus,
+ ClusterStatus_State,
+ type GetStatusResponse,
+ StatusService,
+} from '@/gen/status/v3alpha/status_pb';
+
+type Status = AlertmanagerStatus & {
+ versionInfo: NonNullable;
+ config: NonNullable;
+ cluster: NonNullable;
+ startTime: NonNullable;
};
-export const useStatus = () => {
- return useSuspenseAPIQuery({
- path: '/status',
- });
+export const statusFromResponse = (response: GetStatusResponse): Status => {
+ const { status } = response;
+ if (
+ status === undefined ||
+ status.versionInfo === undefined ||
+ status.config === undefined ||
+ status.cluster === undefined ||
+ status.startTime === undefined
+ ) {
+ throw new Error('GetStatus response is missing required status fields');
+ }
+
+ return status as Status;
};
+
+export const formatClusterState = (state: ClusterStatus_State) => {
+ switch (state) {
+ case ClusterStatus_State.DISABLED:
+ return 'disabled';
+ case ClusterStatus_State.SETTLING:
+ return 'settling';
+ case ClusterStatus_State.READY:
+ return 'ready';
+ case ClusterStatus_State.UNSPECIFIED:
+ return 'unspecified';
+ default:
+ return 'unknown';
+ }
+};
+
+export const useStatus = () =>
+ useSuspenseQuery(
+ StatusService.method.getStatus,
+ {},
+ {
+ select: statusFromResponse,
+ retry: false,
+ refetchOnWindowFocus: false,
+ gcTime: 0,
+ }
+ );
diff --git a/ui/mantine-ui/src/gen/status/v3alpha/status_pb.ts b/ui/mantine-ui/src/gen/status/v3alpha/status_pb.ts
new file mode 100644
index 0000000000..c0fb5c5447
--- /dev/null
+++ b/ui/mantine-ui/src/gen/status/v3alpha/status_pb.ts
@@ -0,0 +1,263 @@
+// Copyright The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// @generated by protoc-gen-es v2.14.0 with parameter "target=ts"
+// @generated from file status/v3alpha/status.proto (package status.v3alpha, syntax proto3)
+/* eslint-disable */
+
+import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
+import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
+import type { Timestamp } from "@bufbuild/protobuf/wkt";
+import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt";
+import type { Message } from "@bufbuild/protobuf";
+
+/**
+ * Describes the file status/v3alpha/status.proto.
+ */
+export const file_status_v3alpha_status: GenFile = /*@__PURE__*/
+ fileDesc("ChtzdGF0dXMvdjNhbHBoYS9zdGF0dXMucHJvdG8SDnN0YXR1cy52M2FscGhhItsBChJBbGVydG1hbmFnZXJTdGF0dXMSMQoMdmVyc2lvbl9pbmZvGAEgASgLMhsuc3RhdHVzLnYzYWxwaGEuVmVyc2lvbkluZm8SMgoGY29uZmlnGAIgASgLMiIuc3RhdHVzLnYzYWxwaGEuQWxlcnRtYW5hZ2VyQ29uZmlnEi4KB2NsdXN0ZXIYAyABKAsyHS5zdGF0dXMudjNhbHBoYS5DbHVzdGVyU3RhdHVzEi4KCnN0YXJ0X3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wInwKC1ZlcnNpb25JbmZvEg8KB3ZlcnNpb24YASABKAkSEAoIcmV2aXNpb24YAiABKAkSDgoGYnJhbmNoGAMgASgJEhIKCmJ1aWxkX3VzZXIYBCABKAkSEgoKYnVpbGRfZGF0ZRgFIAEoCRISCgpnb192ZXJzaW9uGAYgASgJIiYKEkFsZXJ0bWFuYWdlckNvbmZpZxIQCghvcmlnaW5hbBgBIAEoCSLVAQoNQ2x1c3RlclN0YXR1cxIMCgRuYW1lGAEgASgJEjIKBXN0YXRlGAIgASgOMiMuc3RhdHVzLnYzYWxwaGEuQ2x1c3RlclN0YXR1cy5TdGF0ZRIpCgVwZWVycxgDIAMoCzIaLnN0YXR1cy52M2FscGhhLlBlZXJTdGF0dXMiVwoFU3RhdGUSFQoRU1RBVEVfVU5TUEVDSUZJRUQQABISCg5TVEFURV9ESVNBQkxFRBABEhIKDlNUQVRFX1NFVFRMSU5HEAISDwoLU1RBVEVfUkVBRFkQAyIrCgpQZWVyU3RhdHVzEgwKBG5hbWUYASABKAkSDwoHYWRkcmVzcxgCIAEoCSISChBHZXRTdGF0dXNSZXF1ZXN0IkcKEUdldFN0YXR1c1Jlc3BvbnNlEjIKBnN0YXR1cxgBIAEoCzIiLnN0YXR1cy52M2FscGhhLkFsZXJ0bWFuYWdlclN0YXR1czJmCg1TdGF0dXNTZXJ2aWNlElUKCUdldFN0YXR1cxIgLnN0YXR1cy52M2FscGhhLkdldFN0YXR1c1JlcXVlc3QaIS5zdGF0dXMudjNhbHBoYS5HZXRTdGF0dXNSZXNwb25zZSIDkAIBQkVaQ2dpdGh1Yi5jb20vcHJvbWV0aGV1cy9hbGVydG1hbmFnZXIvYXBpL3N0YXR1cy92M2FscGhhO3N0YXR1c3YzYWxwaGFiBnByb3RvMw", [file_google_protobuf_timestamp]);
+
+/**
+ * AlertmanagerStatus is the top-level status payload.
+ *
+ * @generated from message status.v3alpha.AlertmanagerStatus
+ */
+export type AlertmanagerStatus = Message<"status.v3alpha.AlertmanagerStatus"> & {
+ /**
+ * @generated from field: status.v3alpha.VersionInfo version_info = 1;
+ */
+ versionInfo?: VersionInfo | undefined;
+
+ /**
+ * @generated from field: status.v3alpha.AlertmanagerConfig config = 2;
+ */
+ config?: AlertmanagerConfig | undefined;
+
+ /**
+ * @generated from field: status.v3alpha.ClusterStatus cluster = 3;
+ */
+ cluster?: ClusterStatus | undefined;
+
+ /**
+ * @generated from field: google.protobuf.Timestamp start_time = 4;
+ */
+ startTime?: Timestamp | undefined;
+};
+
+/**
+ * Describes the message status.v3alpha.AlertmanagerStatus.
+ * Use `create(AlertmanagerStatusSchema)` to create a new message.
+ */
+export const AlertmanagerStatusSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 0);
+
+/**
+ * VersionInfo describes the running Alertmanager binary.
+ *
+ * @generated from message status.v3alpha.VersionInfo
+ */
+export type VersionInfo = Message<"status.v3alpha.VersionInfo"> & {
+ /**
+ * @generated from field: string version = 1;
+ */
+ version: string;
+
+ /**
+ * @generated from field: string revision = 2;
+ */
+ revision: string;
+
+ /**
+ * @generated from field: string branch = 3;
+ */
+ branch: string;
+
+ /**
+ * @generated from field: string build_user = 4;
+ */
+ buildUser: string;
+
+ /**
+ * @generated from field: string build_date = 5;
+ */
+ buildDate: string;
+
+ /**
+ * @generated from field: string go_version = 6;
+ */
+ goVersion: string;
+};
+
+/**
+ * Describes the message status.v3alpha.VersionInfo.
+ * Use `create(VersionInfoSchema)` to create a new message.
+ */
+export const VersionInfoSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 1);
+
+/**
+ * AlertmanagerConfig carries the currently loaded configuration.
+ *
+ * @generated from message status.v3alpha.AlertmanagerConfig
+ */
+export type AlertmanagerConfig = Message<"status.v3alpha.AlertmanagerConfig"> & {
+ /**
+ * Original YAML configuration as a single string.
+ *
+ * @generated from field: string original = 1;
+ */
+ original: string;
+};
+
+/**
+ * Describes the message status.v3alpha.AlertmanagerConfig.
+ * Use `create(AlertmanagerConfigSchema)` to create a new message.
+ */
+export const AlertmanagerConfigSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 2);
+
+/**
+ * ClusterStatus describes the local node's view of the gossip cluster.
+ *
+ * @generated from message status.v3alpha.ClusterStatus
+ */
+export type ClusterStatus = Message<"status.v3alpha.ClusterStatus"> & {
+ /**
+ * Cluster name, when configured.
+ *
+ * @generated from field: string name = 1;
+ */
+ name: string;
+
+ /**
+ * @generated from field: status.v3alpha.ClusterStatus.State state = 2;
+ */
+ state: ClusterStatus_State;
+
+ /**
+ * @generated from field: repeated status.v3alpha.PeerStatus peers = 3;
+ */
+ peers: PeerStatus[];
+};
+
+/**
+ * Describes the message status.v3alpha.ClusterStatus.
+ * Use `create(ClusterStatusSchema)` to create a new message.
+ */
+export const ClusterStatusSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 3);
+
+/**
+ * @generated from enum status.v3alpha.ClusterStatus.State
+ */
+export enum ClusterStatus_State {
+ /**
+ * @generated from enum value: STATE_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: STATE_DISABLED = 1;
+ */
+ DISABLED = 1,
+
+ /**
+ * @generated from enum value: STATE_SETTLING = 2;
+ */
+ SETTLING = 2,
+
+ /**
+ * @generated from enum value: STATE_READY = 3;
+ */
+ READY = 3,
+}
+
+/**
+ * Describes the enum status.v3alpha.ClusterStatus.State.
+ */
+export const ClusterStatus_StateSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_status_v3alpha_status, 3, 0);
+
+/**
+ * PeerStatus is a single peer in the gossip cluster.
+ *
+ * @generated from message status.v3alpha.PeerStatus
+ */
+export type PeerStatus = Message<"status.v3alpha.PeerStatus"> & {
+ /**
+ * @generated from field: string name = 1;
+ */
+ name: string;
+
+ /**
+ * @generated from field: string address = 2;
+ */
+ address: string;
+};
+
+/**
+ * Describes the message status.v3alpha.PeerStatus.
+ * Use `create(PeerStatusSchema)` to create a new message.
+ */
+export const PeerStatusSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 4);
+
+/**
+ * @generated from message status.v3alpha.GetStatusRequest
+ */
+export type GetStatusRequest = Message<"status.v3alpha.GetStatusRequest"> & {
+};
+
+/**
+ * Describes the message status.v3alpha.GetStatusRequest.
+ * Use `create(GetStatusRequestSchema)` to create a new message.
+ */
+export const GetStatusRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 5);
+
+/**
+ * @generated from message status.v3alpha.GetStatusResponse
+ */
+export type GetStatusResponse = Message<"status.v3alpha.GetStatusResponse"> & {
+ /**
+ * @generated from field: status.v3alpha.AlertmanagerStatus status = 1;
+ */
+ status?: AlertmanagerStatus | undefined;
+};
+
+/**
+ * Describes the message status.v3alpha.GetStatusResponse.
+ * Use `create(GetStatusResponseSchema)` to create a new message.
+ */
+export const GetStatusResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_status_v3alpha_status, 6);
+
+/**
+ * StatusService exposes Alertmanager instance and cluster status.
+ *
+ * @generated from service status.v3alpha.StatusService
+ */
+export const StatusService: GenService<{
+ /**
+ * GetStatus returns the Alertmanager instance and cluster status.
+ *
+ * @generated from rpc status.v3alpha.StatusService.GetStatus
+ */
+ getStatus: {
+ methodKind: "unary";
+ input: typeof GetStatusRequestSchema;
+ output: typeof GetStatusResponseSchema;
+ },
+}> = /*@__PURE__*/
+ serviceDesc(file_status_v3alpha_status, 0);
+
diff --git a/ui/mantine-ui/src/pages/Config.page.test.tsx b/ui/mantine-ui/src/pages/Config.page.test.tsx
new file mode 100644
index 0000000000..c02d35a631
--- /dev/null
+++ b/ui/mantine-ui/src/pages/Config.page.test.tsx
@@ -0,0 +1,35 @@
+import { Suspense } from 'react';
+
+import { timestampFromDate } from '@bufbuild/protobuf/wkt';
+import { createRouterTransport } from '@connectrpc/connect';
+import { render, screen } from '@test-utils';
+
+import { ClusterStatus_State, StatusService } from '@/gen/status/v3alpha/status_pb';
+
+import { ConfigPage } from './Config.page';
+
+const transport = createRouterTransport(({ service }) => {
+ service(StatusService, {
+ getStatus: () => ({
+ status: {
+ versionInfo: {},
+ config: { original: 'route:\n receiver: default\n' },
+ cluster: { state: ClusterStatus_State.DISABLED },
+ startTime: timestampFromDate(new Date('2026-01-02T03:04:05.000Z')),
+ },
+ }),
+ });
+});
+
+describe('ConfigPage', () => {
+ it('renders configuration from the v3alpha service', async () => {
+ render(
+
+
+ ,
+ { transport }
+ );
+
+ expect(await screen.findByText(/receiver: default/)).toBeInTheDocument();
+ });
+});
diff --git a/ui/mantine-ui/src/pages/Status.page.test.tsx b/ui/mantine-ui/src/pages/Status.page.test.tsx
new file mode 100644
index 0000000000..7283940be7
--- /dev/null
+++ b/ui/mantine-ui/src/pages/Status.page.test.tsx
@@ -0,0 +1,62 @@
+import { Suspense } from 'react';
+
+import { timestampFromDate } from '@bufbuild/protobuf/wkt';
+import { createRouterTransport } from '@connectrpc/connect';
+import { render, screen, within } from '@test-utils';
+
+import { ClusterStatus_State, StatusService } from '@/gen/status/v3alpha/status_pb';
+
+import { StatusPage } from './Status.page';
+
+const transport = createRouterTransport(({ service }) => {
+ service(StatusService, {
+ getStatus: () => ({
+ status: {
+ versionInfo: {
+ version: '1.2.3',
+ revision: 'abc123',
+ branch: 'main',
+ buildUser: 'builder',
+ buildDate: '2026-01-02',
+ goVersion: 'go1.26',
+ },
+ config: { original: 'route:\n receiver: default\n' },
+ cluster: {
+ name: 'test-cluster',
+ state: ClusterStatus_State.READY,
+ peers: [{ name: 'node-a', address: '10.0.0.1:9094' }],
+ },
+ startTime: timestampFromDate(new Date('2026-01-02T03:04:05.000Z')),
+ },
+ }),
+ });
+});
+
+describe('StatusPage', () => {
+ it('renders status from the v3alpha service', async () => {
+ render(
+
+
+ ,
+ { transport }
+ );
+
+ expect(await screen.findByText('1.2.3')).toBeInTheDocument();
+ expect(screen.getByText('abc123')).toBeInTheDocument();
+ expect(screen.getByText('main')).toBeInTheDocument();
+ expect(screen.getByText('builder')).toBeInTheDocument();
+ expect(screen.getByText('2026-01-02')).toBeInTheDocument();
+ expect(screen.getByText('go1.26')).toBeInTheDocument();
+ expect(screen.getByText('Start Time')).toBeInTheDocument();
+ expect(screen.queryByText('Uptime')).not.toBeInTheDocument();
+ expect(screen.getByText('2026-01-02T03:04:05.000Z')).toBeInTheDocument();
+ expect(screen.getByText('test-cluster')).toBeInTheDocument();
+ expect(screen.getByText('ready')).toBeInTheDocument();
+ expect(screen.getByText('node-a')).toBeInTheDocument();
+ expect(screen.getByText('10.0.0.1:9094')).toBeInTheDocument();
+
+ const peerCountRow = screen.getByText('Number of Peers').closest('tr');
+ expect(peerCountRow).not.toBeNull();
+ expect(within(peerCountRow!).getByText('1')).toBeInTheDocument();
+ });
+});
diff --git a/ui/mantine-ui/src/pages/Status.page.tsx b/ui/mantine-ui/src/pages/Status.page.tsx
index 7e387e3459..ad604d0c48 100644
--- a/ui/mantine-ui/src/pages/Status.page.tsx
+++ b/ui/mantine-ui/src/pages/Status.page.tsx
@@ -1,8 +1,9 @@
+import { timestampDate } from '@bufbuild/protobuf/wkt';
import { Table } from '@mantine/core';
import InfoPageCard from '@/components/InfoPageCard';
import InfoPageStack from '@/components/InfoPageStack';
-import { useStatus } from '@/data/status';
+import { formatClusterState, useStatus } from '@/data/status';
export function StatusPage() {
const { data } = useStatus();
@@ -42,8 +43,8 @@ export function StatusPage() {
- Uptime
- {data.uptime}
+ Start Time
+ {timestampDate(data.startTime).toISOString()}
Cluster Name
@@ -51,7 +52,7 @@ export function StatusPage() {
Cluster Status
- {data.cluster.status}
+ {formatClusterState(data.cluster.state)}
Number of Peers
diff --git a/ui/mantine-ui/test-utils/render.tsx b/ui/mantine-ui/test-utils/render.tsx
index d7c744f418..67cb453bc3 100644
--- a/ui/mantine-ui/test-utils/render.tsx
+++ b/ui/mantine-ui/test-utils/render.tsx
@@ -1,13 +1,27 @@
+import type { Transport } from '@connectrpc/connect';
+import { TransportProvider } from '@connectrpc/connect-query';
+import { CodeHighlightAdapterProvider, plainTextAdapter } from '@mantine/code-highlight';
import { MantineProvider } from '@mantine/core';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render as testingLibraryRender } from '@testing-library/react';
+import { apiTransport } from '../src/data/connect';
import { theme } from '../src/theme';
-export function render(ui: React.ReactNode) {
+type RenderOptions = {
+ transport?: Transport;
+};
+
+export function render(ui: React.ReactNode, { transport = apiTransport }: RenderOptions = {}) {
+ const queryClient = new QueryClient();
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: React.ReactNode }) => (
- {children}
+
+
+ {children}
+
+
),
});