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.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..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 = {
@@ -28,7 +27,7 @@ const isAPIEnvelope = (value: unknown): value is APIResponse => {
);
};
-const createQueryFn =
+export const createQueryFn =
({
pathPrefix,
path,
@@ -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.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/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.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();
+ });
+});
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)
})