Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 32 additions & 29 deletions ui/mantine-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,35 +32,37 @@ export default function App() {
<HashRouter>
<MantineProvider theme={theme}>
<CodeHighlightAdapterProvider adapter={highlightJsAdapter}>
<QueryClientProvider client={queryClient}>
<AppShell padding="md" header={{ height: 60 }}>
<Header />
<AppShell.Main>
<ErrorBoundary key={location.pathname}>
<Suspense
fallback={
<Box mt="lg">
{Array.from(Array(10), (_, i) => (
<Skeleton key={i} height={40} mb={15} width={1000} mx="auto" />
))}
</Box>
}
>
{/* Main content will be rendered here by the Router */}
<Routes>
{/* Redirect the root path to the alerts page */}
{/* TODO(@sysadmind): This should take the fact that previous UI used /#/routeName */}
<Route path="/" element={<Navigate to="/alerts" replace />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/silences" element={<SilencesPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/config" element={<ConfigPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell.Main>
</AppShell>
</QueryClientProvider>
<SettingsProvider>
<QueryClientProvider client={queryClient}>
<AppShell padding="md" header={{ height: 60 }}>
<Header />
<AppShell.Main>
<ErrorBoundary key={location.pathname}>
<Suspense
fallback={
<Box mt="lg">
{Array.from(Array(10), (_, i) => (
<Skeleton key={i} height={40} mb={15} width={1000} mx="auto" />
))}
</Box>
}
>
{/* Main content will be rendered here by the Router */}
<Routes>
{/* Redirect the root path to the alerts page */}
{/* TODO(@sysadmind): This should take the fact that previous UI used /#/routeName */}
<Route path="/" element={<Navigate to="/alerts" replace />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/silences" element={<SilencesPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/config" element={<ConfigPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell.Main>
</AppShell>
</QueryClientProvider>
</SettingsProvider>
</CodeHighlightAdapterProvider>
</MantineProvider>
</HashRouter>
Expand Down
208 changes: 208 additions & 0 deletions ui/mantine-ui/src/data/api.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
server.listen(0, () => {
serverPort = (server.address() as AddressInfo).port;
resolve();
});
});
});

afterAll(async () => {
await new Promise<void>((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<string, string | string[]>,
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);
});
});
11 changes: 7 additions & 4 deletions ui/mantine-ui/src/data/api.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -28,7 +27,7 @@ const isAPIEnvelope = <T>(value: unknown): value is APIResponse<T> => {
);
};

const createQueryFn =
export const createQueryFn =
<T>({
pathPrefix,
path,
Expand Down Expand Up @@ -121,6 +120,8 @@ export const useAPIQuery = <T>({
refetchInterval,
recordResponseTime,
}: QueryOptions) => {
const { pathPrefix } = useSettings();

return useQuery<T>({
queryKey: key ?? [API_PATH, path, params],
retry: false,
Expand All @@ -133,6 +134,8 @@ export const useAPIQuery = <T>({
};

export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) => {
const { pathPrefix } = useSettings();

return useSuspenseQuery<T>({
queryKey: key !== undefined ? key : [path, params],
retry: false,
Expand Down
65 changes: 65 additions & 0 deletions ui/mantine-ui/src/lib/pathPrefix.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading