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} + + ), });