diff --git a/changelog/unreleased/issue-25916.toml b/changelog/unreleased/issue-25916.toml new file mode 100644 index 000000000000..c564214f548b --- /dev/null +++ b/changelog/unreleased/issue-25916.toml @@ -0,0 +1,5 @@ +type = "f" +message = "Add a traffic type selector to the cluster traffic graph so pre-7.1 history remains viewable via the outgoing traffic view." + +issues = ["25916"] +pulls = ["26548"] diff --git a/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.test.tsx b/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.test.tsx new file mode 100644 index 000000000000..361aebc07718 --- /dev/null +++ b/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 + * . + */ +import * as React from 'react'; +import { render, screen } from 'wrappedTestingLibrary'; + +import asMock from 'helpers/mocking/AsMock'; +import selectEvent from 'helpers/selectEvent'; +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import useGraphDays from 'components/common/Graph/contexts/useGraphDays'; +import useGraphWidth from 'components/common/Graph/useGraphWidth'; +import useLocation from 'routing/useLocation'; +import useClusterTraffic from 'components/cluster/hooks/useClusterTraffic'; + +import ClusterTrafficGraph from './ClusterTrafficGraph'; + +jest.mock('logic/telemetry/useSendTelemetry'); +jest.mock('components/common/Graph/contexts/useGraphDays'); +jest.mock('components/common/Graph/useGraphWidth'); +jest.mock('routing/useLocation'); +jest.mock('components/cluster/hooks/useClusterTraffic'); + +const GIB = 1024 * 1024 * 1024; + +describe('ClusterTrafficGraph', () => { + beforeEach(() => { + jest.clearAllMocks(); + + asMock(useSendTelemetry).mockReturnValue(jest.fn()); + asMock(useGraphDays).mockReturnValue({ + graphDays: 30, + setGraphDays: jest.fn(), + }); + asMock(useGraphWidth).mockReturnValue({ + graphWidth: 1000, + graphContainerRef: React.createRef(), + }); + asMock(useLocation).mockReturnValue({ + pathname: '/system/overview', + search: '', + hash: '', + state: undefined, + key: 'default', + }); + asMock(useClusterTraffic).mockReturnValue({ + isLoading: false, + traffic: { + from: '2022-09-01T00:00:00.000Z', + to: '2022-09-03T00:00:00.000Z', + input: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + '2022-09-02T00:00:00.000Z': 2 * GIB, + }, + output: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + '2022-09-02T00:00:00.000Z': 4 * GIB, + }, + decoded: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + '2022-09-02T00:00:00.000Z': 2 * GIB, + }, + input_indexed: { + '2022-09-01T00:00:00.000Z': GIB, + '2022-09-02T00:00:00.000Z': 2 * GIB, + }, + }, + }); + }); + + it('shows incoming (input_indexed) traffic by default', async () => { + render(); + + await screen.findByRole('heading', { name: /Incoming traffic/ }); + expect(screen.getByText(/Last 30 days: 3(\.0)?\s?GiB/)).toBeInTheDocument(); + }); + + it('switches to output traffic when selecting the outgoing view', async () => { + render(); + + await selectEvent.select(await screen.findByLabelText('Show traffic type'), 'Outgoing traffic'); + + await screen.findByRole('heading', { name: /Outgoing traffic/ }); + expect(screen.getByText(/Last 30 days: 6(\.0)?\s?GiB/)).toBeInTheDocument(); + }); + + it('handles missing input_indexed data and still allows switching to outgoing traffic', async () => { + asMock(useClusterTraffic).mockReturnValue({ + isLoading: false, + traffic: { + from: '2022-09-01T00:00:00.000Z', + to: '2022-09-03T00:00:00.000Z', + input: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + }, + output: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + '2022-09-02T00:00:00.000Z': 4 * GIB, + }, + decoded: { + '2022-09-01T00:00:00.000Z': 2 * GIB, + }, + }, + }); + + render(); + + await screen.findByRole('heading', { name: /Incoming traffic/ }); + expect(screen.queryByText(/Last 30 days:/)).not.toBeInTheDocument(); + + await selectEvent.select(await screen.findByLabelText('Show traffic type'), 'Outgoing traffic'); + + await screen.findByRole('heading', { name: /Outgoing traffic/ }); + expect(screen.getByText(/Last 30 days: 6(\.0)?\s?GiB/)).toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.tsx b/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.tsx index 778054945637..96d3e6e1c165 100644 --- a/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.tsx +++ b/graylog2-web-interface/src/components/cluster/ClusterTrafficGraph.tsx @@ -16,17 +16,24 @@ */ import * as React from 'react'; +import { useState } from 'react'; import { TrafficGraphWithDaySelect } from 'components/common/Graph'; import useGraphDays from 'components/common/Graph/contexts/useGraphDays'; +import type { TrafficType } from 'components/common/Graph/types'; import useClusterTraffic from './hooks/useClusterTraffic'; const ClusterTrafficGraph = () => { const { graphDays } = useGraphDays(); const { traffic } = useClusterTraffic(graphDays); + const [trafficType, setTrafficType] = useState('input-indexed'); - return ; + const trafficSeries = trafficType === 'input-indexed' ? traffic?.input_indexed : traffic?.output; + + return ( + + ); }; export default ClusterTrafficGraph; diff --git a/graylog2-web-interface/src/components/cluster/GraylogClusterOverview.test.tsx b/graylog2-web-interface/src/components/cluster/GraylogClusterOverview.test.tsx index 446cf425f487..df60807be20d 100644 --- a/graylog2-web-interface/src/components/cluster/GraylogClusterOverview.test.tsx +++ b/graylog2-web-interface/src/components/cluster/GraylogClusterOverview.test.tsx @@ -16,7 +16,6 @@ */ import React from 'react'; import * as Immutable from 'immutable'; -import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from 'wrappedTestingLibrary'; import type { Permission } from 'graylog-web-plugin/plugin'; @@ -24,6 +23,7 @@ import { SystemClusterTraffic } from '@graylog/server-api'; import { adminUser } from 'fixtures/users'; import asMock from 'helpers/mocking/AsMock'; +import selectEvent from 'helpers/selectEvent'; import useCurrentUser from 'hooks/useCurrentUser'; import GraylogClusterOverview from './GraylogClusterOverview'; @@ -123,7 +123,7 @@ describe('GraylogClusterOverview', () => { it('renders GraylogClusterOverview', async () => { render(); - expect(screen.getByText(/Incoming traffic/)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Incoming traffic/ })).toBeInTheDocument(); await waitFor(() => expect(SystemClusterTraffic.get).toHaveBeenCalledWith(30, false)); @@ -131,10 +131,9 @@ describe('GraylogClusterOverview', () => { }); it('renders GraylogClusterOverview and change the days for the traffic graph', async () => { - const { getByLabelText } = render(); - const graphDaysSelect = getByLabelText('Days'); + render(); - await userEvent.selectOptions(graphDaysSelect, '365'); + await selectEvent.select(await screen.findByLabelText('Days'), '365'); await waitFor(() => expect(SystemClusterTraffic.get).toHaveBeenCalledWith(365, false)); diff --git a/graylog2-web-interface/src/components/common/Graph/TrafficGraph.test.tsx b/graylog2-web-interface/src/components/common/Graph/TrafficGraph.test.tsx index 8178a72f2353..08c3c4be4123 100644 --- a/graylog2-web-interface/src/components/common/Graph/TrafficGraph.test.tsx +++ b/graylog2-web-interface/src/components/common/Graph/TrafficGraph.test.tsx @@ -32,6 +32,10 @@ const sampleTraffic = { }; describe('TrafficGraph', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('configures xaxis with tickformat and hoverformat to prevent millisecond display', () => { render(); @@ -51,4 +55,75 @@ describe('TrafficGraph', () => { expect(layout.xaxis.title).toEqual({ text: 'Date (UTC)' }); }); + + it('themes outside bar labels with the theme text color', () => { + render(); + + const { chartData } = asMock(GenericPlot).mock.calls[0][0]; + + expect(chartData[0].outsidetextfont).toEqual({ color: '#252D47' }); + }); + + it('pins the yaxis so dragging selects a time range only, like search charts', () => { + render(); + + const { layout } = asMock(GenericPlot).mock.calls[0][0]; + + expect(layout.yaxis.fixedrange).toBe(true); + }); + + it('enables double-click to reset a zoomed time range', () => { + render(); + + const { config } = asMock(GenericPlot).mock.calls[0][0]; + + expect(config).toEqual({ doubleClick: 'reset' }); + }); + + it('does not configure a plotly updatemenus widget', () => { + render(); + + expect(asMock(GenericPlot).mock.calls[0][0].layout.updatemenus).toBeUndefined(); + }); + + it('fits the yaxis to the data when zoomedToData is set', () => { + const { rerender } = render(); + + const initialRange = asMock(GenericPlot).mock.calls[0][0].layout.yaxis.range; + + rerender(); + + const { calls } = asMock(GenericPlot).mock; + const zoomedRange = calls[calls.length - 1][0].layout.yaxis.range; + + expect(zoomedRange[1]).toBeLessThan(initialRange[1]); + }); + + it('passes uiRevision through as the plotly uirevision', () => { + render(); + + expect(asMock(GenericPlot).mock.calls[0][0].layout.uirevision).toBe(7); + }); + + it('reserves top margin so the traffic limit annotation is not clipped', () => { + render(); + + expect(asMock(GenericPlot).mock.calls[0][0].layout.margin.t).toBe(28); + }); + + it('wires onUserZoom to the plot zoom event', () => { + const onUserZoom = jest.fn(); + + render(); + + expect(asMock(GenericPlot).mock.calls[0][0].onZoom).toBe(onUserZoom); + }); + + it('wires onUserZoomReset to the plot reset event', () => { + const onUserZoomReset = jest.fn(); + + render(); + + expect(asMock(GenericPlot).mock.calls[0][0].onZoomReset).toBe(onUserZoomReset); + }); }); diff --git a/graylog2-web-interface/src/components/common/Graph/TrafficGraph.tsx b/graylog2-web-interface/src/components/common/Graph/TrafficGraph.tsx index 0eadb481cc83..973ed926b3b8 100644 --- a/graylog2-web-interface/src/components/common/Graph/TrafficGraph.tsx +++ b/graylog2-web-interface/src/components/common/Graph/TrafficGraph.tsx @@ -19,7 +19,6 @@ import styled, { css, useTheme } from 'styled-components'; import type { PlotLayout } from 'views/components/visualizations/GenericPlot'; import GenericPlot from 'views/components/visualizations/GenericPlot'; -import AppConfig from 'util/AppConfig'; import { getHoverTemplateSettings, getFormatSettingsByData, @@ -30,6 +29,10 @@ type Props = { traffic: { [key: string]: number }; width: number; trafficLimit?: number; + zoomedToData?: boolean; + uiRevision?: number; + onUserZoom?: () => void; + onUserZoomReset?: () => void; }; const GraphWrapper = styled.div<{ @@ -47,11 +50,18 @@ type GeneratedLayout = { ticktext: Array; }; -const getMaxDailyValue = (arr: Array) => arr.reduce((a, b) => Math.max(a, b), 0); - -const TrafficGraph = ({ width, traffic, trafficLimit = undefined }: Props) => { +const PLOT_CONFIG = { doubleClick: 'reset' as const }; + +const TrafficGraph = ({ + width, + traffic, + trafficLimit = undefined, + zoomedToData = false, + uiRevision = 1, + onUserZoom = undefined, + onUserZoomReset = undefined, +}: Props) => { const theme = useTheme(); - const isCloud = AppConfig.isCloud(); const yValues = useMemo(() => Object.values(traffic), [traffic]); @@ -61,17 +71,16 @@ const TrafficGraph = ({ width, traffic, trafficLimit = undefined }: Props) => { type: 'bar', x: Object.keys(traffic), y: yValues, + outsidetextfont: { color: theme.colors.text.primary }, ...getHoverTemplateSettings({ convertedValues: yValues, unit: FieldUnit.fromJSON({ abbrev: 'b', unit_type: 'binary_size' }), }), }, ], - [traffic, yValues], + [theme.colors.text.primary, traffic, yValues], ); - const maxDailyValue = useMemo(() => getMaxDailyValue(yValues), [yValues]); - const trafficLimitAnnotation: Partial = useMemo( () => ({ annotations: [ @@ -139,6 +148,9 @@ const TrafficGraph = ({ width, traffic, trafficLimit = undefined }: Props) => { showlegend: false, margin: { l: 60, + // Headroom for the "Licensed traffic limit" annotation, which renders above the + // limit line and would otherwise be clipped at the plot's top edge. + t: 28, }, xaxis: { type: 'date', @@ -152,57 +164,10 @@ const TrafficGraph = ({ width, traffic, trafficLimit = undefined }: Props) => { hoverlabel: { namelength: -1, }, - yaxis: notZoomedLayout, - updatemenus: [ - { - buttons: [ - { - // args: ['yaxis.range', [0, isCloud ? trafficLimit : range]], - args: [ - { - 'yaxis.range': zoomedLayout.range, - 'yaxis.tickvals': zoomedLayout.tickvals, - 'yaxis.ticktext': zoomedLayout.ticktext, - }, - ], - args2: [ - { - 'yaxis.range': notZoomedLayout.range, - 'yaxis.tickvals': notZoomedLayout.tickvals, - 'yaxis.ticktext': notZoomedLayout.ticktext, - }, - ], - label: 'Zoom/Reset', - method: 'relayout', - }, - ], - direction: 'right', - showactive: false, - bordercolor: theme.colors.global.contentBackground, - font: { - color: theme.colors.global.link, - }, - active: 1, - type: 'buttons', - visible: trafficLimit && maxDailyValue < trafficLimit && !isCloud, - xanchor: 'right', - yanchor: 'top', - x: 1, - y: 1.3, - }, - ], + yaxis: { ...(zoomedToData ? zoomedLayout : notZoomedLayout), fixedrange: true }, + uirevision: uiRevision, }), - [ - isCloud, - notZoomedLayout, - maxDailyValue, - theme.colors.global.contentBackground, - theme.colors.global.link, - trafficLimit, - zoomedLayout.range, - zoomedLayout.ticktext, - zoomedLayout.tickvals, - ], + [notZoomedLayout, uiRevision, zoomedLayout, zoomedToData], ); const trafficLayout = useMemo(() => { @@ -213,7 +178,13 @@ const TrafficGraph = ({ width, traffic, trafficLimit = undefined }: Props) => { return ( - + ); }; diff --git a/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.test.tsx b/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.test.tsx index 78fafeabfe9a..1931ffbc0095 100644 --- a/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.test.tsx +++ b/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.test.tsx @@ -15,10 +15,10 @@ * . */ import * as React from 'react'; -import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from 'wrappedTestingLibrary'; import asMock from 'helpers/mocking/AsMock'; +import selectEvent from 'helpers/selectEvent'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import useGraphDays from 'components/common/Graph/contexts/useGraphDays'; import useLocation from 'routing/useLocation'; @@ -64,15 +64,12 @@ describe('TrafficGraphWithDaySelect', () => { }); it('displays traffic graph with total and allows changing days', async () => { - const { getByLabelText } = render(); + render(); await screen.findByText('Outgoing traffic'); expect(screen.getByText(/Last 30 days:/)).toBeInTheDocument(); - const daysSelect = getByLabelText('Days') as HTMLSelectElement; - expect(daysSelect.value).toBe('30'); - - await userEvent.selectOptions(daysSelect, '365'); + await selectEvent.select(await screen.findByLabelText('Days'), '365'); await waitFor(() => { expect(mockSetGraphDays).toHaveBeenCalledWith(365); @@ -88,14 +85,13 @@ describe('TrafficGraphWithDaySelect', () => { }); it('supports both input and output traffic with correct titles and telemetry', async () => { - const { getByLabelText, rerender } = render( + const { rerender } = render( , ) as any; await screen.findByText('Incoming traffic'); - const daysSelect = getByLabelText('Days'); - await userEvent.selectOptions(daysSelect, '90'); + await selectEvent.select(await screen.findByLabelText('Days'), '90'); expect(mockSendTelemetry).toHaveBeenCalledWith( TELEMETRY_EVENT_TYPE.TRAFFIC_GRAPH_DAYS_CHANGED, @@ -110,6 +106,114 @@ describe('TrafficGraphWithDaySelect', () => { expect(mockSendTelemetry).not.toHaveBeenCalled(); }); + it('renders a traffic type selector when onTrafficTypeChange is provided', async () => { + const onTrafficTypeChange = jest.fn(); + + render( + , + ); + + await selectEvent.select(await screen.findByLabelText('Show traffic type'), 'Outgoing traffic'); + + await waitFor(() => { + expect(onTrafficTypeChange).toHaveBeenCalledWith('output'); + }); + + expect(mockSendTelemetry).toHaveBeenCalledWith( + TELEMETRY_EVENT_TYPE.TRAFFIC_GRAPH_TYPE_CHANGED, + expect.objectContaining({ app_section: 'outgoing-traffic', event_details: { value: 'output' } }), + ); + }); + + it('reports incoming telemetry when switching back to the incoming view', async () => { + const onTrafficTypeChange = jest.fn(); + + render( + , + ); + + await selectEvent.select(await screen.findByLabelText('Show traffic type'), 'Incoming traffic'); + + await waitFor(() => { + expect(onTrafficTypeChange).toHaveBeenCalledWith('input-indexed'); + }); + + expect(mockSendTelemetry).toHaveBeenCalledWith( + TELEMETRY_EVENT_TYPE.TRAFFIC_GRAPH_TYPE_CHANGED, + expect.objectContaining({ app_section: 'incoming-traffic', event_details: { value: 'input-indexed' } }), + ); + }); + + it('does not render a traffic type selector by default', async () => { + render(); + + await screen.findByText('Incoming traffic'); + expect(screen.queryByLabelText('Show traffic type')).not.toBeInTheDocument(); + }); + + it('always renders a Zoom/Reset button in the controls row', async () => { + render(); + + await screen.findByRole('button', { name: 'Zoom/Reset' }); + }); + + it('disables Zoom/Reset on a pristine graph without a traffic limit', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Zoom/Reset' })).toBeDisabled(); + }); + + it('enables Zoom/Reset when a traffic limit dwarfs the data', async () => { + render(); + + expect(await screen.findByRole('button', { name: 'Zoom/Reset' })).toBeEnabled(); + }); + + it('keeps Zoom/Reset disabled when the daily total exceeds the limit even though every raw bucket is below it', async () => { + const hourlyTraffic = { + '2022-09-21T08:00:00.000Z': 600, + '2022-09-21T09:00:00.000Z': 600, + }; + + render(); + + expect(await screen.findByRole('button', { name: 'Zoom/Reset' })).toBeDisabled(); + }); + + it('ignores re-selecting the currently selected days value', async () => { + render(); + + await selectEvent.select(await screen.findByLabelText('Days'), '30'); + + expect(mockSetGraphDays).not.toHaveBeenCalled(); + expect(mockSendTelemetry).not.toHaveBeenCalled(); + }); + + it('ignores re-selecting the current traffic type', async () => { + const onTrafficTypeChange = jest.fn(); + + render( + , + ); + + await selectEvent.select(await screen.findByLabelText('Show traffic type'), 'Outgoing traffic'); + + expect(onTrafficTypeChange).not.toHaveBeenCalled(); + expect(mockSendTelemetry).not.toHaveBeenCalled(); + }); + it('allows custom title to override default', async () => { render( , @@ -125,6 +229,26 @@ describe('TrafficGraphWithDaySelect', () => { await screen.findByText('Outgoing traffic'); }); + it('shows a spinner without a total while traffic is not available yet', async () => { + render(); + + await screen.findByText('Outgoing traffic'); + await screen.findByText(/Loading/); + expect(screen.queryByText(/Last 30 days:/)).not.toBeInTheDocument(); + }); + + it('shows a zero total when the traffic sums to zero', async () => { + const zeroTraffic = { + '2022-09-21T08:00:00.000Z': 0, + '2022-09-21T09:00:00.000Z': 0, + }; + + render(); + + await screen.findByRole('heading', { name: /Outgoing traffic/ }); + expect(screen.getByText(/Last 30 days: 0(\.0)?\s?B/)).toBeInTheDocument(); + }); + it('passes trafficLimit to graph component', async () => { const { container } = render(); diff --git a/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.tsx b/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.tsx index 819abe6af3ca..0d6232d217bf 100644 --- a/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.tsx +++ b/graylog2-web-interface/src/components/common/Graph/TrafficGraphWithDaySelect.tsx @@ -19,16 +19,17 @@ import reduce from 'lodash/reduce'; import styled, { css } from 'styled-components'; import { useMemo } from 'react'; -import { Input } from 'components/bootstrap'; +import { Button } from 'components/bootstrap'; import { Spinner } from 'components/common'; +import Select from 'components/common/Select'; import { formatTrafficData } from 'util/TrafficUtils'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; -import { TrafficGraph, useGraphWidth } from 'components/common/Graph'; +import { TrafficGraph, useGraphWidth, useTrafficGraphZoom } from 'components/common/Graph'; import { getPathnameWithoutId } from 'util/URLUtils'; import useLocation from 'routing/useLocation'; -import type { Traffic } from 'components/common/Graph/types'; -import { DAYS } from 'components/common/Graph/types'; +import type { Traffic, TrafficType } from 'components/common/Graph/types'; +import { DAYS, TRAFFIC_TYPE_APP_SECTIONS, TRAFFIC_TYPE_LABELS } from 'components/common/Graph/types'; import useGraphDays from 'components/common/Graph/contexts/useGraphDays'; import { getPrettifiedValue } from 'views/components/visualizations/utils/unitConverters'; import formatValueWithUnitLabel from 'views/components/visualizations/utils/formatValueWithUnitLabel'; @@ -41,60 +42,88 @@ const StyledH3 = styled.h3( const Wrapper = styled.div( ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.spacings.sm}; margin-bottom: ${theme.spacings.xs}; + `, +); - .control-label { - padding-top: 0; - } - - .graph-days-select { - display: flex; - align-items: baseline; - - select { - padding-top: ${theme.spacings.xxs}; - } - } +const SelectGroup = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.spacings.xs}; `, ); +const SelectLabel = styled.label` + margin: 0; +`; + +const TRAFFIC_TYPE_OPTIONS = Object.entries(TRAFFIC_TYPE_LABELS).map(([value, label]) => ({ value, label })); +const DAY_OPTIONS = DAYS.map((days) => ({ value: days, label: String(days) })); + type Props = { - traffic: Traffic; + traffic?: Traffic; trafficLimit?: number; title?: string; - trafficType?: 'input-indexed' | 'output'; + trafficType?: TrafficType; + onTrafficTypeChange?: (trafficType: TrafficType) => void; }; const TrafficGraphWithDaySelect = ({ - traffic, + traffic = undefined, trafficLimit = undefined, title = undefined, trafficType = 'output', + onTrafficTypeChange = undefined, }: Props) => { const { graphDays, setGraphDays } = useGraphDays(); const { graphWidth, graphContainerRef } = useGraphWidth(); + + const bytesOut = useMemo(() => (traffic ? reduce(traffic, (result, value) => result + value) : null), [traffic]); + const unixTraffic = useMemo(() => (traffic ? formatTrafficData(traffic) : null), [traffic]); + + // The zoom gate compares the PLOTTED series (daily sums) against the limit, not the raw buckets. + const { zoomedToData, uiRevision, canZoomOrReset, onZoomReset, onUserZoom, onUserZoomReset } = useTrafficGraphZoom( + unixTraffic, + trafficLimit, + ); const { pathname } = useLocation(); const sendTelemetry = useSendTelemetry(); - const onGraphDaysChange = (event: React.ChangeEvent): void => { - event.preventDefault(); - const newDays = Number(event.target.value); + const onGraphDaysChange = (newDays: number) => { + // react-select fires onChange even when the already-selected option is picked again. + if (newDays === graphDays) { + return; + } setGraphDays(newDays); - const appSection = trafficType === 'input-indexed' ? 'incoming-traffic' : 'outgoing-traffic'; - sendTelemetry(TELEMETRY_EVENT_TYPE.TRAFFIC_GRAPH_DAYS_CHANGED, { app_pathname: getPathnameWithoutId(pathname), - app_section: appSection, + app_section: TRAFFIC_TYPE_APP_SECTIONS[trafficType], app_action_value: 'trafficgraph-days-button', event_details: { value: newDays }, }); }; - const bytesOut = useMemo(() => (traffic ? reduce(traffic, (result, value) => result + value) : null), [traffic]); - const unixTraffic = useMemo(() => (traffic ? formatTrafficData(traffic) : null), [traffic]); + const onTrafficTypeSelect = (newTrafficType: TrafficType) => { + if (newTrafficType === trafficType) { + return; + } + + onTrafficTypeChange(newTrafficType); + + sendTelemetry(TELEMETRY_EVENT_TYPE.TRAFFIC_GRAPH_TYPE_CHANGED, { + app_pathname: getPathnameWithoutId(pathname), + app_section: TRAFFIC_TYPE_APP_SECTIONS[newTrafficType], + app_action_value: 'trafficgraph-type-select', + event_details: { value: newTrafficType }, + }); + }; const formattedTotalTraffic = useMemo(() => { const prettified = getPrettifiedValue(bytesOut, { abbrev: 'b', unitType: 'binary_size' }); @@ -105,32 +134,59 @@ const TrafficGraphWithDaySelect = ({ return ( <> - - {DAYS.map((size) => ( - - ))} - + {onTrafficTypeChange && ( + + Show + + + - {title ?? (trafficType === 'input-indexed' ? 'Incoming traffic' : 'Outgoing traffic')}{' '} - {bytesOut && ( + {title ?? TRAFFIC_TYPE_LABELS[trafficType]}{' '} + {bytesOut != null && ( Last {graphDays} days: {formattedTotalTraffic} )} {unixTraffic ? ( - + ) : ( )} diff --git a/graylog2-web-interface/src/components/common/Graph/index.ts b/graylog2-web-interface/src/components/common/Graph/index.ts index bb0ced57686b..3b6ba7b1088b 100644 --- a/graylog2-web-interface/src/components/common/Graph/index.ts +++ b/graylog2-web-interface/src/components/common/Graph/index.ts @@ -18,3 +18,4 @@ export { default as TrafficGraphWithDaySelect } from './TrafficGraphWithDaySelect'; export { default as TrafficGraph } from './TrafficGraph'; export { default as useGraphWidth } from './useGraphWidth'; +export { default as useTrafficGraphZoom } from './useTrafficGraphZoom'; diff --git a/graylog2-web-interface/src/components/common/Graph/types.ts b/graylog2-web-interface/src/components/common/Graph/types.ts index fb4470d01d6b..8f9d966a47d2 100644 --- a/graylog2-web-interface/src/components/common/Graph/types.ts +++ b/graylog2-web-interface/src/components/common/Graph/types.ts @@ -16,4 +16,16 @@ */ export type Traffic = { [key: string]: number }; +export type TrafficType = 'input-indexed' | 'output'; + +export const TRAFFIC_TYPE_LABELS: Record = { + 'input-indexed': 'Incoming traffic', + output: 'Outgoing traffic', +}; + +export const TRAFFIC_TYPE_APP_SECTIONS: Record = { + 'input-indexed': 'incoming-traffic', + output: 'outgoing-traffic', +}; + export const DAYS = [30, 90, 180, 365] as const; diff --git a/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.test.ts b/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.test.ts new file mode 100644 index 000000000000..2b70945d1a4e --- /dev/null +++ b/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.test.ts @@ -0,0 +1,151 @@ +/* + * 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 + * . + */ +import { act, renderHook } from 'wrappedTestingLibrary/hooks'; + +import type { Traffic } from './types'; +import useTrafficGraphZoom from './useTrafficGraphZoom'; + +const sampleTraffic: Traffic = { + '2026-04-07T00:00:00.000Z': 1024, + '2026-04-08T00:00:00.000Z': 2048, + '2026-04-09T00:00:00.000Z': 4096, +}; + +describe('useTrafficGraphZoom', () => { + it('zooms to the data on the first trigger and fully resets on the second when a limit is set', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 1024 * 1024)); + + expect(result.current.zoomedToData).toBe(false); + const initialRevision = result.current.uiRevision; + + act(() => result.current.onZoomReset()); + expect(result.current.zoomedToData).toBe(true); + expect(result.current.uiRevision).toBe(initialRevision); + + act(() => result.current.onZoomReset()); + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(initialRevision + 1); + }); + + it('does nothing on a pristine graph without a traffic limit', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, undefined)); + + const initialRevision = result.current.uiRevision; + + act(() => result.current.onZoomReset()); + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(initialRevision); + }); + + it('resets a drag-zoomed graph with a single trigger when there is no traffic limit', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, undefined)); + + const initialRevision = result.current.uiRevision; + + act(() => result.current.onUserZoom()); + act(() => result.current.onZoomReset()); + + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(initialRevision + 1); + }); + + it('fully resets with a single trigger after the user has drag-zoomed', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 1024 * 1024)); + + const initialRevision = result.current.uiRevision; + + act(() => result.current.onUserZoom()); + act(() => result.current.onZoomReset()); + + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(initialRevision + 1); + }); + + it('fully resets with a single trigger when drag-zoomed on top of the data zoom', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 1024 * 1024)); + + const initialRevision = result.current.uiRevision; + + act(() => result.current.onZoomReset()); + expect(result.current.zoomedToData).toBe(true); + + act(() => result.current.onUserZoom()); + act(() => result.current.onZoomReset()); + + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(initialRevision + 1); + }); + + it('zooms to the data on a single trigger after plotly itself reset a drag-zoom', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 1024 * 1024)); + + act(() => result.current.onUserZoom()); + // Plotly's double-click reset restores the initial view without going through onZoomReset. + act(() => result.current.onUserZoomReset()); + + act(() => result.current.onZoomReset()); + + expect(result.current.zoomedToData).toBe(true); + }); + + it('reports the zoom trigger as not actionable on a pristine graph without a traffic limit', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, undefined)); + + expect(result.current.canZoomOrReset).toBe(false); + + act(() => result.current.onUserZoom()); + + expect(result.current.canZoomOrReset).toBe(true); + }); + + it('reports the zoom trigger as actionable when a limit dwarfs the data', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 1024 * 1024)); + + expect(result.current.canZoomOrReset).toBe(true); + }); + + it('reports the zoom trigger as not actionable when the plotted data reaches the limit', () => { + const { result } = renderHook(() => useTrafficGraphZoom(sampleTraffic, 2048)); + + expect(result.current.canZoomOrReset).toBe(false); + }); + + it('is inert while the traffic series is still loading', () => { + const { result } = renderHook(() => useTrafficGraphZoom(undefined, 1024 * 1024)); + + expect(result.current.canZoomOrReset).toBe(false); + + act(() => result.current.onZoomReset()); + + expect(result.current.zoomedToData).toBe(false); + }); + + it('resets the zoom state when the traffic data changes', () => { + const { result, rerender } = renderHook(({ traffic }) => useTrafficGraphZoom(traffic, 1024 * 1024), { + initialProps: { traffic: sampleTraffic }, + }); + + act(() => result.current.onZoomReset()); + expect(result.current.zoomedToData).toBe(true); + const revisionWhileZoomed = result.current.uiRevision; + + rerender({ traffic: { ...sampleTraffic, '2026-04-10T00:00:00.000Z': 8192 } }); + + expect(result.current.zoomedToData).toBe(false); + expect(result.current.uiRevision).toBe(revisionWhileZoomed + 1); + }); +}); diff --git a/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.ts b/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.ts new file mode 100644 index 000000000000..a4d993a4c1da --- /dev/null +++ b/graylog2-web-interface/src/components/common/Graph/useTrafficGraphZoom.ts @@ -0,0 +1,90 @@ +/* + * 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 + * . + */ +import { useCallback, useMemo, useState } from 'react'; + +import AppConfig from 'util/AppConfig'; + +import type { Traffic } from './types'; + +type TrafficGraphZoom = { + zoomedToData: boolean; + uiRevision: number; + canZoomOrReset: boolean; + onZoomReset: () => void; + onUserZoom: () => void; + onUserZoomReset: () => void; +}; + +/** + * Owns the zoom state of a traffic graph: `zoomedToData` fits the y-axis to the data + * instead of the traffic limit, and bumping `uiRevision` makes plotly discard all user + * interaction state (e.g. a dragged time range), restoring the initial view. + * + * `traffic` must be the series the graph actually plots (the daily sums, not the raw + * sub-daily API buckets) — the zoom-to-data gate compares its values against the limit. + * + * A single reset trigger always returns a zoomed graph (drag-zoomed, data-zoomed, or + * both) to its initial view; only a pristine graph zooms to the data. Wire `onUserZoom` + * to the plot's zoom event so drag-zooms are tracked, and `onUserZoomReset` to the + * plot's reset event (plotly restores the view itself on double-click) so the trigger + * stays in sync. `canZoomOrReset` is false when triggering would do nothing. All state + * resets automatically when the traffic data changes. + */ +const useTrafficGraphZoom = (traffic: Traffic | null | undefined, trafficLimit?: number): TrafficGraphZoom => { + const isCloud = AppConfig.isCloud(); + const [zoomedToData, setZoomedToData] = useState(false); + const [userZoomed, setUserZoomed] = useState(false); + const [uiRevision, setUiRevision] = useState(1); + const [prevTraffic, setPrevTraffic] = useState(traffic); + + const resetToInitialView = useCallback(() => { + setZoomedToData(false); + setUserZoomed(false); + setUiRevision((revision) => revision + 1); + }, []); + + if (prevTraffic !== traffic) { + setPrevTraffic(traffic); + resetToInitialView(); + } + + const maxPlottedValue = useMemo(() => (traffic ? Math.max(0, ...Object.values(traffic)) : 0), [traffic]); + const canZoomToData = Boolean(traffic && trafficLimit && maxPlottedValue < trafficLimit && !isCloud); + const canZoomOrReset = zoomedToData || userZoomed || canZoomToData; + + const onUserZoom = useCallback(() => setUserZoomed(true), []); + const onUserZoomReset = useCallback(() => setUserZoomed(false), []); + + const onZoomReset = useCallback(() => { + const isPristine = !zoomedToData && !userZoomed; + + if (isPristine) { + // Nothing to reset — zoom to the data if a limit dwarfs it, otherwise do nothing. + if (canZoomToData) { + setZoomedToData(true); + } + + return; + } + + resetToInitialView(); + }, [canZoomToData, resetToInitialView, userZoomed, zoomedToData]); + + return { zoomedToData, uiRevision, canZoomOrReset, onZoomReset, onUserZoom, onUserZoomReset }; +}; + +export default useTrafficGraphZoom; diff --git a/graylog2-web-interface/src/components/common/Select/Select.test.tsx b/graylog2-web-interface/src/components/common/Select/Select.test.tsx index 16ae5d49ba3e..88e908354dc5 100644 --- a/graylog2-web-interface/src/components/common/Select/Select.test.tsx +++ b/graylog2-web-interface/src/components/common/Select/Select.test.tsx @@ -15,6 +15,7 @@ * . */ import React from 'react'; +import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from 'wrappedTestingLibrary'; import selectEvent from 'helpers/selectEvent'; @@ -147,4 +148,46 @@ describe('Select', () => { expect(select).toHaveAttribute('id', 'myId'); }); }); + + describe('searchable', () => { + it('does not filter options while typing when searchable is false', async () => { + render(); + + const select = await screen.findByLabelText('Select value'); + await userEvent.type(select, 'label1'); + selectEvent.openMenu(select); + + await screen.findByRole('option', { name: 'label1' }); + await screen.findByRole('option', { name: 'label2' }); + }); + + it('supports selecting an option when searchable is false', async () => { + const onChange = jest.fn(); + render(); + + await selectEvent.select(await screen.findByLabelText('Select value'), 'label1'); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith('value1', expect.any(Object))); + }); + }); + + describe('compact', () => { + it('trims the dropdown indicator gutter in compact mode', async () => { + render(); + + // eslint-disable-next-line testing-library/no-node-access + const indicator = (await screen.findByText('arrow_drop_down')).parentElement; + + expect(indicator).toHaveStyle({ marginRight: '4px' }); + }); + + it('keeps the roomy dropdown indicator gutter by default', async () => { + render(); + + // eslint-disable-next-line testing-library/no-node-access + const indicator = (await screen.findByText('arrow_drop_down')).parentElement; + + expect(indicator).toHaveStyle({ marginRight: '1rem' }); + }); + }); }); diff --git a/graylog2-web-interface/src/components/common/Select/Select.tsx b/graylog2-web-interface/src/components/common/Select/Select.tsx index dcd9e1ad5dea..c4009d82993f 100644 --- a/graylog2-web-interface/src/components/common/Select/Select.tsx +++ b/graylog2-web-interface/src/components/common/Select/Select.tsx @@ -90,13 +90,20 @@ const CustomMultiValueLabel = const CustomInput = (inputProps: { [key: string]: any }) => (props) => ; -const dropdownIndicator = (base, state) => ({ - ...base, - padding: '0px', - fontSize: '150%', - marginRight: '1rem', - transform: state.selectProps.menuIsOpen && 'rotate(180deg)', -}); +const dropdownIndicator = + ({ compact }) => + (base, state) => ({ + ...base, + padding: '0px', + fontSize: '150%', + marginRight: compact ? '4px' : '1rem', + transform: state.selectProps.menuIsOpen && 'rotate(180deg)', + }); + +const container = + ({ compact }) => + (base) => + compact ? { ...base, width: 'fit-content' } : base; const clearIndicator = (base) => ({ ...base, @@ -198,11 +205,16 @@ const controlFocus = }; const valueContainer = - ({ size }) => - (base) => ({ - ...base, - padding: size === 'small' ? '0 8px' : '2px 10px', - }); + ({ size, compact }) => + (base) => { + const [vertical, horizontal] = size === 'small' ? ['0', '8px'] : ['2px', '10px']; + + return { + ...base, + // Compact trims the gutter between the value and the chevron. + padding: compact ? `${vertical} 2px ${vertical} ${horizontal}` : `${vertical} ${horizontal}`, + }; + }; type OverriddenComponents = { DropdownIndicator: React.ComponentType; @@ -218,8 +230,9 @@ const _components: OverriddenComponents = { Control, }; -const _styles = ({ size, theme }) => ({ - dropdownIndicator, +const _styles = ({ size, theme, compact }) => ({ + container: container({ compact }), + dropdownIndicator: dropdownIndicator({ compact }), clearIndicator, multiValue: multiValue({ theme }), multiValueLabel: multiValueLabel({ theme }), @@ -229,7 +242,7 @@ const _styles = ({ size, theme }) => ({ singleValue: singleValueAndPlaceholder({ theme }), placeholder: placeholderStyling({ theme }), control: controlFocus({ size, theme }), - valueContainer: valueContainer({ size }), + valueContainer: valueContainer({ size, compact }), }); type ComponentsProp = { @@ -246,6 +259,9 @@ export type Props = { autoFocus?: boolean; className?: string; clearable?: boolean; + // Sizes the control to its content and trims the value-to-chevron gutter — + // for short, fixed-option selects that would otherwise fill their container. + compact?: boolean; components?: ComponentsProp | null | undefined; delimiter?: string; disabled?: boolean; @@ -272,6 +288,9 @@ export type Props = { persistSelection?: boolean; // eslint-disable-next-line react/require-default-props ref?: SelectRef; + // Maps to react-select's `isSearchable` — set to false for fixed option lists + // where typing/filtering makes no sense. + searchable?: boolean; size?: 'normal' | 'small'; styles?: any; theme: DefaultTheme; @@ -331,6 +350,7 @@ class Select extends React.Component, State> { autoFocus: false, className: undefined, clearable: true, + compact: false, components: null, delimiter: ',', disabled: false, @@ -351,6 +371,7 @@ class Select extends React.Component, State> { optionRenderer: undefined, placeholder: undefined, required: false, + searchable: true, size: 'normal', styles: undefined, value: undefined, @@ -546,6 +567,8 @@ class Select extends React.Component, State> { placeholder, styles, options: rawOptions, + compact, // Consumed by _styles below — do not pass down + searchable, ...rest } = this.props; const customFilter = this.createCustomFilter(); @@ -580,6 +603,7 @@ class Select extends React.Component, State> { isMulti, isDisabled, isClearable, + isSearchable: searchable, loadOptions, getOptionLabel: (option: { label?: string }) => option[displayKey] || option.label, getOptionValue: (option) => { @@ -591,7 +615,7 @@ class Select extends React.Component, State> { components: mergedComponents, menuPortalTarget: document.body, isOptionDisabled: (option: { disabled?: boolean }) => !!option.disabled, - styles: { ..._styles({ size, theme }), ...styles }, + styles: { ..._styles({ size, theme, compact }), ...styles }, theme: this._selectTheme, total, value: formattedValue, diff --git a/graylog2-web-interface/src/logic/telemetry/Constants.ts b/graylog2-web-interface/src/logic/telemetry/Constants.ts index 9c7180295fc7..922d7c8d8d04 100644 --- a/graylog2-web-interface/src/logic/telemetry/Constants.ts +++ b/graylog2-web-interface/src/logic/telemetry/Constants.ts @@ -351,6 +351,7 @@ export const TELEMETRY_EVENT_TYPE = { LOG_COLLECTOR_DELETED: 'Sidecar Log Collector Deleted', }, TRAFFIC_GRAPH_DAYS_CHANGED: 'Traffic Graph Days Changed', + TRAFFIC_GRAPH_TYPE_CHANGED: 'Traffic Graph Type Changed', URLALLOWLIST_CONFIGURATION_UPDATED: 'Urlallowlist Configuration Updated', CONTENT_PACK: { INSTALLED: 'Content Pack Installed', diff --git a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.test.tsx b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.test.tsx index 2ee2075f2c69..88730100d59b 100644 --- a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.test.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.test.tsx @@ -15,6 +15,7 @@ * . */ import * as React from 'react'; +import type { PlotRelayoutEvent } from 'plotly.js'; import { render } from 'wrappedTestingLibrary'; import asMock from 'helpers/mocking/AsMock'; @@ -53,3 +54,43 @@ describe('GenericPlot datarevision', () => { expect(lastLayout().datarevision).not.toBe(firstRevision); }); }); + +describe('GenericPlot relayout', () => { + beforeEach(() => { + asMock(Plot).mockClear(); + }); + + const lastPlotProps = () => { + const { calls } = asMock(Plot).mock; + + return calls[calls.length - 1][0]; + }; + + it('reports a zoom when the relayout carries an x-range', () => { + const onZoom = jest.fn(); + const onZoomReset = jest.fn(); + + render(); + + // Plotly delivers date-axis ranges as ISO strings at runtime, despite the `number` typing. + lastPlotProps().onRelayout({ + 'xaxis.range[0]': '2026-04-07', + 'xaxis.range[1]': '2026-04-08', + } as unknown as PlotRelayoutEvent); + + expect(onZoom).toHaveBeenCalledWith('2026-04-07', '2026-04-08'); + expect(onZoomReset).not.toHaveBeenCalled(); + }); + + it('reports a zoom reset when the plot relayouts to autorange (e.g. double-click reset)', () => { + const onZoom = jest.fn(); + const onZoomReset = jest.fn(); + + render(); + + lastPlotProps().onRelayout({ 'xaxis.autorange': true }); + + expect(onZoomReset).toHaveBeenCalled(); + expect(onZoom).not.toHaveBeenCalled(); + }); +}); diff --git a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx index 50b7a1e3128a..9dd41593df02 100644 --- a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { useContext, useMemo, useCallback, useState } from 'react'; import styled, { css, useTheme } from 'styled-components'; import merge from 'lodash/merge'; -import type { Layout, PlotMouseEvent, PlotlyHTMLElement } from 'plotly.js'; +import type { Layout, PlotMouseEvent, PlotRelayoutEvent, PlotlyHTMLElement } from 'plotly.js'; import type Plotly from 'plotly.js/lib/core'; import Plot from 'views/components/visualizations/plotly/AsyncPlot'; @@ -97,6 +97,7 @@ type Props = { layout?: Partial; config?: Partial; onZoom?: (from: string, to: string) => void; + onZoomReset?: () => void; setChartColor?: (data: ChartConfig, color: ColorMapper) => ChartColor; onClickMarker?: (markerEvent: OnClickMarkerEvent, event?: PlotMouseEvent) => void; onHoverMarker?: (event: OnHoverMarkerEvent) => void; @@ -105,10 +106,6 @@ type Props = { onInitialized?: (figure: unknown, graphDiv: PlotlyHTMLElement) => void; }; -type Axis = { - autosize: boolean; -}; - const nonInteractiveLayout = { yaxis: { fixedrange: true }, xaxis: { fixedrange: true }, @@ -223,6 +220,7 @@ const GenericPlot = ({ onHoverMarker = () => {}, onUnhoverMarker = () => {}, onZoom = () => {}, + onZoomReset = undefined, onAfterPlot = () => {}, onInitialized = () => {}, }: Props) => { @@ -253,15 +251,20 @@ const GenericPlot = ({ const onRenderComplete = useContext(RenderCompletionCallback); const _onRelayout = useCallback( - (axis: Axis) => { - if (!axis.autosize && axis['xaxis.range[0]'] && axis['xaxis.range[1]']) { - const from = axis['xaxis.range[0]']; - const to = axis['xaxis.range[1]']; + (axis: Readonly) => { + if (!axis.autosize && axis['xaxis.range[0]'] != null && axis['xaxis.range[1]'] != null) { + // Plotly delivers date-axis ranges as ISO strings at runtime, despite the `number` typing. + const from = String(axis['xaxis.range[0]']); + const to = String(axis['xaxis.range[1]']); onZoom(from, to); + } else if (axis['xaxis.autorange']) { + // Plotly restored the full range itself (e.g. a `doubleClick: 'reset'` config) — + // the zoom reported through onZoom is gone. + onZoomReset?.(); } }, - [onZoom], + [onZoom, onZoomReset], ); const _onHoverMarker = useCallback( diff --git a/graylog2-web-interface/src/views/components/visualizations/__tests__/GenericPlot.test.tsx b/graylog2-web-interface/src/views/components/visualizations/__tests__/GenericPlot.test.tsx index 63ccaf9a9e08..2e35edc89a20 100644 --- a/graylog2-web-interface/src/views/components/visualizations/__tests__/GenericPlot.test.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/__tests__/GenericPlot.test.tsx @@ -45,7 +45,8 @@ describe('GenericPlot', () => { await userEvent.click(await screen.findByRole('button', { name: 'Zoom' })); expect(onZoom).toHaveBeenCalled(); - expect(onZoom).toHaveBeenCalledWith(23, 42); + // The relayout ranges are normalized to strings, matching onZoom's declared signature. + expect(onZoom).toHaveBeenCalledWith('23', '42'); }); it('not calling onZoom prop if axis have not changed', async () => {