Skip to content

Commit a05a612

Browse files
ousmaneolaura-b-g
authored andcommitted
Frontend for the pluggable entity metrics endpoint (Inputs page) (#26094)
* Add useEntityTitles hook for bulk id -> title resolution * Add useInputMetrics hook and per-page metrics context for Inputs * Add Message Count, Extractors, Associated Streams columns to Inputs * adjust extractors permissions * fix review --------- Co-authored-by: Laura Bergenthal-Grotlüschen <197286649+laura-b-g@users.noreply.github.com>
1 parent bbc0e61 commit a05a612

24 files changed

Lines changed: 1665 additions & 21 deletions

graylog2-web-interface/src/components/inputs/InputsOveriew/ColumnRenderers.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import type { InputTypesSummary } from 'hooks/useInputTypes';
2222
import type { InputStates } from 'hooks/useInputsStates';
2323
import { TypeCell, NodeCell, ThroughputCell, ExpandedSectionToggleWrapper } from 'components/inputs/InputsOveriew';
2424
import FailuresCell from 'components/inputs/InputsOveriew/cells/FailuresCell';
25+
import MessageCountCell from 'components/inputs/InputsOveriew/cells/MessageCountCell';
26+
import ExtractorCountCell from 'components/inputs/InputsOveriew/cells/ExtractorCountCell';
27+
import AssociatedStreamsCell from 'components/inputs/InputsOveriew/cells/AssociatedStreamsCell';
28+
import { METRIC_COLUMN_IDS } from 'components/inputs/InputsOveriew/metricColumns';
2529
import { InputStateBadge } from 'components/inputs';
2630
import Routes from 'routing/Routes';
2731
import { Link } from 'components/common';
@@ -97,6 +101,18 @@ const customColumnRenderers = ({ inputTypes, inputStates }: Props): ColumnRender
97101
),
98102
staticWidth: 'matchHeader',
99103
},
104+
[METRIC_COLUMN_IDS.messagesPerStream]: {
105+
renderCell: (_value: unknown, input: InputSummary) => <MessageCountCell input={input} />,
106+
staticWidth: 180,
107+
},
108+
[METRIC_COLUMN_IDS.extractorCount]: {
109+
renderCell: (_value: unknown, input: InputSummary) => <ExtractorCountCell input={input} />,
110+
staticWidth: 130,
111+
},
112+
[METRIC_COLUMN_IDS.associatedStreams]: {
113+
renderCell: (_value: unknown, input: InputSummary) => <AssociatedStreamsCell input={input} />,
114+
staticWidth: 180,
115+
},
100116
},
101117
});
102118

graylog2-web-interface/src/components/inputs/InputsOveriew/Constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
1717
import type { Sort } from 'stores/PaginationTypes';
18+
import { METRIC_COLUMN_IDS, METRIC_COLUMN_TITLES } from 'components/inputs/InputsOveriew/metricColumns';
1819

1920
const getInputsTableElements = () => {
2021
const tableLayout = {
@@ -39,6 +40,9 @@ const getInputsTableElements = () => {
3940
'desired_state',
4041
'traffic',
4142
'input_failures',
43+
METRIC_COLUMN_IDS.messagesPerStream,
44+
METRIC_COLUMN_IDS.extractorCount,
45+
METRIC_COLUMN_IDS.associatedStreams,
4246
'node_id',
4347
'address',
4448
'port',
@@ -50,6 +54,9 @@ const getInputsTableElements = () => {
5054
{ id: 'input_failures', title: 'Input Failures' },
5155
{ id: 'address', title: 'Address' },
5256
{ id: 'port', title: 'Port' },
57+
{ id: METRIC_COLUMN_IDS.messagesPerStream, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.messagesPerStream] },
58+
{ id: METRIC_COLUMN_IDS.extractorCount, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.extractorCount] },
59+
{ id: METRIC_COLUMN_IDS.associatedStreams, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.associatedStreams] },
5360
];
5461

5562
return {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
import * as React from 'react';
18+
import { createContext, useContext } from 'react';
19+
20+
import useInputMetrics from 'hooks/useInputMetrics';
21+
import type { InputMetricField, InputMetrics, InputMetricsByInputId } from 'hooks/useInputMetrics';
22+
23+
type InputMetricsContextValue = {
24+
metricsByInputId: InputMetricsByInputId;
25+
isInitialLoading: boolean;
26+
isError: boolean;
27+
isFieldRequested: (field: InputMetricField) => boolean;
28+
};
29+
30+
const EMPTY_CONTEXT: InputMetricsContextValue = {
31+
metricsByInputId: {},
32+
isInitialLoading: false,
33+
isError: false,
34+
isFieldRequested: () => false,
35+
};
36+
37+
const InputMetricsContext = createContext<InputMetricsContextValue>(EMPTY_CONTEXT);
38+
39+
type Props = React.PropsWithChildren<{
40+
inputIds: Array<string>;
41+
fields: Array<InputMetricField>;
42+
}>;
43+
44+
export const InputMetricsProvider = ({ inputIds, fields, children = undefined }: Props) => {
45+
const { metricsByInputId, isInitialLoading, isError } = useInputMetrics(inputIds, fields);
46+
const requestedFields = new Set(fields);
47+
48+
const value: InputMetricsContextValue = {
49+
metricsByInputId,
50+
isInitialLoading,
51+
isError,
52+
isFieldRequested: (field) => requestedFields.has(field),
53+
};
54+
55+
return <InputMetricsContext.Provider value={value}>{children}</InputMetricsContext.Provider>;
56+
};
57+
58+
export const useInputMetricsContext = (): InputMetricsContextValue => useContext(InputMetricsContext);
59+
60+
export const useInputMetricsFor = (
61+
inputId: string,
62+
): {
63+
metrics: InputMetrics | undefined;
64+
isInitialLoading: boolean;
65+
isError: boolean;
66+
} => {
67+
const { metricsByInputId, isInitialLoading, isError } = useContext(InputMetricsContext);
68+
69+
return {
70+
metrics: metricsByInputId[inputId],
71+
isInitialLoading,
72+
isError,
73+
};
74+
};
75+
76+
export default InputMetricsContext;

graylog2-web-interface/src/components/inputs/InputsOveriew/InputsOverview.tsx

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* <http://www.mongodb.com/licensing/server-side-public-license>.
1616
*/
1717
import * as React from 'react';
18-
import { useCallback, useMemo } from 'react';
18+
import { useState } from 'react';
1919
import * as Immutable from 'immutable';
2020

2121
import type { NodeInfo } from 'stores/nodes/NodesStore';
@@ -29,8 +29,13 @@ import type { InputTypesSummary } from 'hooks/useInputTypes';
2929
import type { InputTypeDescriptionsResponse } from 'hooks/useInputTypesDescriptions';
3030
import useInputsStates from 'hooks/useInputsStates';
3131
import useTableElements from 'components/inputs/InputsOveriew/useTableElements';
32+
import { InputMetricsProvider } from 'components/inputs/InputsOveriew/InputMetricsContext';
33+
import { backendFieldsForVisibleColumns } from 'components/inputs/InputsOveriew/metricColumns';
34+
import useUserLayoutPreferences from 'components/common/EntityDataTable/hooks/useUserLayoutPreferences';
35+
import { ATTRIBUTE_STATUS } from 'components/common/EntityDataTable/Constants';
3236
import { IfPermitted } from 'components/common';
3337
import type { SearchParams } from 'stores/PaginationTypes';
38+
import type { PaginatedResponse } from 'components/common/PaginatedEntityTable/useFetchEntities';
3439

3540
type Input = {
3641
id: string;
@@ -64,15 +69,16 @@ const InputsOverview = ({
6469
const { data: inputStates } = useInputsStates();
6570
const { tableLayout, additionalAttributes } = getInputsTableElements();
6671
const resolvedTableLayout = entityTableId ? { ...tableLayout, entityTableId } : tableLayout;
67-
const resolvedKeyFn = useCallback(
68-
(searchParams: SearchParams) => [...KEY_PREFIX, entityTableId ?? tableLayout.entityTableId, searchParams],
69-
[entityTableId, tableLayout.entityTableId],
70-
);
72+
const resolvedKeyFn = (searchParams: SearchParams) => [
73+
...KEY_PREFIX,
74+
entityTableId ?? tableLayout.entityTableId,
75+
searchParams,
76+
];
7177
const { entityActions, expandedSections } = useTableElements({
7278
inputTypes,
7379
inputTypeDescriptions,
7480
});
75-
const columnRenderers = useMemo(() => customColumnRenderers({ inputTypes, inputStates }), [inputTypes, inputStates]);
81+
const columnRenderers = customColumnRenderers({ inputTypes, inputStates });
7682
const fetchEntities = (options: SearchParams) => {
7783
const optionsCopy = { ...options };
7884

@@ -85,28 +91,52 @@ const InputsOverview = ({
8591
return fetchInputs(optionsCopy);
8692
};
8793

94+
const [visibleInputIds, setVisibleInputIds] = useState<Array<string>>([]);
95+
const onDataLoaded = (data: PaginatedResponse<Input>) => {
96+
const nextVisibleInputIds = data.list.map((entity) => entity.id);
97+
98+
setVisibleInputIds((currentVisibleInputIds) => {
99+
const hasSameInputIds =
100+
currentVisibleInputIds.length === nextVisibleInputIds.length &&
101+
currentVisibleInputIds.every((inputId, index) => inputId === nextVisibleInputIds[index]);
102+
103+
return hasSameInputIds ? currentVisibleInputIds : nextVisibleInputIds;
104+
});
105+
};
106+
107+
const { data: layoutPreferences } = useUserLayoutPreferences(resolvedTableLayout.entityTableId);
108+
const userPrefs = layoutPreferences?.attributes ?? {};
109+
const userSelection = Object.entries(userPrefs)
110+
.filter(([, pref]) => pref.status === ATTRIBUTE_STATUS.show)
111+
.map(([attributeId]) => attributeId);
112+
const visibleColumns = userSelection.length > 0 ? userSelection : resolvedTableLayout.defaultDisplayedAttributes;
113+
const requestedFields = backendFieldsForVisibleColumns(visibleColumns);
114+
88115
return (
89116
<div>
90117
{!node && !global && (
91118
<IfPermitted permissions="inputs:create">
92119
<CreateInputControl />
93120
</IfPermitted>
94121
)}
95-
<PaginatedEntityTable<Input>
96-
humanName="inputs"
97-
additionalAttributes={additionalAttributes}
98-
queryHelpComponent={<QueryHelper entityName={entityName} />}
99-
entityActions={entityActions}
100-
tableLayout={resolvedTableLayout}
101-
fetchEntities={fetchEntities}
102-
expandedSectionRenderers={expandedSections}
103-
keyFn={resolvedKeyFn}
104-
bulkSelection={undefined}
105-
withoutURLParams={withoutURLParams}
106-
entityAttributesAreCamelCase={false}
107-
filterValueRenderers={{}}
108-
columnRenderers={columnRenderers}
109-
/>
122+
<InputMetricsProvider inputIds={visibleInputIds} fields={requestedFields}>
123+
<PaginatedEntityTable<Input>
124+
humanName="inputs"
125+
additionalAttributes={additionalAttributes}
126+
queryHelpComponent={<QueryHelper entityName={entityName} />}
127+
entityActions={entityActions}
128+
tableLayout={resolvedTableLayout}
129+
fetchEntities={fetchEntities}
130+
onDataLoaded={onDataLoaded}
131+
expandedSectionRenderers={expandedSections}
132+
keyFn={resolvedKeyFn}
133+
bulkSelection={undefined}
134+
withoutURLParams={withoutURLParams}
135+
entityAttributesAreCamelCase={false}
136+
filterValueRenderers={{}}
137+
columnRenderers={columnRenderers}
138+
/>
139+
</InputMetricsProvider>
110140
</div>
111141
);
112142
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
import * as React from 'react';
18+
import { render, screen } from 'wrappedTestingLibrary';
19+
import userEvent from '@testing-library/user-event';
20+
21+
import { asMock } from 'helpers/mocking';
22+
import { useInputMetricsFor } from 'components/inputs/InputsOveriew/InputMetricsContext';
23+
import useExpandedSections from 'components/common/EntityDataTable/hooks/useExpandedSections';
24+
25+
import AssociatedStreamsCell from './AssociatedStreamsCell';
26+
27+
jest.mock('components/inputs/InputsOveriew/InputMetricsContext', () => ({
28+
useInputMetricsFor: jest.fn(),
29+
}));
30+
jest.mock('components/common/EntityDataTable/hooks/useExpandedSections', () => jest.fn());
31+
32+
const input = {
33+
id: 'input-1',
34+
title: 'My Test Input',
35+
type: 'org.graylog2.inputs.raw.tcp.RawTCPInput',
36+
name: 'Raw/Plaintext TCP',
37+
global: true,
38+
node: '',
39+
created_at: '2024-01-01T00:00:00Z',
40+
creator_user_id: 'admin',
41+
attributes: {},
42+
static_fields: {},
43+
content_pack: '',
44+
};
45+
46+
describe('AssociatedStreamsCell', () => {
47+
const toggleSection = jest.fn();
48+
49+
beforeEach(() => {
50+
jest.clearAllMocks();
51+
asMock(useExpandedSections).mockReturnValue({ toggleSection, expandedSections: {} });
52+
});
53+
54+
it('renders the number of streams from the messages_per_stream keys', () => {
55+
asMock(useInputMetricsFor).mockReturnValue({
56+
metrics: { messages_per_stream: { 'stream-a': 10, 'stream-b': 5, 'stream-c': 0 } },
57+
isInitialLoading: false,
58+
isError: false,
59+
});
60+
61+
render(<AssociatedStreamsCell input={input} />);
62+
63+
expect(screen.getByText('3')).toBeInTheDocument();
64+
});
65+
66+
it('renders 0 when no streams have received messages', () => {
67+
asMock(useInputMetricsFor).mockReturnValue({
68+
metrics: { messages_per_stream: {} },
69+
isInitialLoading: false,
70+
isError: false,
71+
});
72+
73+
render(<AssociatedStreamsCell input={input} />);
74+
75+
expect(screen.getByText('0')).toBeInTheDocument();
76+
});
77+
78+
it('toggles the associated_streams section when clicked', async () => {
79+
asMock(useInputMetricsFor).mockReturnValue({
80+
metrics: { messages_per_stream: { 'stream-a': 1, 'stream-b': 1 } },
81+
isInitialLoading: false,
82+
isError: false,
83+
});
84+
85+
render(<AssociatedStreamsCell input={input} />);
86+
await userEvent.click(screen.getByText('2'));
87+
88+
expect(toggleSection).toHaveBeenCalledWith('input-1', 'associated_streams');
89+
});
90+
91+
it('renders a spinner while metrics are loading and no cached data is present', async () => {
92+
asMock(useInputMetricsFor).mockReturnValue({
93+
metrics: undefined,
94+
isInitialLoading: true,
95+
isError: false,
96+
});
97+
98+
render(<AssociatedStreamsCell input={input} />);
99+
100+
expect(await screen.findByText(/loading/i)).toBeInTheDocument();
101+
});
102+
103+
it('renders a dash when the request errored', () => {
104+
asMock(useInputMetricsFor).mockReturnValue({
105+
metrics: undefined,
106+
isInitialLoading: false,
107+
isError: true,
108+
});
109+
110+
render(<AssociatedStreamsCell input={input} />);
111+
112+
expect(screen.getByText('—')).toBeInTheDocument();
113+
});
114+
});

0 commit comments

Comments
 (0)