From d450bed6f16ce084951018cf5c7a6006f01a54c2 Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Mon, 27 Feb 2023 17:10:58 +0100 Subject: [PATCH] implementing the status page Signed-off-by: Augustin Husson --- ui/react-app/package-lock.json | 97 +++++++++++++++++++++++++- ui/react-app/package.json | 4 +- ui/react-app/src/App.tsx | 10 ++- ui/react-app/src/Router.tsx | 17 +++++ ui/react-app/src/client/am-client.ts | 41 +++++++++++ ui/react-app/src/components/navbar.tsx | 31 ++++++-- ui/react-app/src/index.html | 2 +- ui/react-app/src/index.tsx | 23 +++++- ui/react-app/src/utils/fetch.ts | 31 ++++++++ ui/react-app/src/utils/url-builder.ts | 17 +++++ ui/react-app/src/views/ViewStatus.tsx | 89 +++++++++++++++++++++++ ui/react-app/webpack.dev.ts | 2 +- 12 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 ui/react-app/src/Router.tsx create mode 100644 ui/react-app/src/client/am-client.ts create mode 100644 ui/react-app/src/utils/fetch.ts create mode 100644 ui/react-app/src/utils/url-builder.ts create mode 100644 ui/react-app/src/views/ViewStatus.tsx diff --git a/ui/react-app/package-lock.json b/ui/react-app/package-lock.json index ddab5c65..f4b5c37c 100644 --- a/ui/react-app/package-lock.json +++ b/ui/react-app/package-lock.json @@ -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", diff --git a/ui/react-app/package.json b/ui/react-app/package.json index 4a40bd1d..149c5deb 100644 --- a/ui/react-app/package.json +++ b/ui/react-app/package.json @@ -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", diff --git a/ui/react-app/src/App.tsx b/ui/react-app/src/App.tsx index 6755db78..eb716893 100644 --- a/ui/react-app/src/App.tsx +++ b/ui/react-app/src/App.tsx @@ -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', }} > + theme.spacing(1), flex: 1, }} > + ); diff --git a/ui/react-app/src/Router.tsx b/ui/react-app/src/Router.tsx new file mode 100644 index 00000000..ea0c5fe6 --- /dev/null +++ b/ui/react-app/src/Router.tsx @@ -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 ( + + + } /> + + + ); +} + +export default Router; diff --git a/ui/react-app/src/client/am-client.ts b/ui/react-app/src/client/am-client.ts new file mode 100644 index 00000000..69f868a9 --- /dev/null +++ b/ui/react-app/src/client/am-client.ts @@ -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([], () => { + const url = buildURL({ resource: resource }); + return fetchJson(url); + }); +} diff --git a/ui/react-app/src/components/navbar.tsx b/ui/react-app/src/components/navbar.tsx index 3dabb708..ed5e5bb4 100644 --- a/ui/react-app/src/components/navbar.tsx +++ b/ui/react-app/src/components/navbar.tsx @@ -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 ( - - + + + + + + + + + ); diff --git a/ui/react-app/src/index.html b/ui/react-app/src/index.html index e3c14051..bc01283f 100644 --- a/ui/react-app/src/index.html +++ b/ui/react-app/src/index.html @@ -5,7 +5,7 @@ - Perses + AlertManager diff --git a/ui/react-app/src/index.tsx b/ui/react-app/src/index.tsx index 2eb3ec70..1611c331 100644 --- a/ui/react-app/src/index.tsx +++ b/ui/react-app/src/index.tsx @@ -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( - + + + + + ); diff --git a/ui/react-app/src/utils/fetch.ts b/ui/react-app/src/utils/fetch.ts new file mode 100644 index 00000000..16e107fa --- /dev/null +++ b/ui/react-app/src/utils/fetch.ts @@ -0,0 +1,31 @@ +/** + * Calls `global.fetch`, but throws a `FetchError` for non-200 responses. + */ +export async function fetch(...args: Parameters) { + 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(...args: Parameters) { + 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); + } +} diff --git a/ui/react-app/src/utils/url-builder.ts b/ui/react-app/src/utils/url-builder.ts new file mode 100644 index 00000000..adb8d101 --- /dev/null +++ b/ui/react-app/src/utils/url-builder.ts @@ -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; +} diff --git a/ui/react-app/src/views/ViewStatus.tsx b/ui/react-app/src/views/ViewStatus.tsx new file mode 100644 index 00000000..eb76dcff --- /dev/null +++ b/ui/react-app/src/views/ViewStatus.tsx @@ -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 = { + [`& .${tableCellClasses.root}`]: { + borderBottom: 'none', + }, +}; + +const tableHeaderStyle: SxProps = { + fontWeight: 'bold', +}; + +interface tableCellProperties { + header: string; + content: string; +} + +function CustomTableCell(props: tableCellProperties) { + const { header, content } = props; + return ( + + + {header} + + {content} + + ); +} + +export default function ViewStatus() { + const { data } = useAMStatus(); + if (data === undefined || data === null) { + return <>; + } + + return ( + + Status + + + +
+
+ Cluster Status + + + + + Status + + + + + +
+ Version Information + + + + + + + + +
+
+ Config + +
+ ); +} diff --git a/ui/react-app/webpack.dev.ts b/ui/react-app/webpack.dev.ts index 96a7764b..669d9031 100644 --- a/ui/react-app/webpack.dev.ts +++ b/ui/react-app/webpack.dev.ts @@ -68,7 +68,7 @@ const devConfig: Configuration = { historyApiFallback: true, allowedHosts: 'all', proxy: { - '/api': 'http://localhost:8080', + '/api': 'http://localhost:9093', }, }, cache: true,