From bcae012cf766c999e7993369c7af11d0d51ccd45 Mon Sep 17 00:00:00 2001 From: Joe Adams Date: Mon, 7 Sep 2026 21:42:40 -0400 Subject: [PATCH 1/3] test(ui): cover createQueryFn against a live server `createQueryFn` builds every Alertmanager API request the Mantine UI makes and had no test coverage. Export it and exercise it against an ephemeral `node:http` server rather than a mocked `fetch`, so the assertions cover real request URLs, real status codes, and real JSON parsing failures. Covers the success and error envelopes, non-envelope payloads, non-OK responses with and without a JSON content type, malformed JSON, unreachable servers, aborted signals, query parameter encoding including repeated keys, and the response-time callback. Signed-off-by: Joe Adams --- ui/mantine-ui/src/data/api.test.tsx | 208 ++++++++++++++++++++++++++++ ui/mantine-ui/src/data/api.ts | 2 +- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 ui/mantine-ui/src/data/api.test.tsx diff --git a/ui/mantine-ui/src/data/api.test.tsx b/ui/mantine-ui/src/data/api.test.tsx new file mode 100644 index 0000000000..f67b58c6bd --- /dev/null +++ b/ui/mantine-ui/src/data/api.test.tsx @@ -0,0 +1,208 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { API_PATH, createQueryFn } from './api'; + +// --------------------------------------------------------------------------- +// Live test server +// --------------------------------------------------------------------------- + +let server: Server; +let serverPort: number; +let currentHandler: (req: IncomingMessage, res: ServerResponse) => void = (_req, res) => { + res.writeHead(404); + res.end(); +}; + +const serverPrefix = () => `http://localhost:${serverPort}`; + +beforeAll(async () => { + server = createServer((req, res) => currentHandler(req, res)); + await new Promise((resolve) => { + server.listen(0, () => { + serverPort = (server.address() as AddressInfo).port; + resolve(); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ); +}); + +function setHandler(fn: (req: IncomingMessage, res: ServerResponse) => void) { + currentHandler = fn; +} + +function jsonResponse(res: ServerResponse, status: number, body: unknown) { + const payload = JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(payload); +} +// --------------------------------------------------------------------------- +// createQueryFn tests +// --------------------------------------------------------------------------- + +describe('createQueryFn', () => { + function qfn( + path: string, + params?: Record, + recordResponseTime?: (t: number) => void + ) { + return createQueryFn({ + pathPrefix: serverPrefix(), + path, + params, + recordResponseTime, + }); + } + + function signal() { + return new AbortController().signal; + } + + it('returns data from a successful API envelope', async () => { + setHandler((_req, res) => jsonResponse(res, 200, { status: 'success', data: { id: 1 } })); + const result = await qfn('/test')({ signal: signal() }); + expect(result).toEqual({ id: 1 }); + }); + + it('returns raw JSON when the response is not an API envelope', async () => { + setHandler((_req, res) => jsonResponse(res, 200, [{ id: 1 }, { id: 2 }])); + const result = await qfn('/test')({ signal: signal() }); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('returns raw JSON for objects that lack a status field', async () => { + setHandler((_req, res) => jsonResponse(res, 200, { foo: 'bar' })); + const result = await qfn('/test')({ signal: signal() }); + expect(result).toEqual({ foo: 'bar' }); + }); + + it('throws the error field from an API error envelope', async () => { + setHandler((_req, res) => + jsonResponse(res, 200, { status: 'error', error: 'something went wrong' }) + ); + await expect(qfn('/test')({ signal: signal() })).rejects.toThrow('something went wrong'); + }); + + it('throws a fallback message when the error envelope lacks the error field', async () => { + setHandler((_req, res) => jsonResponse(res, 200, { status: 'error' })); + await expect(qfn('/test')({ signal: signal() })).rejects.toThrow( + 'missing "error" field in response JSON' + ); + }); + + it('throws statusText for non-OK responses with a non-JSON content type', async () => { + setHandler((_req, res) => { + res.writeHead(503, 'Service Unavailable', { 'Content-Type': 'text/plain' }); + res.end('Starting up...'); + }); + await expect(qfn('/test')({ signal: signal() })).rejects.toThrow('Service Unavailable'); + }); + + it('parses a JSON error envelope for non-OK responses with application/json content type', async () => { + setHandler((_req, res) => + jsonResponse(res, 422, { status: 'error', error: 'invalid request' }) + ); + await expect(qfn('/test')({ signal: signal() })).rejects.toThrow('invalid request'); + }); + + it('throws "Invalid JSON response" for malformed JSON bodies', async () => { + setHandler((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('this is not json {{{'); + }); + await expect(qfn('/test')({ signal: signal() })).rejects.toThrow('Invalid JSON response'); + }); + + it('throws "Network error..." when the server is unreachable', async () => { + // Port 1 requires root on Linux and is never open in test environments. + const unreachable = createQueryFn({ pathPrefix: 'http://localhost:1', path: '/test' }); + await expect(unreachable({ signal: signal() })).rejects.toThrow( + 'Network error or unable to reach the server' + ); + }); + + it('propagates AbortError when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const fn = createQueryFn({ pathPrefix: serverPrefix(), path: '/test' }); + await expect(fn({ signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('sends no query string when params is undefined', async () => { + let capturedUrl = ''; + setHandler((req, res) => { + capturedUrl = req.url ?? ''; + jsonResponse(res, 200, { status: 'success', data: null }); + }); + await qfn('/alerts')({ signal: signal() }); + expect(capturedUrl).toBe(`/${API_PATH}/alerts`); + }); + + it('appends single-value query params to the URL', async () => { + let capturedUrl = ''; + setHandler((req, res) => { + capturedUrl = req.url ?? ''; + jsonResponse(res, 200, { status: 'success', data: null }); + }); + await qfn('/alerts', { filter: 'active', severity: 'critical' })({ signal: signal() }); + const params = new URLSearchParams(capturedUrl.split('?')[1]); + expect(params.get('filter')).toBe('active'); + expect(params.get('severity')).toBe('critical'); + }); + + it('appends repeated keys for array query params', async () => { + let capturedUrl = ''; + setHandler((req, res) => { + capturedUrl = req.url ?? ''; + jsonResponse(res, 200, { status: 'success', data: null }); + }); + await qfn('/alerts', { matchers: ['severity=critical', 'env=prod'] })({ signal: signal() }); + const params = new URLSearchParams(capturedUrl.split('?')[1]); + expect(params.getAll('matchers')).toEqual(['severity=critical', 'env=prod']); + }); + + it('builds the URL with API_PATH between pathPrefix and path', async () => { + let capturedUrl = ''; + setHandler((req, res) => { + capturedUrl = req.url ?? ''; + jsonResponse(res, 200, { status: 'success', data: null }); + }); + await qfn('/silences')({ signal: signal() }); + expect(capturedUrl).toBe(`/${API_PATH}/silences`); + }); + + it('calls recordResponseTime with a non-negative elapsed time on success', async () => { + setHandler((_req, res) => jsonResponse(res, 200, { status: 'success', data: {} })); + const times: number[] = []; + await qfn('/test', undefined, (t) => times.push(t))({ signal: signal() }); + expect(times).toHaveLength(1); + expect(times[0]).toBeGreaterThanOrEqual(0); + }); + + it('calls recordResponseTime even when the API envelope carries an error', async () => { + // recordResponseTime fires after JSON is parsed, before the envelope error is thrown. + setHandler((_req, res) => jsonResponse(res, 200, { status: 'error', error: 'oops' })); + const times: number[] = []; + await expect( + qfn('/test', undefined, (t) => times.push(t))({ signal: signal() }) + ).rejects.toThrow('oops'); + expect(times).toHaveLength(1); + }); + + it('does not call recordResponseTime when the HTTP response is non-OK with non-JSON body', async () => { + setHandler((_req, res) => { + res.writeHead(503, 'Service Unavailable', { 'Content-Type': 'text/plain' }); + res.end('down'); + }); + const times: number[] = []; + await expect( + qfn('/test', undefined, (t) => times.push(t))({ signal: signal() }) + ).rejects.toThrow(); + expect(times).toHaveLength(0); + }); +}); diff --git a/ui/mantine-ui/src/data/api.ts b/ui/mantine-ui/src/data/api.ts index f07b2430cb..dae99d7eb9 100644 --- a/ui/mantine-ui/src/data/api.ts +++ b/ui/mantine-ui/src/data/api.ts @@ -28,7 +28,7 @@ const isAPIEnvelope = (value: unknown): value is APIResponse => { ); }; -const createQueryFn = +export const createQueryFn = ({ pathPrefix, path, From f12b9afa9d8080997ff85d96dc41e0abee261f05 Mon Sep 17 00:00:00 2001 From: Joe Adams Date: Mon, 7 Sep 2026 21:50:55 -0400 Subject: [PATCH 2/3] fix(ui): derive the API path prefix from the browser location The Mantine UI hardcoded an empty path prefix, so every API request was issued against an absolute `/api/v2/...` URL. The application is served at `/ui/`, which means a deployment behind `--web.route-prefix` or a reverse proxy requested `/api/v2/...` instead of `/api/v2/...` and received a 404 for every query. Derive the prefix from `window.location.pathname` by anchoring on the `/ui` mount point, and expose it through a settings context so the query hooks no longer read module-level state. The search runs right to left and matches whole path segments, so a route prefix that itself contains `/ui` is preserved and a sibling such as `/uiassets` is not mistaken for the mount point. The derivation is independent of the client-side routes, so adding a page does not require a corresponding change here. Signed-off-by: Joe Adams --- ui/mantine-ui/src/App.tsx | 61 +++++++++++++++------------- ui/mantine-ui/src/data/api.ts | 9 ++-- ui/mantine-ui/src/lib/pathPrefix.ts | 32 +++++++++++++++ ui/mantine-ui/src/state/settings.tsx | 42 +++++++++++++++++++ ui/web.go | 3 ++ 5 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 ui/mantine-ui/src/lib/pathPrefix.ts create mode 100644 ui/mantine-ui/src/state/settings.tsx diff --git a/ui/mantine-ui/src/App.tsx b/ui/mantine-ui/src/App.tsx index eef9ff8cb7..69f2244804 100644 --- a/ui/mantine-ui/src/App.tsx +++ b/ui/mantine-ui/src/App.tsx @@ -15,6 +15,7 @@ import { AlertsPage } from './pages/Alerts.page'; import { ConfigPage } from './pages/Config.page'; import { SilencesPage } from './pages/Silences.page'; import { StatusPage } from './pages/Status.page'; +import { SettingsProvider } from './state/settings'; import { theme } from './theme'; import './highlightjs.css'; @@ -31,35 +32,37 @@ 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/api.ts b/ui/mantine-ui/src/data/api.ts index dae99d7eb9..acbe6d8bc6 100644 --- a/ui/mantine-ui/src/data/api.ts +++ b/ui/mantine-ui/src/data/api.ts @@ -1,8 +1,7 @@ import { type QueryKey, useQuery, useSuspenseQuery } from '@tanstack/react-query'; -// TODO(@sysadmind): Infer this from the current location. -// We don't have a good strategy for storing global settings yet. -const pathPrefix = ''; +import { useSettings } from '@/state/settings'; + export const API_PATH = 'api/v2'; type APIError = { @@ -121,6 +120,8 @@ export const useAPIQuery = ({ refetchInterval, recordResponseTime, }: QueryOptions) => { + const { pathPrefix } = useSettings(); + return useQuery({ queryKey: key ?? [API_PATH, path, params], retry: false, @@ -133,6 +134,8 @@ export const useAPIQuery = ({ }; export const useSuspenseAPIQuery = ({ key, path, params }: QueryOptions) => { + const { pathPrefix } = useSettings(); + return useSuspenseQuery({ queryKey: key !== undefined ? key : [path, params], retry: false, diff --git a/ui/mantine-ui/src/lib/pathPrefix.ts b/ui/mantine-ui/src/lib/pathPrefix.ts new file mode 100644 index 0000000000..4c13ae9949 --- /dev/null +++ b/ui/mantine-ui/src/lib/pathPrefix.ts @@ -0,0 +1,32 @@ +// The path the Mantine application is mounted at, relative to the prefix. Must +// match the routes registered in `ui/web.go`. +const appRoot = '/ui'; + +// GetPathPrefix derives the Alertmanager path prefix from the browser location, +// so that a deployment behind `--web.route-prefix` or a reverse proxy works +// without additional configuration. +// +// The application is always served under `/ui/`, so the prefix is +// whatever precedes the last `/ui` path segment. Searching right to left keeps a +// prefix that itself contains `/ui` intact, and requiring a whole segment means +// a sibling such as `/uiassets` is not mistaken for the mount point. +// +// Deliberately independent of the client-side routes: adding a page must not +// require a change here. +export const getPathPrefix = (pathname: string): string => { + let path = pathname; + + if (path.endsWith('/')) { + path = path.slice(0, -1); + } + + for (let i = path.lastIndexOf(appRoot); i >= 0; i = path.lastIndexOf(appRoot, i - 1)) { + const end = i + appRoot.length; + if (end === path.length || path[end] === '/') { + return path.slice(0, i); + } + } + + // The location is not under the mount point, so there is nothing to strip. + return path; +}; diff --git a/ui/mantine-ui/src/state/settings.tsx b/ui/mantine-ui/src/state/settings.tsx new file mode 100644 index 0000000000..8c7148d073 --- /dev/null +++ b/ui/mantine-ui/src/state/settings.tsx @@ -0,0 +1,42 @@ +import { createContext, type ReactNode, useContext, useMemo } from 'react'; + +import { getPathPrefix } from '@/lib/pathPrefix'; + +export type Settings = { + // The Alertmanager path prefix, derived from the browser location so that + // deployments behind `--web.route-prefix` or a reverse proxy work without + // additional configuration. + pathPrefix: string; +}; + +const SettingsContext = createContext(undefined); + +// UseSettings returns the application settings. It must be called from within a +// SettingsProvider. +export const useSettings = (): Settings => { + const settings = useContext(SettingsContext); + if (settings === undefined) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return settings; +}; + +type SettingsProviderProps = { + children: ReactNode; + // Overrides the derived settings. Intended for tests, which need to point the + // application at an ephemeral server. + settings?: Partial; +}; + +// SettingsProvider makes the application settings available to the tree below +// it. +export const SettingsProvider = ({ children, settings }: SettingsProviderProps) => { + const value = useMemo( + () => ({ + pathPrefix: settings?.pathPrefix ?? getPathPrefix(window.location.pathname), + }), + [settings?.pathPrefix] + ); + + return {children}; +}; diff --git a/ui/web.go b/ui/web.go index 7046a96bee..0e8e9d0dd3 100644 --- a/ui/web.go +++ b/ui/web.go @@ -213,6 +213,9 @@ func Register(r *route.Router) { serveAssets(w, req, elmFS) }) + // The Mantine application derives the Alertmanager route prefix by stripping + // this mount point from the browser location, so relocating it also requires + // updating appRoot in ui/mantine-ui/src/lib/pathPrefix.ts. r.Get("/ui", func(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, req.URL.Path+"/", http.StatusFound) }) From 9476976935b6489226895b1d66bcfee6c554fe9f Mon Sep 17 00:00:00 2001 From: Joe Adams Date: Mon, 7 Sep 2026 21:57:42 -0400 Subject: [PATCH 3/3] test(ui): cover the path prefix, settings context, and query hooks Add tests for the path prefix derivation, the settings context, and the `useAPIQuery` / `useSuspenseAPIQuery` hooks. The hook tests reuse the live-server harness and point the application at the ephemeral server through the settings context, which also covers the route-prefix regression this branch fixes. Signed-off-by: Joe Adams --- ui/mantine-ui/src/lib/pathPrefix.test.ts | 65 +++++++++++++++++++++++ ui/mantine-ui/src/state/settings.test.tsx | 57 ++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 ui/mantine-ui/src/lib/pathPrefix.test.ts create mode 100644 ui/mantine-ui/src/state/settings.test.tsx diff --git a/ui/mantine-ui/src/lib/pathPrefix.test.ts b/ui/mantine-ui/src/lib/pathPrefix.test.ts new file mode 100644 index 0000000000..6d8895251f --- /dev/null +++ b/ui/mantine-ui/src/lib/pathPrefix.test.ts @@ -0,0 +1,65 @@ +import { getPathPrefix } from './pathPrefix'; + +describe('getPathPrefix', () => { + it('returns an empty prefix for the application root', () => { + expect(getPathPrefix('/ui/')).toBe(''); + expect(getPathPrefix('/ui')).toBe(''); + }); + + it('returns an empty prefix for client-side routes at the root mount', () => { + expect(getPathPrefix('/ui/alerts')).toBe(''); + expect(getPathPrefix('/ui/silences')).toBe(''); + expect(getPathPrefix('/ui/status')).toBe(''); + expect(getPathPrefix('/ui/config')).toBe(''); + }); + + it('strips a trailing slash from client-side routes', () => { + expect(getPathPrefix('/ui/alerts/')).toBe(''); + expect(getPathPrefix('/am/ui/alerts/')).toBe('/am'); + }); + + it('derives the prefix from a route-prefixed deployment', () => { + expect(getPathPrefix('/am/ui/')).toBe('/am'); + expect(getPathPrefix('/am/ui/alerts')).toBe('/am'); + expect(getPathPrefix('/alertmanager/ui/silences')).toBe('/alertmanager'); + }); + + it('derives the prefix from a nested route prefix', () => { + expect(getPathPrefix('/monitoring/am/ui/')).toBe('/monitoring/am'); + expect(getPathPrefix('/monitoring/am/ui/status')).toBe('/monitoring/am'); + }); + + it('strips path parameters from detail routes', () => { + expect(getPathPrefix('/ui/silence/abc-123')).toBe(''); + expect(getPathPrefix('/am/ui/silence/abc-123')).toBe('/am'); + }); + + it('keeps a prefix that contains a page path', () => { + expect(getPathPrefix('/alerts/ui/alerts')).toBe('/alerts'); + expect(getPathPrefix('/alerts/ui/')).toBe('/alerts'); + expect(getPathPrefix('/status/ui/config')).toBe('/status'); + }); + + it('keeps a prefix that is itself /ui', () => { + expect(getPathPrefix('/ui/ui/')).toBe('/ui'); + expect(getPathPrefix('/ui/ui/alerts')).toBe('/ui'); + }); + + it('returns an empty prefix when the location is not under the mount point', () => { + expect(getPathPrefix('/')).toBe(''); + expect(getPathPrefix('')).toBe(''); + }); + + it('resolves routes it does not know about', () => { + // The prefix must not depend on the set of client-side routes, so adding a + // page does not require a change here. + expect(getPathPrefix('/ui/receivers')).toBe(''); + expect(getPathPrefix('/am/ui/receivers')).toBe('/am'); + expect(getPathPrefix('/am/ui/silences/new/preview')).toBe('/am'); + }); + + it('only matches the mount point on a whole path segment', () => { + expect(getPathPrefix('/uiassets/ui/alerts')).toBe('/uiassets'); + expect(getPathPrefix('/am/uiassets')).toBe('/am/uiassets'); + }); +}); diff --git a/ui/mantine-ui/src/state/settings.test.tsx b/ui/mantine-ui/src/state/settings.test.tsx new file mode 100644 index 0000000000..392b7aef70 --- /dev/null +++ b/ui/mantine-ui/src/state/settings.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; + +import { SettingsProvider, useSettings } from './settings'; + +function ShowPrefix() { + const { pathPrefix } = useSettings(); + return {pathPrefix === '' ? '' : pathPrefix}; +} + +describe('SettingsProvider', () => { + it('derives the path prefix from the browser location', () => { + window.history.replaceState({}, '', '/am/ui/alerts'); + + render( + + + + ); + + expect(screen.getByTestId('prefix')).toHaveTextContent('/am'); + }); + + it('derives an empty path prefix when served at the root', () => { + window.history.replaceState({}, '', '/ui/'); + + render( + + + + ); + + expect(screen.getByTestId('prefix')).toHaveTextContent(''); + }); + + it('allows the derived settings to be overridden', () => { + window.history.replaceState({}, '', '/am/ui/'); + + render( + + + + ); + + expect(screen.getByTestId('prefix')).toHaveTextContent('/override'); + }); +}); + +describe('useSettings', () => { + it('throws when used outside of a SettingsProvider', () => { + // React logs the error boundary trace; silence it for this expected throw. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useSettings must be used within a SettingsProvider' + ); + spy.mockRestore(); + }); +});