diff --git a/graylog2-web-interface/src/components/inputs/Constants.ts b/graylog2-web-interface/src/components/inputs/Constants.ts new file mode 100644 index 0000000000..5e20bf0e60 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/Constants.ts @@ -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 + * . + */ +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.'; diff --git a/graylog2-web-interface/src/components/inputs/InputStateControl.tsx b/graylog2-web-interface/src/components/inputs/InputStateControl.tsx index 3ce44ed328..3016366720 100644 --- a/graylog2-web-interface/src/components/inputs/InputStateControl.tsx +++ b/graylog2-web-interface/src/components/inputs/InputStateControl.tsx @@ -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 ( - + ); } if (isInputRunning(inputStates, input.id)) { return ( - + ); } return ( - + ); }; diff --git a/graylog2-web-interface/src/components/inputs/InputsNotifications.test.tsx b/graylog2-web-interface/src/components/inputs/InputsNotifications.test.tsx new file mode 100644 index 0000000000..f9eafbdf14 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputsNotifications.test.tsx @@ -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 + * . + */ +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('', () => { + 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(); + + 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(); + await userEvent.click(screen.getByRole('button', { name: linkText })); + + expect(mockSetQueryParams).toHaveBeenCalledWith({ + filters: [`runtime_status=${status}`], + page: 1, + slice: undefined, + sliceCol: undefined, + }); + }); +}); diff --git a/graylog2-web-interface/src/components/inputs/InputsNotifications.tsx b/graylog2-web-interface/src/components/inputs/InputsNotifications.tsx index c992f846f4..2853c08e23 100644 --- a/graylog2-web-interface/src/components/inputs/InputsNotifications.tsx +++ b/graylog2-web-interface/src/components/inputs/InputsNotifications.tsx @@ -15,14 +15,17 @@ * . */ 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 = []; 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 ; + const items = getNotificationItems(inputs, inputStates, isLoading).map((item) => { + if (item.message === FAILED_MESSAGE) { + return { + ...item, + id: 'failed', + message: ( + <> + {FAILED_MESSAGE}{' '} + + . + + ), + }; + } + + if (item.message === SETUP_MESSAGE) { + return { + ...item, + id: 'setup', + message: ( + <> + {SETUP_MESSAGE}{' '} + + . + + ), + }; + } + + if (item.message === STOPPED_MESSAGE) { + return { + ...item, + id: 'stopped', + message: ( + <> + {STOPPED_MESSAGE}{' '} + + . + + ), + }; + } + + return item; + }); + + return ; }; export default InputsNotifications; diff --git a/graylog2-web-interface/src/components/inputs/NotificationBanner.test.tsx b/graylog2-web-interface/src/components/inputs/NotificationBanner.test.tsx index e0fff9d1a7..71d1be2bf5 100644 --- a/graylog2-web-interface/src/components/inputs/NotificationBanner.test.tsx +++ b/graylog2-web-interface/src/components/inputs/NotificationBanner.test.tsx @@ -71,4 +71,21 @@ describe('', () => { expect(screen.getByText('stopped.')).toBeInTheDocument(); expect(screen.getAllByRole('alert')).toHaveLength(1); }); + + it('renders rich message content with links', () => { + const items: Array = [ + { + severity: 'warning', + message: ( + <> + Inputs currently stopped will not receive traffic until started. . + + ), + }, + ]; + + render(); + + expect(screen.getByRole('button', { name: 'Show stopped inputs' })).toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/components/inputs/NotificationBanner.tsx b/graylog2-web-interface/src/components/inputs/NotificationBanner.tsx index 066969af57..881cb02f53 100644 --- a/graylog2-web-interface/src/components/inputs/NotificationBanner.tsx +++ b/graylog2-web-interface/src/components/inputs/NotificationBanner.tsx @@ -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) => { - {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 ( -
  • +
  • {item.message}
  • diff --git a/graylog2-web-interface/src/pages/InputsPage.tsx b/graylog2-web-interface/src/pages/InputsPage.tsx index 56190d6a86..bbd45fdfb4 100644 --- a/graylog2-web-interface/src/pages/InputsPage.tsx +++ b/graylog2-web-interface/src/pages/InputsPage.tsx @@ -66,9 +66,10 @@ const InputsPage = () => { {productName} nodes accept data via inputs. Launch or terminate as many inputs as you want here. )} - + +