Add input failures column to Inputs list (#25048)

* 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

* 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>
This commit is contained in:
Ousmane SAMBA
2026-03-10 13:48:03 +01:00
committed by GitHub
parent c4711312f7
commit 7c966ebc6a
5 changed files with 205 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ import type { InputSummary } from 'hooks/usePaginatedInputs';
import type { InputTypesSummary } from 'hooks/useInputTypes';
import type { InputStates } from 'hooks/useInputsStates';
import { TypeCell, NodeCell, ThroughputCell, ExpandedSectionToggleWrapper } from 'components/inputs/InputsOveriew';
import FailuresCell from 'components/inputs/InputsOveriew/cells/FailuresCell';
import { InputStateBadge } from 'components/inputs';
import Routes from 'routing/Routes';
import { Link } from 'components/common';
@@ -74,6 +75,14 @@ const customColumnRenderers = ({ inputTypes, inputStates }: Props): ColumnRender
),
staticWidth: 180,
},
input_failures: {
renderCell: (_failures: string, input: InputSummary) => (
<ExpandedSectionToggleWrapper id={input.id}>
<FailuresCell input={input} />
</ExpandedSectionToggleWrapper>
),
staticWidth: 130,
},
address: {
renderCell: (_address: string, input: InputSummary) => (
<ExpandedSectionToggleWrapper id={input.id}>

View File

@@ -27,15 +27,17 @@ const getInputsTableElements = () => {
'direction',
'desired_state',
'traffic',
'input_failures',
'node_id',
'address',
'port',
],
defaultColumnOrder: ['title', 'type', 'direction', 'desired_state', 'traffic', 'node_id', 'address', 'port'],
defaultColumnOrder: ['title', 'type', 'direction', 'desired_state', 'traffic', 'input_failures', 'node_id', 'address', 'port'],
};
const additionalAttributes = [
{ id: 'traffic', title: 'Traffic Last Minute' },
{ id: 'input_failures', title: 'Input Failures' },
{ id: 'address', title: 'Address' },
{ id: 'port', title: 'Port' },
];

View File

@@ -0,0 +1,124 @@
/*
* 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 { asMock } from 'helpers/mocking';
import { useMetrics } from 'hooks/useMetrics';
import type { ClusterMetric } from 'types/metrics';
import FailuresCell from './FailuresCell';
jest.mock('hooks/useMetrics', () => ({
useMetrics: jest.fn(),
}));
const input = {
id: 'input-1',
title: 'My Test Input',
type: 'org.graylog2.inputs.raw.tcp.RawTCPInput',
name: 'Raw/Plaintext TCP',
global: true,
node: '',
created_at: '2024-01-01T00:00:00Z',
creator_user_id: 'admin',
attributes: {},
static_fields: {},
content_pack: '',
};
const gauge = (fullName: string, value: number) => ({
full_name: fullName,
name: fullName.split('.').pop()!,
type: 'gauge' as const,
metric: { value },
});
const defaultMetricsData: ClusterMetric = {
'node-1': {
'org.graylog2.inputs.input-1.failures.input': gauge('org.graylog2.inputs.input-1.failures.input', 5),
'org.graylog2.inputs.input-1.failures.processing': gauge('org.graylog2.inputs.input-1.failures.processing', 3),
'org.graylog2.inputs.input-1.failures.indexing': gauge('org.graylog2.inputs.input-1.failures.indexing', 2),
},
};
describe('FailuresCell', () => {
beforeEach(() => {
asMock(useMetrics).mockReturnValue({ data: defaultMetricsData, isLoading: false });
});
it('renders the sum of all failure metrics across nodes', () => {
render(<FailuresCell input={input} />);
expect(screen.getByText('10')).toBeInTheDocument();
});
it('links to the Input Diagnosis page', () => {
render(<FailuresCell input={input} />);
const link = screen.getByRole('link', { name: /show input diagnosis/i });
expect(link).toHaveAttribute('href', '/system/input/diagnosis/input-1');
});
it('calls useMetrics with the correct metric names', () => {
render(<FailuresCell input={input} />);
expect(useMetrics).toHaveBeenCalledWith([
'org.graylog2.inputs.input-1.failures.input',
'org.graylog2.inputs.input-1.failures.processing',
'org.graylog2.inputs.input-1.failures.indexing',
]);
});
it('aggregates failure metrics from multiple nodes', () => {
const multiNodeData: ClusterMetric = {
'node-1': {
'org.graylog2.inputs.input-1.failures.input': gauge('org.graylog2.inputs.input-1.failures.input', 5),
'org.graylog2.inputs.input-1.failures.processing': gauge('org.graylog2.inputs.input-1.failures.processing', 3),
'org.graylog2.inputs.input-1.failures.indexing': gauge('org.graylog2.inputs.input-1.failures.indexing', 2),
},
'node-2': {
'org.graylog2.inputs.input-1.failures.input': gauge('org.graylog2.inputs.input-1.failures.input', 10),
'org.graylog2.inputs.input-1.failures.processing': gauge('org.graylog2.inputs.input-1.failures.processing', 7),
'org.graylog2.inputs.input-1.failures.indexing': gauge('org.graylog2.inputs.input-1.failures.indexing', 3),
},
};
asMock(useMetrics).mockReturnValue({ data: multiNodeData, isLoading: false });
render(<FailuresCell input={input} />);
expect(screen.getByText('30')).toBeInTheDocument();
});
it('shows 0 when no failure metrics exist for the input', () => {
asMock(useMetrics).mockReturnValue({ data: { 'node-1': {} }, isLoading: false });
render(<FailuresCell input={input} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('shows a spinner while loading', async () => {
asMock(useMetrics).mockReturnValue({ data: {}, isLoading: true });
render(<FailuresCell input={input} />);
expect(await screen.findByText(/loading/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
/*
* 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 { InputSummary } from 'hooks/usePaginatedInputs';
import { useMetrics } from 'hooks/useMetrics';
import { Link, Spinner } from 'components/common';
import { formatCount, getValueFromMetric } from 'components/inputs/helpers/InputThroughputUtils';
import Routes from 'routing/Routes';
type Props = {
input: InputSummary;
};
const FAILURE_SUFFIXES = ['failures.input', 'failures.processing', 'failures.indexing'] as const;
const buildMetricName = (inputId: string, suffix: string) => `org.graylog2.inputs.${inputId}.${suffix}`;
const StyledLink = styled(Link)<{ $hasFailures: boolean }>(
({ theme, $hasFailures }) => css`
color: ${$hasFailures ? theme.colors.variant.danger : 'inherit'};
font-weight: ${$hasFailures ? 'bold' : 'normal'};
`,
);
const FailuresCell = ({ input }: Props) => {
const metricNames = FAILURE_SUFFIXES.map((suffix) => buildMetricName(input.id, suffix));
const { data: metrics, isLoading } = useMetrics(metricNames);
if (isLoading) {
return <Spinner size="xs" />;
}
const totalFailures = metricNames.reduce((sum, metricName) => {
const aggregated = Object.keys(metrics).reduce(
(prev, nodeId) => prev + getValueFromMetric(metrics[nodeId]?.[metricName]),
0,
);
return sum + aggregated;
}, 0);
return (
<StyledLink
to={Routes.SYSTEM.INPUT_DIAGNOSIS(input.id)}
title={`Show input diagnosis for ${input.title}`}
$hasFailures={totalFailures > 0}>
{formatCount(totalFailures)}
</StyledLink>
);
};
export default FailuresCell;

View File

@@ -21,6 +21,7 @@ export { default as InputsActions } from './InputsActions';
export { default as TypeCell } from './cells/TypeCell';
export { default as NodeCell } from './cells/NodeCell';
export { default as ThroughputCell } from './cells/ThroughputCell';
export { default as FailuresCell } from './cells/FailuresCell';
export { default as ExpandedSectionToggleWrapper } from './ExpandedSectionToggleWrapper';
export { default as ThroughputSection } from './expanded-sections/ThroughputSection';
export { default as Connections } from './expanded-sections/Connections';