Input stop/start button UX fixes (#24659)

* fix Input stop/start button permissions

* fix: initial button state after creating an input.

* cl

* fix review comment

---------

Co-authored-by: Ousmane SAMBA <ousmane@graylog.com>
Co-authored-by: Laura Bergenthal-Grotlüschen <laura.bergenthalgrotlueschen@graylog.com>
This commit is contained in:
Mohamed OULD HOCINE
2026-01-20 12:09:14 +01:00
committed by GitHub
parent 98409b9520
commit 895601595f
7 changed files with 228 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
type = "f"
message = "Input stop/start button UX fixes"
issues = ["24235", "graylog-plugin-enterprise#12659"]
pulls = ["24659"]

View File

@@ -243,7 +243,7 @@ declare module 'graylog-web-plugin/plugin' {
indexsets_field_restrictions: 'edit';
indices: 'read' | 'changestate' | 'failures';
input_types: 'create';
inputs: 'create' | 'edit' | 'read' | 'terminate';
inputs: 'create' | 'edit' | 'read' | 'terminate' | 'changestate';
journal: 'read';
jvmstats: 'read';
lbstatus: 'change';

View File

@@ -0,0 +1,141 @@
/*
* 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 useFeature from 'hooks/useFeature';
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
import useLocation from 'routing/useLocation';
import type { Input } from 'components/messageloaders/Types';
import type { InputStates } from 'hooks/useInputsStates';
import { asMock } from 'helpers/mocking';
import InputStateControl from './InputStateControl';
jest.mock('logic/telemetry/useSendTelemetry', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('routing/useLocation', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useFeature');
const baseInput: Input = {
id: 'input-id',
title: 'Test Input',
name: 'Test Input',
type: 'org.graylog2.inputs.raw.udp.RawUDPInput',
global: true,
node: 'node-1',
created_at: '2024-01-01T00:00:00.000Z',
creator_user_id: 'user',
content_pack: false,
static_fields: {},
attributes: {},
};
const renderSUT = (inputStates: InputStates, featureEnabled = true) => {
asMock(useFeature).mockReturnValue(featureEnabled);
asMock(useSendTelemetry).mockReturnValue(jest.fn());
asMock(useLocation).mockReturnValue({
pathname: '/system/inputs',
search: '',
hash: '',
state: null,
key: 'mock-key',
});
return render(<InputStateControl input={baseInput} inputStates={inputStates} openWizard={jest.fn()} />);
};
describe('InputStateControl', () => {
it('shows setup state when feature is enabled and input state is not loaded yet', async () => {
renderSUT({});
expect(await screen.findByRole('button', { name: /set-up input/i })).toBeInTheDocument();
});
it('falls back to start when feature is disabled and input state is not loaded yet', async () => {
renderSUT({}, false);
expect(await screen.findByRole('button', { name: /start input/i })).toBeInTheDocument();
});
it('shows stop when input is running', async () => {
const runningStates: InputStates = {
[baseInput.id]: {
node1: {
id: baseInput.id,
state: 'RUNNING',
detailed_message: null,
message_input: {
title: baseInput.title,
global: baseInput.global,
name: baseInput.name,
content_pack: '',
id: baseInput.id,
created_at: baseInput.created_at,
type: baseInput.type,
creator_user_id: baseInput.creator_user_id,
attributes: baseInput.attributes,
static_fields: baseInput.static_fields,
node: baseInput.node,
},
},
},
};
renderSUT(runningStates);
expect(await screen.findByRole('button', { name: /stop input/i })).toBeInTheDocument();
});
it('shows start after stopping an input instead of setup', async () => {
const runningStates: InputStates = {
[baseInput.id]: {
node1: {
id: baseInput.id,
state: 'RUNNING',
detailed_message: null,
message_input: {
title: baseInput.title,
global: baseInput.global,
name: baseInput.name,
content_pack: '',
id: baseInput.id,
created_at: baseInput.created_at,
type: baseInput.type,
creator_user_id: baseInput.creator_user_id,
attributes: baseInput.attributes,
static_fields: baseInput.static_fields,
node: baseInput.node,
},
},
},
};
const { rerender } = renderSUT(runningStates);
expect(await screen.findByRole('button', { name: /stop input/i })).toBeInTheDocument();
rerender(<InputStateControl input={baseInput} inputStates={{}} openWizard={jest.fn()} />);
expect(await screen.findByRole('button', { name: /start input/i })).toBeInTheDocument();
});
});

View File

@@ -28,6 +28,7 @@ import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants';
import { Button } from 'components/bootstrap';
import { INPUT_SETUP_MODE_FEATURE_FLAG } from 'components/inputs/InputSetupWizard';
import type { InputStates } from 'hooks/useInputsStates';
import useIsInitialUnknownInputState from 'components/inputs/hooks/useIsInitialUnknownInputState';
type Props = {
input: Input;
@@ -40,6 +41,7 @@ const InputStateControl = ({ input, openWizard, inputStates }: Props) => {
const { pathname } = useLocation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const inputSetupFeatureFlagIsEnabled = useFeature(INPUT_SETUP_MODE_FEATURE_FLAG);
const isInitialUnknownState = useIsInitialUnknownInputState(inputStates, input.id);
const startInput = () => {
setIsLoading(true);
@@ -75,7 +77,10 @@ const InputStateControl = ({ input, openWizard, inputStates }: Props) => {
openWizard();
};
if (inputSetupFeatureFlagIsEnabled && isInputInSetupMode(inputStates, input.id)) {
if (
inputSetupFeatureFlagIsEnabled &&
(isInputInSetupMode(inputStates, input.id) || isInitialUnknownState)
) {
return (
<Button bsStyle="warning" bsSize="xsmall" onClick={setupInput}>
Set-up Input

View File

@@ -27,6 +27,7 @@ import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
import { asMock } from 'helpers/mocking';
import useFeature from 'hooks/useFeature';
import useInputMutations from 'hooks/useInputMutations';
import usePermissions from 'hooks/usePermissions';
import InputsActions from './InputsActions';
@@ -46,6 +47,7 @@ jest.mock('routing/useLocation', () => ({
}));
jest.mock('hooks/useFeature');
jest.mock('hooks/useInputMutations');
jest.mock('hooks/usePermissions');
jest.mock('stores/inputs/InputStatesStore', () => ({
__esModule: true,
default: {
@@ -149,6 +151,7 @@ describe('InputsActions', () => {
updateInput: updateInputMock,
deleteInput: deleteInputMock,
} as any);
asMock(usePermissions).mockReturnValue({ isPermitted: () => true });
});
it('renders Received messages button with correct query for standard input', () => {
@@ -179,6 +182,38 @@ describe('InputsActions', () => {
expect(await screen.findByText(/InputSetupWizard/i)).toBeInTheDocument();
});
it('renders input state controls when user has changestate permission', async () => {
const input = {
...baseInput,
id: 'input-changestate',
title: 'Input with changestate only',
type: 'org.graylog2.inputs.gelf.udp.GELFUDPInput',
};
const isPermitted = jest.fn((permission) => permission === `inputs:changestate:${input.id}`);
asMock(usePermissions).mockReturnValue({ isPermitted });
renderSUT(input);
expect(await screen.findByRole('button', { name: /set up input/i })).toBeInTheDocument();
expect(isPermitted).toHaveBeenCalledWith(`inputs:changestate:${input.id}`);
});
it('does not render input state controls without changestate permission', () => {
const input = {
...baseInput,
id: 'input-no-changestate',
title: 'Input without changestate',
type: 'org.graylog2.inputs.gelf.udp.GELFUDPInput',
};
asMock(usePermissions).mockReturnValue({ isPermitted: () => false });
renderSUT(input);
expect(screen.queryByRole('button', { name: /set up input/i })).not.toBeInTheDocument();
});
it('opens Static Field form when Add static field is selected', async () => {
const input = {
...baseInput,

View File

@@ -155,7 +155,7 @@ const InputsActions = ({ input, inputTypes: _, inputTypeDescriptions, currentNod
</LinkContainer>
</IfPermitted>
<IfPermitted permissions={[`inputs:edit:${input.id}`, `input_types:create:${input.type}`]}>
<IfPermitted permissions={`inputs:changestate:${input.id}`}>
{!isLoadingInputStates && (
<InputStateControl
key={`input-state-control-${input.id}`}

View File

@@ -0,0 +1,39 @@
/*
* 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 { useEffect, useMemo } from 'react';
import type { InputStates } from 'hooks/useInputsStates';
const useIsInitialUnknownInputState = (inputStates: InputStates, inputId: string) => {
const seenInputIds = useMemo(() => new Set<string>(), []);
useEffect(() => {
if (!inputStates) {
return;
}
Object.keys(inputStates).forEach((id) => {
seenInputIds.add(id);
});
}, [inputStates, seenInputIds]);
const hasKnownState = !!inputStates?.[inputId];
return !hasKnownState && !seenInputIds.has(inputId);
};
export default useIsInitialUnknownInputState;