Data nodes overview for preflight UI (#15214)

* Implement basic data nodes overview.

* Create foundation for CA configuration step.

* Implement hook to fetch available data nodes.

* Implement hook to fetch data nodes CA status.

* Display different configuration steps based on data nodes status.

* Fixing tests.

* Cleanup code.

* Improve styling of configuration steps overview.

* Fix theme spacing.

* Cleanup code.

* Improve styling of steps overview.

* Implement dropzone to upload CA.

* Improve overall styling.

* Unify text color.

* Implement configuration step for certificate provisioning.

* Implement configuration step for finished configuration.

* Improve headline styling.

* Fix naming.

* Add test for `useConfigurationStep` hook.

* Create components directory.

* ADding test for data nodes overview.

* Adding test for `DataNodesOverview`.

* Adding test for `ConfigurationWizard`.

* Improve usage of list items.

* Fixing rebase

* REmove dat nodes mocks and use actual API endpoint.

* Configure dev server for preflight UI webpack setup, to be able to proxy API requests.

* Update data nodes object structure.

* Configure timezone provider.

* Change structure of data nodes object.

* Improve layout

* Add missing timezone provider.

* Remove configuration wizard for now.

* Fix `useDataNodes` test.

* Remove further not needed code.

* Fix preflight status API routes.

* Display data nodes overview as list instead of table.

* Improve descriptions.

* Implement documentation links.

* Adding test

* Remove not needed timezone provider.

* Cleanup code.

* Improve wording.

* Add missing license header.
This commit is contained in:
Linus Pahl
2023-04-14 15:49:53 +02:00
committed by GitHub
parent 0401153ab1
commit 2a40df5c58
28 changed files with 541 additions and 30 deletions

View File

@@ -32,7 +32,7 @@ import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path(PreflightConstants.API_PREFIX + "/status")
@Path(PreflightConstants.API_PREFIX + "status")
@Produces(MediaType.APPLICATION_JSON)
public class PreflightStatusResource {

View File

@@ -214,12 +214,16 @@ function queuePromiseIfNotLoggedin<T>(promise: () => Promise<T>): () => Promise<
type Method = 'GET' | 'PUT' | 'POST' | 'DELETE';
export default function fetch<T = any>(method: Method, url: string, body?: any): Promise<T> {
export default function fetch<T = any>(method: Method, url: string, body?: any, requireSession: boolean = true): Promise<T> {
const promise = () => new Builder(method, url)
.json(body)
.build();
return queuePromiseIfNotLoggedin(promise)();
if (requireSession) {
return queuePromiseIfNotLoggedin(promise)();
}
return promise();
}
export function fetchPlainText(method, url, body) {

View File

@@ -15,21 +15,42 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import { AppShell } from '@mantine/core';
import { AppShell, Title, Space } from '@mantine/core';
import styled from 'styled-components';
import Section from 'preflight/common/Section';
import Button from 'preflight/common/Button';
import Section from 'preflight/components/common/Section';
import Navigation from 'preflight/navigation/Navigation';
import DataNodesOverview from 'preflight/components/DataNodesOverview';
import DocumentationLink from '../components/support/DocumentationLink';
const P = styled.p`
max-width: 700px;
`;
const App = () => (
<AppShell padding="md" header={<Navigation />}>
<Section title="Welcome!">
<p>
It looks like you are starting Graylog for the first time.
Through this wizard, you can configure and secure your data nodes.
</p>
<Button size="xs">Continue</Button>
<Section title="Welcome!" titleOrder={1}>
<P>
It looks like you are starting Graylog for the first time and have not configured a data node.<br />
Data nodes allow you to index and search through all the messages in your Graylog message database.
</P>
<P>
You can either implement a <DocumentationLink page="" text="Graylog data node" /> (recommended) or you can configure an <DocumentationLink page="" text="Elasticsearch" /> or <DocumentationLink page="" text="OpenSearch" /> node manually.
</P>
<Space h="md" />
<Title order={2}>Graylog Data Nodes</Title>
<DataNodesOverview />
<Space h="md" />
<Title order={2}>Manual Data Node Configuration</Title>
<P>
If you want to configure an Elasticsearch or OpenSearch node manually, you need to adjust the Graylog configuration and restart the Graylog server.
After the restart this page will not show up again.
</P>
</Section>
</AppShell>
);
export default App;

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import { render, screen, waitFor } from 'wrappedTestingLibrary';
import userEvent from '@testing-library/user-event';
import fetch from 'logic/rest/FetchProvider';
import DataNodesOverview from 'preflight/components/DataNodesOverview';
import useDataNodes from 'preflight/hooks/useDataNodes';
import { asMock } from 'helpers/mocking';
jest.mock('preflight/hooks/useDataNodes');
jest.mock('logic/rest/FetchProvider', () => jest.fn(() => Promise.resolve()));
const availableDataNodes = [
{
hostname: '192.168.0.10',
id: 'data-node-id-1',
is_leader: false,
is_master: false,
last_seen: '2020-01-10T10:40:00.000Z',
node_id: 'node-id-complete-1',
short_node_id: 'node-id-1',
transport_address: 'http://localhost:9200',
type: 'DATANODE',
},
{
hostname: '192.168.0.11',
id: 'data-node-id-2',
is_leader: false,
is_master: false,
last_seen: '2020-01-10T10:40:00.000Z',
node_id: 'node-id-complete-2',
short_node_id: 'node-id-2',
transport_address: 'http://localhost:9201',
type: 'DATANODE',
},
{
hostname: '192.168.0.12',
id: 'data-node-id-3',
is_leader: false,
is_master: false,
last_seen: '2020-01-10T10:40:00.000Z',
node_id: 'node-id-complete-3',
short_node_id: 'node-id-3',
transport_address: 'http://localhost:9202',
type: 'DATANODE',
},
];
describe('DataNodesOverview', () => {
beforeEach(() => {
asMock(useDataNodes).mockReturnValue({
data: availableDataNodes,
isFetching: false,
isInitialLoading: false,
error: undefined,
});
});
it('should list available data nodes', async () => {
render(<DataNodesOverview />);
await screen.findByText('node-id-3');
await screen.findByText('http://localhost:9200');
});
it('should resume startup', async () => {
render(<DataNodesOverview />);
await screen.findByText('node-id-3');
const resumeStartupButton = screen.getByRole('button', {
name: /resume startup/i,
});
userEvent.click(resumeStartupButton);
await waitFor(() => expect(fetch).toHaveBeenCalledWith('POST', expect.stringContaining('/api/status/finish-config'), undefined, false));
});
});

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import { Space } from '@mantine/core';
import styled from 'styled-components';
import { useState } from 'react';
import UserNotification from 'util/UserNotification';
import Spinner from 'components/common/Spinner';
import useDataNodes from 'preflight/hooks/useDataNodes';
import { Alert, Badge, List, Button } from 'preflight/components/common';
import fetch from 'logic/rest/FetchProvider';
import { qualifyUrl } from 'util/URLUtils';
const P = styled.p`
max-width: 700px;
`;
const NodeId = styled(Badge)`
margin-right: 3px;
`;
const DataNodesOverview = () => {
const [resumingStartup, setResumingStartup] = useState(false);
const {
data: dataNodes,
isFetching: isFetchingDataNodes,
error: dataNodesFetchError,
isInitialLoading: isInitialLoadingDataNodes,
} = useDataNodes();
const resumeStartup = () => (
fetch('POST', qualifyUrl('/api/status/finish-config'), undefined, false)
.then(() => {
setResumingStartup(true);
})
.catch((error) => {
setResumingStartup(false);
UserNotification.error(`Resuming startup failed with: ${error}`,
'Could not resume startup');
})
);
return (
<>
<P>
Graylog data nodes offer a better integration with Graylog and simplify future updates.
Once a Graylog data node is running, you can click on &quot;Resume startup&quot;.
</P>
<P>
These are the data nodes which are currently registered.
The list is constantly updated. {isFetchingDataNodes && <Spinner text="" />}
</P>
{!!dataNodes.length && (
<>
<Space h="sm" />
<List spacing="xs">
{dataNodes.map(({
hostname,
transport_address,
short_node_id,
}) => (
<List.Item key={short_node_id}>
<NodeId title="Short node id">{short_node_id}</NodeId>
<span title="Transport address">{transport_address}</span>{' '}
<span title="Hostname">{hostname}</span>
</List.Item>
))}
</List>
</>
)}
{(!dataNodes.length && !isInitialLoadingDataNodes) && (
<Alert type="info">
No data nodes have been found.
</Alert>
)}
{dataNodesFetchError && (
<Alert type="danger">
There was an error fetching the data nodes: {dataNodesFetchError.message}
</Alert>
)}
<Space h="md" />
<Button onClick={resumeStartup} disabled={!dataNodes.length || resumingStartup} size="xs">
{resumingStartup ? <Spinner delay={0} text="Resuming startup..." /> : 'Resume startup'}
</Button>
</>
);
};
export default DataNodesOverview;

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import styled, { css, ThemeContext } from 'styled-components';
import { Alert as MantineAlert } from '@mantine/core';
import { useContext } from 'react';
import type { ColorVariants } from '../../theme/types';
const StyledAlert = styled(MantineAlert)(({ theme }) => css`
margin: ${theme.spacings.md} 0;
`);
type Props = {
children: React.ReactNode,
type: ColorVariants,
};
const Alert = ({ children, type }: Props) => {
const theme = useContext(ThemeContext);
const alertStyles = () => ({
root: {
borderColor: theme.colors.variant.lighter[type],
backgroundColor: `${theme.colors.variant.lightest[type]} !important`,
},
message: {
fontSize: theme.fonts.size.medium.rem,
},
});
return (
<StyledAlert styles={alertStyles} color={theme.colors.variant.lightest[type]}>
{children}
</StyledAlert>
);
};
export default Alert;

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import type { BadgeProps } from '@mantine/core';
import { Badge as MantineBadge } from '@mantine/core';
type Props = BadgeProps & {
title: string,
}
const Badge = ({ children, ...props }: Props) => (
<MantineBadge {...props}>
{children}
</MantineBadge>
);
export default Badge;

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import styled, { css } from 'styled-components';
import type { ListProps } from '@mantine/core';
import { List as MantineList } from '@mantine/core';
const StyledList = styled(MantineList)(({ theme }) => css`
color: ${theme.colors.global.textDefault};
`);
type ListComponent = ((props: ListProps) => React.ReactElement) & {
Item: typeof MantineList.Item
}
const List: ListComponent = ({ children, ...props }: ListProps) => (
<StyledList {...props}>
{children}
</StyledList>
);
List.Item = MantineList.Item;
export default List;

View File

@@ -18,10 +18,10 @@ import * as React from 'react';
import styled, { css } from 'styled-components';
import type { DefaultTheme } from 'styled-components';
import { Box, Title } from '@mantine/core';
import type { BoxProps } from '@mantine/core';
import type { BoxProps, TitleOrder } from '@mantine/core';
import Col from 'preflight/common/Col';
import Row from 'preflight/common/Row';
import Col from 'preflight/components/common/Col';
import Row from 'preflight/components/common/Row';
type ContainerType = BoxProps & {
theme: DefaultTheme,
@@ -42,23 +42,19 @@ const SectionContainer = styled(SubsectionContainer)(({ theme }: ContainerType)
background-color: ${theme.colors.global.contentBackground};
border: 1px solid ${theme.colors.variant.lighter.default};
border-radius: 4px;
min-height: 80vh;
`);
const SectionTitle = styled(Title)(({ theme }) => css`
margin-bottom: ${theme.spacings.md};
`);
type Props = {
title: React.ReactNode,
actions?: React.ReactNode,
titleOrder?: TitleOrder
};
const SectionHeader = ({ title, actions }: Props) => {
const SectionHeader = ({ title, actions, titleOrder }: Props) => {
return (
<Row>
<Col lg={6} md={6}>
<SectionTitle order={2}>{title}</SectionTitle>
<Title order={titleOrder}>{title}</Title>
</Col>
<Col lg={6} md={6}>
<TitleActionContainer>{actions}</TitleActionContainer>
@@ -69,12 +65,13 @@ const SectionHeader = ({ title, actions }: Props) => {
SectionHeader.defaultProps = {
actions: undefined,
titleOrder: 2,
};
export const Subsection = ({ title, children, actions }: React.PropsWithChildren<Props>) => {
export const Subsection = ({ title, children, actions, titleOrder }: React.PropsWithChildren<Props>) => {
return (
<SubsectionContainer component="section">
<SectionHeader title={title} actions={actions} />
<SectionHeader title={title} actions={actions} titleOrder={titleOrder} />
{children}
</SubsectionContainer>
);
@@ -82,12 +79,13 @@ export const Subsection = ({ title, children, actions }: React.PropsWithChildren
Subsection.defaultProps = {
actions: undefined,
titleOrder: undefined,
};
const Section = ({ title, children, actions }: React.PropsWithChildren<Props>) => {
const Section = ({ title, children, actions, titleOrder }: React.PropsWithChildren<Props>) => {
return (
<SectionContainer component="section">
<SectionHeader title={title} actions={actions} />
<SectionHeader title={title} actions={actions} titleOrder={titleOrder} />
{children}
</SectionContainer>
);
@@ -95,6 +93,7 @@ const Section = ({ title, children, actions }: React.PropsWithChildren<Props>) =
Section.defaultProps = {
actions: undefined,
titleOrder: undefined,
};
export default Section;

View File

@@ -14,6 +14,8 @@
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
export { default as Alert } from './Alert';
export { default as Badge } from './Badge';
export { default as Button } from './Button';
export { default as Col } from './Col';
export { default as Icon } from './Icon';
@@ -21,6 +23,7 @@ export { default as Menu } from './Menu';
export { default as MenuItem } from './MenuItem';
export { default as Row } from './Row';
export { default as Section } from './Section';
export { default as List } from './List';
export { default as MenuTarget } from './mantine/MenuTarget';
export { default as MenuDropdownWrapper } from './mantine/MenuDropdownWrapper';
export * from './mantine/imports';

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import { renderHook } from 'wrappedTestingLibrary/hooks';
import asMock from 'helpers/mocking/AsMock';
import fetch from 'logic/rest/FetchProvider';
import useDataNodes from './useDataNodes';
jest.mock('logic/rest/FetchProvider', () => jest.fn());
describe('useDataNodes', () => {
const availableDataNodes = [
{
id: 'data-node-id-1',
name: 'data-node-name',
transportAddress: 'transport.address1',
altNames: [],
status: 'UNCONFIGURED',
},
{
id: 'data-node-id-2',
name: 'data-node-name',
altNames: [],
transportAddress: 'transport.address2',
status: 'UNCONFIGURED',
},
{
id: 'data-node-id-3',
name: 'data-node-name',
altNames: [],
transportAddress: 'transport.address3',
status: 'UNCONFIGURED',
},
];
beforeEach(() => {
asMock(fetch).mockReturnValue(Promise.resolve(availableDataNodes));
});
it('should return data nodes CA status', async () => {
const { result, waitFor } = renderHook(() => useDataNodes());
expect(result.current.data).toEqual([]);
await waitFor(() => result.current.isFetching);
await waitFor(() => !result.current.isFetching);
expect(fetch).toHaveBeenCalledWith('GET', expect.stringContaining('/api/data_nodes'), undefined, false);
await waitFor(() => expect(result.current.data).toEqual(availableDataNodes));
});
});

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import { useQuery } from '@tanstack/react-query';
import { qualifyUrl } from 'util/URLUtils';
import fetch from 'logic/rest/FetchProvider';
import type { DataNodes } from 'preflight/types';
import type FetchError from 'logic/errors/FetchError';
const fetchDataNodes = () => (
fetch('GET', qualifyUrl('/api/data_nodes'), undefined, false)
);
const useDataNodes = (): {
data: DataNodes,
isFetching: boolean,
isInitialLoading: boolean,
error: FetchError
} => {
const {
data,
isFetching,
error,
isInitialLoading,
} = useQuery<DataNodes, FetchError>(
['data-nodes', 'overview'],
fetchDataNodes,
{
initialData: [],
refetchInterval: 3000,
});
return { data, isFetching, isInitialLoading, error };
};
export default useDataNodes;

View File

@@ -17,7 +17,7 @@
import React from 'react';
import styled, { css } from 'styled-components';
import { Button, Icon, Menu, MenuTarget, MenuItem, MenuDropdownWrapper, Text } from 'preflight/common';
import { Button, Icon, Menu, MenuTarget, MenuItem, MenuDropdownWrapper, Text } from 'preflight/components/common';
const StyledButton = styled(Button)(({ theme }) => css`
border-radius: 50px;

View File

@@ -20,7 +20,7 @@ import type { DefaultTheme } from 'styled-components';
import styled, { css } from 'styled-components';
import HelpMenu from 'preflight/navigation/HelpMenu';
import { Group, Header, Text } from 'preflight/common';
import { Group, Header, Text } from 'preflight/components/common';
import NavigationBrand from './NavigationBrand';
import ThemeModeToggle from './ThemeModeToggle';

View File

@@ -22,7 +22,7 @@ import styled, { css, withTheme } from 'styled-components';
import defer from 'lodash/defer';
import ThemePropTypes from 'preflight/theme/types';
import { Icon } from 'preflight/common';
import { Icon } from 'preflight/components/common';
import {
THEME_MODE_LIGHT,
THEME_MODE_DARK,

View File

@@ -61,6 +61,7 @@ const ThemeWrapper = ({ children }: Props) => {
},
},
spacing: {
xxs: theme.spacings.xxs,
xs: theme.spacings.xs,
sm: theme.spacings.sm,
md: theme.spacings.md,

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
export type DataNode = {
hostname: string,
id: string,
is_leader: boolean,
is_master: boolean,
last_seen: string,
node_id: string,
short_node_id: string,
transport_address: string,
type: string,
}
export type DataNodes = Array<DataNode>;

View File

@@ -22,6 +22,7 @@ const merge = require('webpack-merge');
const { EsbuildPlugin } = require('esbuild-loader');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const { DEFAULT_API_URL } = require('./webpack.vendor');
const supportedBrowsers = require('./supportedBrowsers');
const core = require('./webpack/core');
@@ -33,6 +34,8 @@ const TARGET = process.env.npm_lifecycle_event || 'build';
process.env.BABEL_ENV = TARGET;
const mode = TARGET.startsWith('build') ? 'production' : 'development';
const apiUrl = process.env.GRAYLOG_API_URL ?? DEFAULT_API_URL;
const baseConfig = {
mode,
name: 'preflight',
@@ -67,6 +70,17 @@ let webpackConfig;
if (mode === 'development') {
webpackConfig = merge(baseConfig, {
devServer: {
hot: false,
liveReload: true,
compress: true,
historyApiFallback: true,
proxy: {
'/api': {
target: apiUrl,
},
},
},
devtool: 'cheap-module-source-map',
output: {
filename: '[name].js',

View File

@@ -31,7 +31,7 @@ const supportedBrowsers = require('./supportedBrowsers');
const TARGET = process.env.npm_lifecycle_event || 'build';
process.env.BABEL_ENV = TARGET;
const DEFAULT_API_URL = 'http://localhost:9000';
export const DEFAULT_API_URL = 'http://localhost:9000';
const apiUrl = process.env.GRAYLOG_API_URL ?? DEFAULT_API_URL;
// eslint-disable-next-line no-console