Implement pathPrefix handling

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-03-08 13:38:11 +01:00
parent 5fea050fed
commit 33a753c2f8
4 changed files with 110 additions and 31 deletions

View file

@ -60,9 +60,26 @@ import ErrorBoundary from "./ErrorBoundary";
import { ThemeSelector } from "./ThemeSelector";
import { SettingsContext } from "./settings";
import { Notifications } from "@mantine/notifications";
import { useAppDispatch } from "./state/hooks";
import { updateSettings } from "./state/settingsSlice";
const queryClient = new QueryClient();
const mainNavPages = [
{
title: "Query",
path: "/query",
icon: <IconDatabaseSearch style={{ width: rem(14), height: rem(14) }} />,
element: <QueryPage />,
},
{
title: "Alerts",
path: "/alerts",
icon: <IconBellFilled style={{ width: rem(14), height: rem(14) }} />,
element: <AlertsPage />,
},
];
const monitoringStatusPages = [
{
title: "Targets",
@ -114,6 +131,7 @@ const serverStatusPages = [
];
const allStatusPages = [...monitoringStatusPages, ...serverStatusPages];
const allPages = [...mainNavPages, ...allStatusPages];
const theme = createTheme({
colors: {
@ -132,6 +150,21 @@ const theme = createTheme({
},
});
// This dynamically/generically determines the pathPrefix by stripping the first known
// endpoint suffix from the window location path. It works out of the box for both direct
// hosting and reverse proxy deployments with no additional configurations required.
const getPathPrefix = (path: string) => {
if (path.endsWith("/")) {
path = path.slice(0, -1);
}
const pagePath = allPages.find((p) => path.endsWith(p.path))?.path;
if (pagePath === undefined) {
throw new Error(`Could not find base path for ${path}`);
}
return path.slice(0, path.length - pagePath.length);
};
const navLinkIconSize = 15;
const navLinkXPadding = "md";
@ -139,26 +172,24 @@ function App() {
const [opened, { toggle }] = useDisclosure();
const { agentMode } = useContext(SettingsContext);
const pathPrefix = getPathPrefix(window.location.pathname);
const dispatch = useAppDispatch();
dispatch(updateSettings({ pathPrefix }));
const navLinks = (
<>
<Button
component={NavLink}
to="/query"
className={classes.link}
leftSection={<IconDatabaseSearch size={navLinkIconSize} />}
px={navLinkXPadding}
>
Query
</Button>
<Button
component={NavLink}
to="/alerts"
className={classes.link}
leftSection={<IconBellFilled size={navLinkIconSize} />}
px={navLinkXPadding}
>
Alerts
</Button>
{mainNavPages.map((p) => (
<Button
key={p.path}
component={NavLink}
to={p.path}
className={classes.link}
leftSection={p.icon}
px={navLinkXPadding}
>
{p.title}
</Button>
))}
<Menu shadow="md" width={230}>
<Routes>
@ -246,7 +277,7 @@ function App() {
);
return (
<BrowserRouter>
<BrowserRouter basename={pathPrefix}>
<MantineProvider defaultColorScheme="auto" theme={theme}>
<Notifications position="top-right" />

View file

@ -1,4 +1,5 @@
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useAppSelector } from "../state/hooks";
export const API_PATH = "api/v1";
@ -17,18 +18,29 @@ export type ErrorAPIResponse = {
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse;
const createQueryFn =
<T>({ path, params }: { path: string; params?: Record<string, string> }) =>
<T>({
pathPrefix,
path,
params,
}: {
pathPrefix: string;
path: string;
params?: Record<string, string>;
}) =>
async ({ signal }: { signal: AbortSignal }) => {
const queryString = params
? `?${new URLSearchParams(params).toString()}`
: "";
try {
const res = await fetch(`/${API_PATH}/${path}${queryString}`, {
cache: "no-store",
credentials: "same-origin",
signal,
});
const res = await fetch(
`${pathPrefix}/${API_PATH}${path}${queryString}`,
{
cache: "no-store",
credentials: "same-origin",
signal,
}
);
if (
!res.ok &&
@ -74,21 +86,32 @@ type QueryOptions = {
enabled?: boolean;
};
export const useAPIQuery = <T>({ key, path, params, enabled }: QueryOptions) =>
useQuery<SuccessAPIResponse<T>>({
export const useAPIQuery = <T>({
key,
path,
params,
enabled,
}: QueryOptions) => {
const pathPrefix = useAppSelector((state) => state.settings.pathPrefix);
return useQuery<SuccessAPIResponse<T>>({
queryKey: key ? [key] : [path, params],
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
enabled,
queryFn: createQueryFn({ path, params }),
queryFn: createQueryFn({ pathPrefix, path, params }),
});
};
export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) =>
useSuspenseQuery<SuccessAPIResponse<T>>({
export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) => {
const pathPrefix = useAppSelector((state) => state.settings.pathPrefix);
return useSuspenseQuery<SuccessAPIResponse<T>>({
queryKey: key ? [key] : [path, params],
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
queryFn: createQueryFn({ path, params }),
queryFn: createQueryFn({ pathPrefix, path, params }),
});
};

View file

@ -0,0 +1,23 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
interface Settings {
pathPrefix: string;
}
const initialState: Settings = {
pathPrefix: "",
};
export const settingsSlice = createSlice({
name: "settings",
initialState,
reducers: {
updateSettings: (state, { payload }: PayloadAction<Partial<Settings>>) => {
Object.assign(state, payload);
},
},
});
export const { updateSettings } = settingsSlice.actions;
export default settingsSlice.reducer;

View file

@ -1,9 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import queryPageSlice from "./queryPageSlice";
import { prometheusApi } from "./api";
import settingsSlice from "./settingsSlice";
const store = configureStore({
reducer: {
settings: settingsSlice,
queryPage: queryPageSlice,
[prometheusApi.reducerPath]: prometheusApi.reducer,
},