mirror of
https://github.com/Graylog2/graylog2-server.git
synced 2026-03-13 09:32:21 +08:00
Add filters links to Inputs notifications (#25195)
* first draft of getting a related identifier * add composite suggestion display * add computed field registry logic * fix linter * add input failures column to Inputs list * fix rebasing * fix import * add link to filter table on notifications - fix input ation button size - adjust copy * extract constants * fix linter * update to use useMetrics hook and fix review --------- Co-authored-by: Maxwell Anipah <maxwell.anipah@graylog.com> Co-authored-by: Laura Bergenthal-Grotlüschen <197286649+laura-b-g@users.noreply.github.com> Co-authored-by: Mohamed OULD HOCINE <106236152+gally47@users.noreply.github.com>
This commit is contained in:
20
graylog2-web-interface/src/components/inputs/Constants.ts
Normal file
20
graylog2-web-interface/src/components/inputs/Constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 const RUNTIME_STATUS_FILTER = 'runtime_status';
|
||||
export const FAILED_MESSAGE = 'Inputs have failed and will not receive traffic until started.';
|
||||
export const SETUP_MESSAGE = 'Inputs currently in setup mode will not receive traffic until started.';
|
||||
export const STOPPED_MESSAGE = 'Inputs currently stopped will not receive traffic until started.';
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { InputStatesStore } from 'stores/inputs/InputStatesStore';
|
||||
import { isInputRunning, isInputInSetupMode } from 'components/inputs/helpers/inputState';
|
||||
@@ -35,6 +36,12 @@ type Props = {
|
||||
openWizard: () => void;
|
||||
};
|
||||
|
||||
const StateActionButton = styled(Button)(
|
||||
() => css`
|
||||
min-width: 95px;
|
||||
`,
|
||||
);
|
||||
|
||||
const InputStateControl = ({ input, openWizard, inputStates }: Props) => {
|
||||
const sendTelemetry = useSendTelemetry();
|
||||
const { pathname } = useLocation();
|
||||
@@ -78,24 +85,24 @@ const InputStateControl = ({ input, openWizard, inputStates }: Props) => {
|
||||
|
||||
if (inputSetupFeatureFlagIsEnabled && isInputInSetupMode(inputStates, input.id)) {
|
||||
return (
|
||||
<Button bsStyle="warning" bsSize="xsmall" onClick={setupInput}>
|
||||
<StateActionButton bsStyle="warning" bsSize="xsmall" onClick={setupInput}>
|
||||
Set-up Input
|
||||
</Button>
|
||||
</StateActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInputRunning(inputStates, input.id)) {
|
||||
return (
|
||||
<Button bsSize="xsmall" onClick={stopInput} disabled={isLoading}>
|
||||
<StateActionButton bsSize="xsmall" onClick={stopInput} disabled={isLoading}>
|
||||
{isLoading ? 'Stopping...' : 'Stop input'}
|
||||
</Button>
|
||||
</StateActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button bsStyle="primary" bsSize="xsmall" onClick={startInput} disabled={isLoading}>
|
||||
<StateActionButton bsStyle="primary" bsSize="xsmall" onClick={startInput} disabled={isLoading}>
|
||||
{isLoading ? 'Starting...' : 'Start input'}
|
||||
</Button>
|
||||
</StateActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 } from 'wrappedTestingLibrary';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { asMock } from 'helpers/mocking';
|
||||
import useInputsStates from 'hooks/useInputsStates';
|
||||
import { useStore } from 'stores/connect';
|
||||
import { useQueryParams } from 'routing/QueryParams';
|
||||
import type { InputSummary } from 'hooks/usePaginatedInputs';
|
||||
|
||||
import InputsNotifications from './InputsNotifications';
|
||||
|
||||
const mockInputsActionsList = jest.fn();
|
||||
const mockSetQueryParams = jest.fn();
|
||||
|
||||
jest.mock('hooks/useInputsStates');
|
||||
jest.mock('stores/connect', () => ({
|
||||
useStore: jest.fn(),
|
||||
}));
|
||||
jest.mock('stores/inputs/InputsStore', () => ({
|
||||
InputsStore: {},
|
||||
InputsActions: {
|
||||
list: (...args: any) => mockInputsActionsList(...args),
|
||||
},
|
||||
}));
|
||||
jest.mock('routing/QueryParams', () => ({
|
||||
...jest.requireActual('routing/QueryParams'),
|
||||
useQueryParams: jest.fn(),
|
||||
}));
|
||||
|
||||
const buildInputState = (state: 'RUNNING' | 'FAILED' | 'FAILING' | 'SETUP') =>
|
||||
({ state, id: 'state-id', detailed_message: null, message_input: {} as InputSummary });
|
||||
|
||||
describe('<InputsNotifications />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
asMock(useQueryParams).mockReturnValue([{}, mockSetQueryParams]);
|
||||
asMock(useStore).mockReturnValue([{ id: 'input-1' }, { id: 'input-2' }]);
|
||||
});
|
||||
|
||||
it('renders filter links for failed/setup/stopped warnings', () => {
|
||||
asMock(useInputsStates).mockReturnValue({
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
data: {
|
||||
'input-1': {
|
||||
node1: buildInputState('FAILED'),
|
||||
node2: buildInputState('SETUP'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<InputsNotifications />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show failed inputs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show inputs in setup mode' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show stopped inputs' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Show failed inputs', 'FAILED'],
|
||||
['Show inputs in setup mode', 'SETUP'],
|
||||
['Show stopped inputs', 'NOT_RUNNING'],
|
||||
])('applies %s filter and resets table filter state', async (linkText, status) => {
|
||||
asMock(useInputsStates).mockReturnValue({
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
data: {
|
||||
'input-1': {
|
||||
node1: buildInputState('FAILED'),
|
||||
node2: buildInputState('SETUP'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<InputsNotifications />);
|
||||
await userEvent.click(screen.getByRole('button', { name: linkText }));
|
||||
|
||||
expect(mockSetQueryParams).toHaveBeenCalledWith({
|
||||
filters: [`runtime_status=${status}`],
|
||||
page: 1,
|
||||
slice: undefined,
|
||||
sliceCol: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,17 @@
|
||||
* <http://www.mongodb.com/licensing/server-side-public-license>.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Button } from 'components/bootstrap';
|
||||
import type { Input } from 'components/messageloaders/Types';
|
||||
import useInputsStates from 'hooks/useInputsStates';
|
||||
import type { InputStates, InputState } from 'hooks/useInputsStates';
|
||||
import { useStore } from 'stores/connect';
|
||||
import { InputsStore, InputsActions } from 'stores/inputs/InputsStore';
|
||||
import { useQueryParams, ArrayParam, NumberParam, StringParam } from 'routing/QueryParams';
|
||||
|
||||
import { RUNTIME_STATUS_FILTER, FAILED_MESSAGE, SETUP_MESSAGE, STOPPED_MESSAGE } from './Constants';
|
||||
import NotificationBanner from './NotificationBanner';
|
||||
import type { NotificationItem } from './NotificationBanner';
|
||||
|
||||
@@ -58,18 +61,15 @@ const getNotificationItems = (
|
||||
const result: Array<NotificationItem> = [];
|
||||
|
||||
if (hasInputInState(inputStates, [INPUT_STATES.FAILED, INPUT_STATES.FAILING])) {
|
||||
result.push({
|
||||
severity: 'danger',
|
||||
message: 'in failed state. Failed or failing inputs will not receive traffic until fixed.',
|
||||
});
|
||||
result.push({ severity: 'danger', message: FAILED_MESSAGE });
|
||||
}
|
||||
|
||||
if (hasInputInState(inputStates, INPUT_STATES.SETUP)) {
|
||||
result.push({ severity: 'warning', message: 'in setup mode. Inputs will not receive traffic until started.' });
|
||||
result.push({ severity: 'warning', message: SETUP_MESSAGE });
|
||||
}
|
||||
|
||||
if (inputs.some((input) => !inputStates[input.id])) {
|
||||
result.push({ severity: 'warning', message: 'stopped. Stopped Inputs will not receive traffic until started.' });
|
||||
result.push({ severity: 'warning', message: STOPPED_MESSAGE });
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -78,6 +78,12 @@ const getNotificationItems = (
|
||||
const InputsNotifications = () => {
|
||||
const { data: inputStates, isLoading } = useInputsStates();
|
||||
const inputs = useStore(InputsStore, (state) => state.inputs);
|
||||
const [, setQueryParams] = useQueryParams({
|
||||
filters: ArrayParam,
|
||||
page: NumberParam,
|
||||
slice: StringParam,
|
||||
sliceCol: StringParam,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
InputsActions.list();
|
||||
@@ -86,9 +92,68 @@ const InputsNotifications = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const items = getNotificationItems(inputs, inputStates, isLoading);
|
||||
const applyRuntimeStatusFilter = useCallback((status: 'FAILED' | 'SETUP' | 'NOT_RUNNING') => {
|
||||
setQueryParams({
|
||||
filters: [`${RUNTIME_STATUS_FILTER}=${status}`],
|
||||
page: 1,
|
||||
slice: undefined,
|
||||
sliceCol: undefined,
|
||||
});
|
||||
}, [setQueryParams]);
|
||||
|
||||
return <NotificationBanner title="One or more inputs are currently" items={items} />;
|
||||
const items = getNotificationItems(inputs, inputStates, isLoading).map((item) => {
|
||||
if (item.message === FAILED_MESSAGE) {
|
||||
return {
|
||||
...item,
|
||||
id: 'failed',
|
||||
message: (
|
||||
<>
|
||||
{FAILED_MESSAGE}{' '}
|
||||
<Button bsStyle="link" onClick={() => applyRuntimeStatusFilter('FAILED')}>
|
||||
Show failed inputs
|
||||
</Button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.message === SETUP_MESSAGE) {
|
||||
return {
|
||||
...item,
|
||||
id: 'setup',
|
||||
message: (
|
||||
<>
|
||||
{SETUP_MESSAGE}{' '}
|
||||
<Button bsStyle="link" onClick={() => applyRuntimeStatusFilter('SETUP')}>
|
||||
Show inputs in setup mode
|
||||
</Button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.message === STOPPED_MESSAGE) {
|
||||
return {
|
||||
...item,
|
||||
id: 'stopped',
|
||||
message: (
|
||||
<>
|
||||
{STOPPED_MESSAGE}{' '}
|
||||
<Button bsStyle="link" onClick={() => applyRuntimeStatusFilter('NOT_RUNNING')}>
|
||||
Show stopped inputs
|
||||
</Button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return <NotificationBanner title="Warning" items={items} />;
|
||||
};
|
||||
|
||||
export default InputsNotifications;
|
||||
|
||||
@@ -71,4 +71,21 @@ describe('<NotificationBanner>', () => {
|
||||
expect(screen.getByText('stopped.')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders rich message content with links', () => {
|
||||
const items: Array<NotificationItem> = [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: (
|
||||
<>
|
||||
Inputs currently stopped will not receive traffic until started. <button type="button">Show stopped inputs</button>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
render(<NotificationBanner title="Test" items={items} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show stopped inputs' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,9 @@ import Icon from 'components/common/Icon';
|
||||
type NotificationSeverity = 'danger' | 'warning';
|
||||
|
||||
type NotificationItem = {
|
||||
id?: string;
|
||||
severity: NotificationSeverity;
|
||||
message: string;
|
||||
message: React.ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -85,11 +86,12 @@ const NotificationBanner = ({ title, items }: Props) => {
|
||||
<Col md={12}>
|
||||
<StyledAlert bsStyle="info" noIcon title={title}>
|
||||
<NotificationList>
|
||||
{items.map((item) => {
|
||||
{items.map((item, index) => {
|
||||
const SeverityIcon = ICON_BY_SEVERITY[item.severity];
|
||||
const key = item.id ?? (typeof item.message === 'string' ? item.message : `${item.severity}-${index}`);
|
||||
|
||||
return (
|
||||
<li key={item.message}>
|
||||
<li key={key}>
|
||||
<SeverityIcon name="error" />
|
||||
<span>{item.message}</span>
|
||||
</li>
|
||||
|
||||
@@ -66,9 +66,10 @@ const InputsPage = () => {
|
||||
<span>{productName} nodes accept data via inputs. Launch or terminate as many inputs as you want here.</span>
|
||||
)}
|
||||
</PageHeader>
|
||||
<InputsNotifications />
|
||||
<Row className="content">
|
||||
<Col md={12}>
|
||||
<InputsNotifications />
|
||||
|
||||
<InputsOverview inputTypeDescriptions={inputTypeDescriptions} inputTypes={inputTypes} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
Reference in New Issue
Block a user