implementing the status page

Signed-off-by: Augustin Husson <augustin.husson@amadeus.com>
This commit is contained in:
Augustin Husson 2023-02-27 17:10:58 +01:00
parent c86c6131ae
commit d450bed6f1
12 changed files with 349 additions and 15 deletions

View File

@ -11,10 +11,12 @@
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11.9.3",
"@mui/material": "^5.10.14",
"@tanstack/react-query": "^4.7.1",
"mdi-material-ui": "^7.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.3.0"
"react-router-dom": "^6.3.0",
"use-query-params": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.0.0",
@ -1111,6 +1113,41 @@
"node": ">=14"
}
},
"node_modules/@tanstack/query-core": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.10.tgz",
"integrity": "sha512-2QywqXEAGBIUoTdgn1lAB4/C8QEqwXHj2jrCLeYTk2xVGtLiPEUD8jcMoeB2noclbiW2mMt4+Fq7fZStuz3wAQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.10.tgz",
"integrity": "sha512-FY1DixytOcNNCydPQXLxuKEV7VSST32CAuJ55BjhDNqASnMLZn+6c30yQBMrODjmWMNwzfjMZnq0Vw7C62Fwow==",
"dependencies": {
"@tanstack/query-core": "4.24.10",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@ -6827,6 +6864,11 @@
"randombytes": "^2.1.0"
}
},
"node_modules/serialize-query-params": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.2.tgz",
"integrity": "sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q=="
},
"node_modules/serve-index": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz",
@ -7649,6 +7691,26 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-query-params": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.0.tgz",
"integrity": "sha512-MPBwXVZYzFeJEdjv0YgPNFsafUOM8WTpwBEZfNEMlyzbTsf2c+ZpOBkdM95/w4rxzk4eVO3E4DW7v33+VDbiQw==",
"dependencies": {
"serialize-query-params": "^2.0.2"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -8818,6 +8880,20 @@
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz",
"integrity": "sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA=="
},
"@tanstack/query-core": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.10.tgz",
"integrity": "sha512-2QywqXEAGBIUoTdgn1lAB4/C8QEqwXHj2jrCLeYTk2xVGtLiPEUD8jcMoeB2noclbiW2mMt4+Fq7fZStuz3wAQ=="
},
"@tanstack/react-query": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.10.tgz",
"integrity": "sha512-FY1DixytOcNNCydPQXLxuKEV7VSST32CAuJ55BjhDNqASnMLZn+6c30yQBMrODjmWMNwzfjMZnq0Vw7C62Fwow==",
"requires": {
"@tanstack/query-core": "4.24.10",
"use-sync-external-store": "^1.2.0"
}
},
"@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@ -13129,6 +13205,11 @@
"randombytes": "^2.1.0"
}
},
"serialize-query-params": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.2.tgz",
"integrity": "sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q=="
},
"serve-index": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz",
@ -13728,6 +13809,20 @@
"punycode": "^2.1.0"
}
},
"use-query-params": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.0.tgz",
"integrity": "sha512-MPBwXVZYzFeJEdjv0YgPNFsafUOM8WTpwBEZfNEMlyzbTsf2c+ZpOBkdM95/w4rxzk4eVO3E4DW7v33+VDbiQw==",
"requires": {
"serialize-query-params": "^2.0.2"
}
},
"use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"requires": {}
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -13,10 +13,12 @@
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11.9.3",
"@mui/material": "^5.10.14",
"@tanstack/react-query": "^4.7.1",
"mdi-material-ui": "^7.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.3.0"
"react-router-dom": "^6.3.0",
"use-query-params": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.0.0",

View File

@ -1,5 +1,9 @@
import { Box } from '@mui/material';
import { Box, styled } from '@mui/material';
import Navbar from './components/navbar';
import Router from './Router';
// Based on the MUI doc: https://mui.com/material-ui/react-app-bar/#fixed-placement
const Offset = styled('div')(({ theme }) => theme.mixins.toolbar);
function App() {
return (
@ -7,16 +11,16 @@ function App() {
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<Navbar />
<Offset />
<Box
sx={{
paddingBottom: (theme) => theme.spacing(1),
flex: 1,
}}
>
<Router />
</Box>
</Box>
);

View File

@ -0,0 +1,17 @@
// Other routes are lazy-loaded for code-splitting
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
const ViewStatus = lazy(() => import('./views/ViewStatus'));
function Router() {
return (
<Suspense>
<Routes>
<Route path="/status" element={<ViewStatus />} />
</Routes>
</Suspense>
);
}
export default Router;

View File

@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import buildURL from '../utils/url-builder';
import { fetchJson } from '../utils/fetch';
const resource = 'status';
export interface AMStatusClusterPeersInfo {
address: string;
name: string;
}
export interface AMStatusClusterInfo {
name: string;
peers: AMStatusClusterPeersInfo[];
status: string;
}
export interface AMStatusVersionInfo {
branch: string;
buildDate: string;
buildUser: string;
goVersion: string;
revision: string;
version: string;
}
export interface AMStatus {
cluster: AMStatusClusterInfo;
uptime: string;
versionInfo: AMStatusVersionInfo;
config: {
original: string;
};
}
export function useAMStatus() {
return useQuery<AMStatus, Error>([], () => {
const url = buildURL({ resource: resource });
return fetchJson<AMStatus>(url);
});
}

View File

@ -1,12 +1,15 @@
import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material';
import { AppBar, Box, Button, Stack, Toolbar, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function Navbar(): JSX.Element {
const navigate = useNavigate();
return (
<AppBar position='relative'>
<Toolbar>
<AppBar position={'fixed'} elevation={1}>
<Toolbar
sx={{
backgroundColor: 'lightgrey',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'row' }} flexGrow={1}>
<Button
onClick={() => {
@ -14,16 +17,32 @@ export default function Navbar(): JSX.Element {
}}
>
<Typography
variant='h1'
variant="h6"
sx={(theme) => ({
marginRight: '1rem',
color: theme.palette.common.white,
color: theme.palette.common.black,
})}
>
AlertManager
</Typography>
</Button>
<Button variant="text">Alerts</Button>
<Button variant="text">Silences</Button>
<Button
variant="text"
onClick={() => {
navigate('/status');
}}
>
Status
</Button>
<Button variant="text" target="_blank" href="https://prometheus.io/docs/alerting/latest/alertmanager/">
Help
</Button>
</Box>
<Stack direction={'row'} alignItems={'center'}>
<Button variant="outlined">New Silence</Button>
</Stack>
</Toolbar>
</AppBar>
);

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="AlertManager"/>
<title>Perses</title>
<title>AlertManager</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -2,16 +2,35 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function renderApp(container: Element| null) {
function renderApp(container: Element | null) {
if (container === null) {
return;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
// react-query uses a default of 3 retries.
// This sets the default to 0 retries.
// If needed, the number of retries can be overridden in individual useQuery calls.
retry: 0,
},
},
});
const root = ReactDOM.createRoot(container);
root.render(
<React.StrictMode>
<BrowserRouter>
<App/>
<QueryClientProvider client={queryClient}>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<App />
</QueryParamProvider>
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,31 @@
/**
* Calls `global.fetch`, but throws a `FetchError` for non-200 responses.
*/
export async function fetch(...args: Parameters<typeof global.fetch>) {
const response = await global.fetch(...args);
if (!response.ok) {
throw new FetchError(response);
}
return response;
}
/**
* Calls `global.fetch` and throws a `FetchError` on non-200 responses, but also
* decodes the response body as JSON, casting it to type `T`. Returns the
* decoded body.
*/
export async function fetchJson<T>(...args: Parameters<typeof global.fetch>) {
const response = await fetch(...args);
const json: T = await response.json();
return json;
}
/**
* Error thrown when fetch returns a non-200 response.
*/
export class FetchError extends Error {
constructor(readonly response: Response) {
super(`${response.status} ${response.statusText}`);
Object.setPrototypeOf(this, FetchError.prototype);
}
}

View File

@ -0,0 +1,17 @@
const apiPrefix = '/api/v2';
export type URLParams = {
resource: string;
queryParams?: URLSearchParams;
apiPrefix?: string;
};
export default function buildURL(params: URLParams): string {
let url = params.apiPrefix === undefined ? apiPrefix : params.apiPrefix;
url = `${url}/${params.resource}`;
if (params.queryParams !== undefined) {
url = `${url}?${params.queryParams.toString()}`;
}
return url;
}

View File

@ -0,0 +1,89 @@
import {
Chip,
Container,
SxProps,
Table,
TableCell,
tableCellClasses,
TableContainer,
TableRow,
TextareaAutosize,
Theme,
Typography,
} from '@mui/material';
import { useAMStatus } from '../client/am-client';
const tableStyle: SxProps<Theme> = {
[`& .${tableCellClasses.root}`]: {
borderBottom: 'none',
},
};
const tableHeaderStyle: SxProps<Theme> = {
fontWeight: 'bold',
};
interface tableCellProperties {
header: string;
content: string;
}
function CustomTableCell(props: tableCellProperties) {
const { header, content } = props;
return (
<TableRow>
<TableCell variant="head" sx={tableHeaderStyle}>
{header}
</TableCell>
<TableCell>{content}</TableCell>
</TableRow>
);
}
export default function ViewStatus() {
const { data } = useAMStatus();
if (data === undefined || data === null) {
return <></>;
}
return (
<Container maxWidth="md">
<Typography variant="h4">Status</Typography>
<TableContainer>
<Table sx={tableStyle}>
<CustomTableCell header="Uptime" content={data.uptime} />
</Table>
</TableContainer>
<Typography variant="h4">Cluster Status</Typography>
<Table sx={tableStyle}>
<CustomTableCell header="Name" content={data.cluster.name} />
<TableRow>
<TableCell variant="head" sx={tableHeaderStyle}>
Status
</TableCell>
<TableCell>
<Chip color={data.cluster.status === 'ready' ? 'success' : 'error'} label={data.cluster.status} />
</TableCell>
</TableRow>
</Table>
<Typography variant="h4">Version Information</Typography>
<TableContainer>
<Table sx={tableStyle}>
<CustomTableCell header="Branch" content={data.versionInfo.branch} />
<CustomTableCell header="Build Date" content={data.versionInfo.buildDate} />
<CustomTableCell header="Build User" content={data.versionInfo.buildUser} />
<CustomTableCell header="Go Version" content={data.versionInfo.goVersion} />
<CustomTableCell header="Revision" content={data.versionInfo.revision} />
<CustomTableCell header="Version" content={data.versionInfo.version} />
</Table>
</TableContainer>
<Typography variant="h4">Config</Typography>
<TextareaAutosize
readOnly
aria-multiline
value={data.config.original}
style={{ width: '100%', backgroundColor: 'lightgrey' }}
/>
</Container>
);
}

View File

@ -68,7 +68,7 @@ const devConfig: Configuration = {
historyApiFallback: true,
allowedHosts: 'all',
proxy: {
'/api': 'http://localhost:8080',
'/api': 'http://localhost:9093',
},
},
cache: true,