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:
Ousmane SAMBA
2026-03-10 14:56:05 +01:00
committed by GitHub
parent 9dd1a00330
commit aa24657a4f
7 changed files with 233 additions and 19 deletions

View 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.';

View File

@@ -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>
);
};

View File

@@ -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,
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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>