Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ 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 MessageCountCell from 'components/inputs/InputsOveriew/cells/MessageCountCell';
import ExtractorCountCell from 'components/inputs/InputsOveriew/cells/ExtractorCountCell';
import AssociatedStreamsCell from 'components/inputs/InputsOveriew/cells/AssociatedStreamsCell';
import { METRIC_COLUMN_IDS } from 'components/inputs/InputsOveriew/metricColumns';
import { InputStateBadge } from 'components/inputs';
import Routes from 'routing/Routes';
import { Link } from 'components/common';
Expand Down Expand Up @@ -97,6 +101,18 @@ const customColumnRenderers = ({ inputTypes, inputStates }: Props): ColumnRender
),
staticWidth: 'matchHeader',
},
[METRIC_COLUMN_IDS.messagesPerStream]: {
renderCell: (_value: unknown, input: InputSummary) => <MessageCountCell input={input} />,
staticWidth: 180,
},
[METRIC_COLUMN_IDS.extractorCount]: {
renderCell: (_value: unknown, input: InputSummary) => <ExtractorCountCell input={input} />,
staticWidth: 130,
},
[METRIC_COLUMN_IDS.associatedStreams]: {
renderCell: (_value: unknown, input: InputSummary) => <AssociatedStreamsCell input={input} />,
staticWidth: 180,
},
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import type { Sort } from 'stores/PaginationTypes';
import { METRIC_COLUMN_IDS, METRIC_COLUMN_TITLES } from 'components/inputs/InputsOveriew/metricColumns';

const getInputsTableElements = () => {
const tableLayout = {
Expand All @@ -39,6 +40,9 @@ const getInputsTableElements = () => {
'desired_state',
'traffic',
'input_failures',
METRIC_COLUMN_IDS.messagesPerStream,
METRIC_COLUMN_IDS.extractorCount,
METRIC_COLUMN_IDS.associatedStreams,
'node_id',
'address',
'port',
Expand All @@ -50,6 +54,9 @@ const getInputsTableElements = () => {
{ id: 'input_failures', title: 'Input Failures' },
{ id: 'address', title: 'Address' },
{ id: 'port', title: 'Port' },
{ id: METRIC_COLUMN_IDS.messagesPerStream, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.messagesPerStream] },
{ id: METRIC_COLUMN_IDS.extractorCount, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.extractorCount] },
{ id: METRIC_COLUMN_IDS.associatedStreams, title: METRIC_COLUMN_TITLES[METRIC_COLUMN_IDS.associatedStreams] },
];

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 { createContext, useContext } from 'react';

import useInputMetrics from 'hooks/useInputMetrics';
import type { InputMetricField, InputMetrics, InputMetricsByInputId } from 'hooks/useInputMetrics';

type InputMetricsContextValue = {
metricsByInputId: InputMetricsByInputId;
isInitialLoading: boolean;
isError: boolean;
isFieldRequested: (field: InputMetricField) => boolean;
};

const EMPTY_CONTEXT: InputMetricsContextValue = {
metricsByInputId: {},
isInitialLoading: false,
isError: false,
isFieldRequested: () => false,
};

const InputMetricsContext = createContext<InputMetricsContextValue>(EMPTY_CONTEXT);

type Props = React.PropsWithChildren<{
inputIds: Array<string>;
fields: Array<InputMetricField>;
}>;

export const InputMetricsProvider = ({ inputIds, fields, children = undefined }: Props) => {
const { metricsByInputId, isInitialLoading, isError } = useInputMetrics(inputIds, fields);
const requestedFields = new Set(fields);

const value: InputMetricsContextValue = {
metricsByInputId,
isInitialLoading,
isError,
isFieldRequested: (field) => requestedFields.has(field),
};

return <InputMetricsContext.Provider value={value}>{children}</InputMetricsContext.Provider>;
};

export const useInputMetricsContext = (): InputMetricsContextValue => useContext(InputMetricsContext);

export const useInputMetricsFor = (
inputId: string,
): {
metrics: InputMetrics | undefined;
isInitialLoading: boolean;
isError: boolean;
} => {
const { metricsByInputId, isInitialLoading, isError } = useContext(InputMetricsContext);

return {
metrics: metricsByInputId[inputId],
isInitialLoading,
isError,
};
};

export default InputMetricsContext;
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { useState } from 'react';
import * as Immutable from 'immutable';

import type { NodeInfo } from 'stores/nodes/NodesStore';
Expand All @@ -29,8 +29,13 @@ import type { InputTypesSummary } from 'hooks/useInputTypes';
import type { InputTypeDescriptionsResponse } from 'hooks/useInputTypesDescriptions';
import useInputsStates from 'hooks/useInputsStates';
import useTableElements from 'components/inputs/InputsOveriew/useTableElements';
import { InputMetricsProvider } from 'components/inputs/InputsOveriew/InputMetricsContext';
import { backendFieldsForVisibleColumns } from 'components/inputs/InputsOveriew/metricColumns';
import useUserLayoutPreferences from 'components/common/EntityDataTable/hooks/useUserLayoutPreferences';
import { ATTRIBUTE_STATUS } from 'components/common/EntityDataTable/Constants';
import { IfPermitted } from 'components/common';
import type { SearchParams } from 'stores/PaginationTypes';
import type { PaginatedResponse } from 'components/common/PaginatedEntityTable/useFetchEntities';

type Input = {
id: string;
Expand Down Expand Up @@ -64,15 +69,16 @@ const InputsOverview = ({
const { data: inputStates } = useInputsStates();
const { tableLayout, additionalAttributes } = getInputsTableElements();
const resolvedTableLayout = entityTableId ? { ...tableLayout, entityTableId } : tableLayout;
const resolvedKeyFn = useCallback(
(searchParams: SearchParams) => [...KEY_PREFIX, entityTableId ?? tableLayout.entityTableId, searchParams],
[entityTableId, tableLayout.entityTableId],
);
const resolvedKeyFn = (searchParams: SearchParams) => [
...KEY_PREFIX,
entityTableId ?? tableLayout.entityTableId,
searchParams,
];
const { entityActions, expandedSections } = useTableElements({
inputTypes,
inputTypeDescriptions,
});
const columnRenderers = useMemo(() => customColumnRenderers({ inputTypes, inputStates }), [inputTypes, inputStates]);
const columnRenderers = customColumnRenderers({ inputTypes, inputStates });
const fetchEntities = (options: SearchParams) => {
const optionsCopy = { ...options };

Expand All @@ -85,28 +91,52 @@ const InputsOverview = ({
return fetchInputs(optionsCopy);
};

const [visibleInputIds, setVisibleInputIds] = useState<Array<string>>([]);
const onDataLoaded = (data: PaginatedResponse<Input>) => {
const nextVisibleInputIds = data.list.map((entity) => entity.id);

setVisibleInputIds((currentVisibleInputIds) => {
const hasSameInputIds =
currentVisibleInputIds.length === nextVisibleInputIds.length &&
currentVisibleInputIds.every((inputId, index) => inputId === nextVisibleInputIds[index]);

return hasSameInputIds ? currentVisibleInputIds : nextVisibleInputIds;
});
};

const { data: layoutPreferences } = useUserLayoutPreferences(resolvedTableLayout.entityTableId);
const userPrefs = layoutPreferences?.attributes ?? {};
const userSelection = Object.entries(userPrefs)
.filter(([, pref]) => pref.status === ATTRIBUTE_STATUS.show)
.map(([attributeId]) => attributeId);
const visibleColumns = userSelection.length > 0 ? userSelection : resolvedTableLayout.defaultDisplayedAttributes;
const requestedFields = backendFieldsForVisibleColumns(visibleColumns);

return (
<div>
{!node && !global && (
<IfPermitted permissions="inputs:create">
<CreateInputControl />
</IfPermitted>
)}
<PaginatedEntityTable<Input>
humanName="inputs"
additionalAttributes={additionalAttributes}
queryHelpComponent={<QueryHelper entityName={entityName} />}
entityActions={entityActions}
tableLayout={resolvedTableLayout}
fetchEntities={fetchEntities}
expandedSectionRenderers={expandedSections}
keyFn={resolvedKeyFn}
bulkSelection={undefined}
withoutURLParams={withoutURLParams}
entityAttributesAreCamelCase={false}
filterValueRenderers={{}}
columnRenderers={columnRenderers}
/>
<InputMetricsProvider inputIds={visibleInputIds} fields={requestedFields}>
<PaginatedEntityTable<Input>
humanName="inputs"
additionalAttributes={additionalAttributes}
queryHelpComponent={<QueryHelper entityName={entityName} />}
entityActions={entityActions}
tableLayout={resolvedTableLayout}
fetchEntities={fetchEntities}
onDataLoaded={onDataLoaded}
expandedSectionRenderers={expandedSections}
keyFn={resolvedKeyFn}
bulkSelection={undefined}
withoutURLParams={withoutURLParams}
entityAttributesAreCamelCase={false}
filterValueRenderers={{}}
columnRenderers={columnRenderers}
/>
</InputMetricsProvider>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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 { useInputMetricsFor } from 'components/inputs/InputsOveriew/InputMetricsContext';
import useExpandedSections from 'components/common/EntityDataTable/hooks/useExpandedSections';

import AssociatedStreamsCell from './AssociatedStreamsCell';

jest.mock('components/inputs/InputsOveriew/InputMetricsContext', () => ({
useInputMetricsFor: jest.fn(),
}));
jest.mock('components/common/EntityDataTable/hooks/useExpandedSections', () => 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: '',
};

describe('AssociatedStreamsCell', () => {
const toggleSection = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
asMock(useExpandedSections).mockReturnValue({ toggleSection, expandedSections: {} });
});

it('renders the number of streams from the messages_per_stream keys', () => {
asMock(useInputMetricsFor).mockReturnValue({
metrics: { messages_per_stream: { 'stream-a': 10, 'stream-b': 5, 'stream-c': 0 } },
isInitialLoading: false,
isError: false,
});

render(<AssociatedStreamsCell input={input} />);

expect(screen.getByText('3')).toBeInTheDocument();
});

it('renders 0 when no streams have received messages', () => {
asMock(useInputMetricsFor).mockReturnValue({
metrics: { messages_per_stream: {} },
isInitialLoading: false,
isError: false,
});

render(<AssociatedStreamsCell input={input} />);

expect(screen.getByText('0')).toBeInTheDocument();
});

it('toggles the associated_streams section when clicked', async () => {
asMock(useInputMetricsFor).mockReturnValue({
metrics: { messages_per_stream: { 'stream-a': 1, 'stream-b': 1 } },
isInitialLoading: false,
isError: false,
});

render(<AssociatedStreamsCell input={input} />);
await userEvent.click(screen.getByText('2'));

expect(toggleSection).toHaveBeenCalledWith('input-1', 'associated_streams');
});

it('renders a spinner while metrics are loading and no cached data is present', async () => {
asMock(useInputMetricsFor).mockReturnValue({
metrics: undefined,
isInitialLoading: true,
isError: false,
});

render(<AssociatedStreamsCell input={input} />);

expect(await screen.findByText(/loading/i)).toBeInTheDocument();
});

it('renders a dash when the request errored', () => {
asMock(useInputMetricsFor).mockReturnValue({
metrics: undefined,
isInitialLoading: false,
isError: true,
});

render(<AssociatedStreamsCell input={input} />);

expect(screen.getByText('—')).toBeInTheDocument();
});
});
Loading
Loading