diff --git a/public/pages/Configuration/Configuration.test.tsx b/public/pages/Configuration/Configuration.test.tsx index d795e8f9..c6ed6f35 100644 --- a/public/pages/Configuration/Configuration.test.tsx +++ b/public/pages/Configuration/Configuration.test.tsx @@ -9,14 +9,23 @@ import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; import Configuration from './Configuration'; import { DataSourceContext } from '../TopNQueries/TopNQueries'; - const mockConfigInfo = jest.fn(); +const mockHistoryPush = jest.fn(); const mockCoreStart = { chrome: { setBreadcrumbs: jest.fn(), }, + http: { + get: jest.fn(), + }, }; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ push: mockHistoryPush }), + useLocation: () => ({ pathname: '/configuration' }), +})); + const defaultLatencySettings = { isEnabled: true, currTopN: '5', @@ -37,12 +46,12 @@ const defaultMemorySettings = { }; const groupBySettings = { - groupBy: 'SIMILARITY', + groupBy: 'similarity', }; const dataRetentionSettings = { exporterType: 'local_index', - deleteAfterDays: '179', + deleteAfterDays: '7', }; const dataSourceMenuMock = jest.fn(() =>
Mock DataSourceMenu
); @@ -57,8 +66,44 @@ const mockDataSourceContext = { setDataSource: jest.fn(), }; -const renderConfiguration = (overrides = {}) => - render( +const mockClusterSettings = { + persistent: {}, + transient: {}, + defaults: { + search: { + insights: { + top_queries: { + latency: { + enabled: 'true', + top_n_size: '10', + window_size: '5m', + }, + cpu: { + enabled: 'false', + top_n_size: '10', + window_size: '5m', + }, + memory: { + enabled: 'false', + top_n_size: '10', + window_size: '5m', + }, + grouping: { + group_by: 'none', + }, + exporter: { + type: 'local_index', + delete_after_days: '7', + }, + }, + }, + }, + }, +}; + +const renderConfiguration = (overrides = {}) => { + mockCoreStart.http.get.mockResolvedValue(mockClusterSettings); + return render( ); +}; -const getWindowSizeConfigurations = () => screen.getAllByRole('combobox'); -const getTopNSizeConfiguration = () => screen.getAllByRole('spinbutton'); -const getEnableToggle = () => screen.getByRole('switch'); +const getMetricSelect = () => screen.getByLabelText('Metric type'); +const getEnableToggle = () => screen.getByLabelText('Enable metric'); +const getTopNInput = () => screen.getByRole('spinbutton'); +const getWindowSizeInput = () => screen.getByTestId('window-size-raw'); +const getGroupBySelect = () => screen.getByLabelText('Group by'); +const getExporterSelect = () => screen.getByLabelText('Exporter type'); +const getDeleteAfterInput = () => screen.getByLabelText('Delete after days'); +const getSaveButton = () => screen.queryByTestId('save-config-button'); +const getCancelButton = () => screen.queryByText('Cancel'); describe('Configuration Component', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders with default settings', () => { + it('renders with default settings', async () => { const { container } = renderConfiguration(); + await waitFor(() => { + expect( + screen.getByText('Top N queries monitoring configuration settings') + ).toBeInTheDocument(); + }); expect(container).toMatchSnapshot('should match default settings snapshot'); }); - // The following tests test the interactions on the frontend with Mocks. - it('updates state when toggling metrics and enables Save button when changes are made', () => { - const { container } = renderConfiguration(); - // before toggling the metric - expect(getWindowSizeConfigurations()[0]).toHaveValue('latency'); - expect(getEnableToggle()).toBeChecked(); - // toggle the metric - fireEvent.change(getWindowSizeConfigurations()[0], { target: { value: 'cpu' } }); - // after toggling the metric - expect(getWindowSizeConfigurations()[0]).toHaveValue('cpu'); - // the enabled box should be disabled by default based on our configuration - const cpuEnableBox = getEnableToggle(); - expect(cpuEnableBox).toBeInTheDocument(); - expect(cpuEnableBox).not.toBeChecked(); - - fireEvent.click(getEnableToggle()); - expect(getEnableToggle()).toBeChecked(); - expect(screen.getByText('Save')).toBeEnabled(); - expect(container).toMatchSnapshot('should match settings snapshot after toggling'); - }); - - it('validates topNSize and windowSize inputs and disables Save button for invalid input', () => { + it('loads cluster settings on mount', async () => { renderConfiguration(); - fireEvent.change(getTopNSizeConfiguration()[0], { target: { value: '101' } }); - expect(screen.queryByText('Save')).not.toBeInTheDocument(); - fireEvent.change(getWindowSizeConfigurations()[1], { target: { value: '999' } }); - expect(screen.queryByText('Save')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockCoreStart.http.get).toHaveBeenCalledWith('/api/cluster_settings', { + query: { include_defaults: true, dataSourceId: 'test' }, + }); + }); }); - it('calls configInfo and navigates on Save button click', async () => { + it('displays error when cluster settings fail to load', async () => { + mockCoreStart.http.get.mockRejectedValue(new Error('Network error')); renderConfiguration(); - fireEvent.change(getTopNSizeConfiguration()[0], { target: { value: '7' } }); - fireEvent.click(screen.getByText('Save')); await waitFor(() => { - expect(mockConfigInfo).toHaveBeenCalledWith( - false, - true, - 'latency', - '7', - '10', - 'MINUTES', - 'local_index', - 'SIMILARITY', - '179' - ); + expect(screen.getByText('Could not load cluster settings')).toBeInTheDocument(); + expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); - it('resets state on Cancel button click', async () => { - renderConfiguration(); - fireEvent.change(getTopNSizeConfiguration()[0], { target: { value: '7' } }); - fireEvent.click(screen.getByText('Cancel')); - expect(getTopNSizeConfiguration()[0]).toHaveValue(5); // Resets to initial value + describe('Metric Configuration', () => { + it('switches between different metrics', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getMetricSelect()).toHaveValue('latency'); + }); + + fireEvent.change(getMetricSelect(), { target: { value: 'cpu' } }); + expect(getMetricSelect()).toHaveValue('cpu'); + + fireEvent.change(getMetricSelect(), { target: { value: 'memory' } }); + expect(getMetricSelect()).toHaveValue('memory'); + }); + + it('toggles metric enable/disable', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getEnableToggle()).toBeChecked(); + }); + + fireEvent.click(getEnableToggle()); + expect(getEnableToggle()).not.toBeChecked(); + + fireEvent.click(getEnableToggle()); + expect(getEnableToggle()).toBeChecked(); + }); + + it('validates top N size input', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getTopNInput()).toBeInTheDocument(); + }); + + // Valid input + fireEvent.change(getTopNInput(), { target: { value: '50' } }); + expect(getTopNInput()).toHaveValue(50); + + // Invalid input - exceeds max + fireEvent.change(getTopNInput(), { target: { value: '150' } }); + expect(getTopNInput()).toHaveValue(100); // Should be clamped to max + + // Invalid input - below min + fireEvent.change(getTopNInput(), { target: { value: '0' } }); + expect(getTopNInput()).toHaveValue(1); // Should be clamped to min + }); + + it('validates window size input format', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getWindowSizeInput()).toBeInTheDocument(); + }); + + // Valid formats + fireEvent.change(getWindowSizeInput(), { target: { value: '10m' } }); + expect(getWindowSizeInput()).toHaveValue('10m'); + + fireEvent.change(getWindowSizeInput(), { target: { value: '2h' } }); + expect(getWindowSizeInput()).toHaveValue('2h'); + + // Invalid format should show warning + fireEvent.change(getWindowSizeInput(), { target: { value: 'invalid' } }); + fireEvent.click(getEnableToggle()); // Enable to trigger validation + await waitFor(() => { + expect(screen.getByText('Invalid window size')).toBeInTheDocument(); + }); + }); + }); + + describe('Group By Configuration', () => { + it('changes group by setting', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getGroupBySelect()).toHaveValue('none'); + }); + + fireEvent.change(getGroupBySelect(), { target: { value: 'similarity' } }); + expect(getGroupBySelect()).toHaveValue('similarity'); + }); + + it('displays group by status correctly', async () => { + renderConfiguration(); + await waitFor(() => { + expect(screen.getByText('Group By')).toBeInTheDocument(); + }); + + // Initially disabled (none) + expect(screen.getAllByText('Disabled')[1]).toBeInTheDocument(); + + // Enable similarity grouping + fireEvent.change(getGroupBySelect(), { target: { value: 'similarity' } }); + await waitFor(() => { + expect(screen.getAllByText('Enabled')[3]).toBeInTheDocument(); + }); + }); + }); + + describe('Data Retention Configuration', () => { + it('changes exporter type', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getExporterSelect()).toHaveValue('local_index'); + }); + + fireEvent.change(getExporterSelect(), { target: { value: 'none' } }); + expect(getExporterSelect()).toHaveValue('none'); + }); + + it('disables delete after days when exporter is none', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getDeleteAfterInput()).not.toBeDisabled(); + }); + + fireEvent.change(getExporterSelect(), { target: { value: 'none' } }); + expect(getDeleteAfterInput()).toBeDisabled(); + }); + + it('validates delete after days input', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getDeleteAfterInput()).toBeInTheDocument(); + }); + + // Valid input + fireEvent.change(getDeleteAfterInput(), { target: { value: '30' } }); + expect(getDeleteAfterInput()).toHaveValue(30); + + // Invalid input - exceeds max + fireEvent.change(getDeleteAfterInput(), { target: { value: '200' } }); + expect(getDeleteAfterInput()).toHaveValue(180); // Should be clamped to max + + // Invalid input - below min + fireEvent.change(getDeleteAfterInput(), { target: { value: '0' } }); + expect(getDeleteAfterInput()).toHaveValue(1); // Should be clamped to min + }); + }); + + describe('Status Display', () => { + it('displays metric statuses correctly', async () => { + renderConfiguration(); + await waitFor(() => { + expect(screen.getByText('Statuses for configuration metrics')).toBeInTheDocument(); + }); + + // Check status indicators + expect(screen.getByText('Latency')).toBeInTheDocument(); + expect(screen.getByText('CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory')).toBeInTheDocument(); + }); + + it('displays exporter status correctly', async () => { + renderConfiguration(); + await waitFor(() => { + expect(screen.getByText('Statuses for data retention')).toBeInTheDocument(); + }); + + // Initially enabled (local_index) + expect(screen.getAllByText('Enabled')[2]).toBeInTheDocument(); + + // Disable exporter + fireEvent.change(getExporterSelect(), { target: { value: 'none' } }); + await waitFor(() => { + expect(screen.getAllByText('Disabled')[3]).toBeInTheDocument(); + }); + }); + }); + + describe('Save and Cancel Actions', () => { + it('shows save/cancel buttons only when changes are made', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getSaveButton()).not.toBeInTheDocument(); + expect(getCancelButton()).not.toBeInTheDocument(); + }); + + // Make a change + fireEvent.change(getTopNInput(), { target: { value: '15' } }); + await waitFor(() => { + expect(getSaveButton()).toBeInTheDocument(); + expect(getCancelButton()).toBeInTheDocument(); + }); + }); + + it('disables save button for invalid window size', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getWindowSizeInput()).toBeInTheDocument(); + }); + + // Make invalid window size change + fireEvent.change(getWindowSizeInput(), { target: { value: 'invalid' } }); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); + }); + + it('calls configInfo with correct parameters on save', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getTopNInput()).toBeInTheDocument(); + }); + + // Make changes + fireEvent.change(getTopNInput(), { target: { value: '25' } }); + fireEvent.change(getWindowSizeInput(), { target: { value: '15m' } }); + fireEvent.change(getGroupBySelect(), { target: { value: 'similarity' } }); + fireEvent.change(getDeleteAfterInput(), { target: { value: '30' } }); + + fireEvent.click(getSaveButton()!); + await waitFor(() => { + expect(mockConfigInfo).toHaveBeenCalledWith( + false, + true, + 'latency', + 25, + 15, + 'm', + 'local_index', + 'similarity', + 30 + ); + expect(mockHistoryPush).toHaveBeenCalledWith('/query-insights'); + }); + }); + + it('resets form on cancel', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getTopNInput()).toBeInTheDocument(); + }); + + const originalValue = getTopNInput().value; + + // Make changes + fireEvent.change(getTopNInput(), { target: { value: '25' } }); + expect(getTopNInput()).toHaveValue(25); + + // Cancel changes + fireEvent.click(getCancelButton()!); + expect(getTopNInput()).toHaveValue(parseInt(originalValue)); + }); + }); + + describe('Data Source Integration', () => { + it('reloads settings when data source changes', async () => { + renderConfiguration(); + await waitFor(() => { + expect(mockCoreStart.http.get).toHaveBeenCalledTimes(1); + }); + + // Simulate data source change + const newDataSource = { id: 'new-test', label: 'New Test' }; + mockDataSourceContext.setDataSource(newDataSource); + + // Component should reload settings + expect(mockCoreStart.http.get).toHaveBeenCalledWith('/api/cluster_settings', { + query: { include_defaults: true, dataSourceId: 'test' }, + }); + }); + }); + + describe('Edge Cases', () => { + it('handles empty cluster settings response', async () => { + mockCoreStart.http.get.mockResolvedValue({}); + renderConfiguration(); + await waitFor(() => { + expect( + screen.getByText('Top N queries monitoring configuration settings') + ).toBeInTheDocument(); + }); + }); + + it('handles malformed window size values', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getWindowSizeInput()).toBeInTheDocument(); + }); + + const invalidValues = ['', '0m', '25h', '1441m', 'abc', '10x']; + + for (const value of invalidValues) { + fireEvent.change(getWindowSizeInput(), { target: { value } }); + fireEvent.click(getEnableToggle()); + + if (value !== '' && value !== '10x' && value !== 'abc') { + await waitFor(() => { + expect(screen.queryByText('Invalid window size')).toBeInTheDocument(); + }); + } + } + }); + + it('handles non-numeric inputs gracefully', async () => { + renderConfiguration(); + await waitFor(() => { + expect(getTopNInput()).toBeInTheDocument(); + }); + + // Non-numeric input should be handled + fireEvent.change(getTopNInput(), { target: { value: 'abc' } }); + expect(getTopNInput()).toHaveValue(0); + }); }); }); diff --git a/public/pages/Configuration/Configuration.tsx b/public/pages/Configuration/Configuration.tsx index 79f9079e..e59823a9 100644 --- a/public/pages/Configuration/Configuration.tsx +++ b/public/pages/Configuration/Configuration.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useState, useEffect, useContext } from 'react'; +import React, { useCallback, useState, useEffect, useContext, useMemo } from 'react'; import { EuiBottomBar, EuiButton, @@ -22,114 +22,251 @@ import { EuiText, EuiTitle, EuiDescriptionList, + EuiSwitchEvent, } from '@elastic/eui'; import { useHistory, useLocation } from 'react-router-dom'; import { AppMountParameters, CoreStart } from 'opensearch-dashboards/public'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; + import { QUERY_INSIGHTS, - MetricSettings, GroupBySettings, DataSourceContext, DataRetentionSettings, + MetricSettings, } from '../TopNQueries/TopNQueries'; + import { METRIC_TYPES_TEXT, - TIME_UNITS_TEXT, - MINUTES_OPTIONS, GROUP_BY_OPTIONS, EXPORTER_TYPES_LIST, EXPORTER_TYPE, } from '../../../common/constants'; + import { QueryInsightsDataSourceMenu } from '../../components/DataSourcePicker'; import { QueryInsightsDashboardsPluginStartDependencies } from '../../types'; -const Configuration = ({ - latencySettings, - cpuSettings, - memorySettings, - groupBySettings, - dataRetentionSettings, - configInfo, - core, - depsStart, - params, - dataSourceManagement, -}: { - latencySettings: MetricSettings; +type MetricKey = 'latency' | 'cpu' | 'memory'; +type UnitUI = 'm' | 'h'; + +type ConfigInfoFn = ( + refreshOnly: boolean, + isEnabled?: boolean, + metric?: MetricKey, + topNSize?: number, + windowSize?: number, + timeUnit?: string, + exporterType?: string, + groupBy?: string, + deleteAfterDays?: number +) => void; + +interface Props { + latencySettings: MetricSettings; // kept only for prop shape cpuSettings: MetricSettings; memorySettings: MetricSettings; groupBySettings: GroupBySettings; dataRetentionSettings: DataRetentionSettings; - configInfo: any; + configInfo: ConfigInfoFn; core: CoreStart; params: AppMountParameters; dataSourceManagement?: DataSourceManagementPluginSetup; depsStart: QueryInsightsDashboardsPluginStartDependencies; -}) => { +} + +// ---------- helpers ---------- +const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); +const toInt = (v: unknown): number => { + const n = typeof v === 'number' ? v : parseInt(String(v ?? ''), 10); + return Number.isFinite(n) ? n : 0; +}; +const parseBool = (v: any): boolean => { + if (typeof v === 'boolean') return v; + const s = String(v).toLowerCase(); + return s === 'true' || s === '1'; +}; +// Accepts "10m" / "1h" +const parseWindowSizeStrict = (raw?: string): { value: number; unit: UnitUI } | null => { + const s = String(raw ?? '') + .trim() + .toLowerCase(); + const m = s.match(/^(\d+)\s*([mh])$/); + if (!m) return null; + const value = parseInt(m[1], 10); + const unit = (m[2] as UnitUI) ?? 'm'; + if (!Number.isFinite(value) || value <= 0) return null; + if (unit === 'h' && (value < 1 || value > 24)) return null; + if (unit === 'm' && (value < 1 || value > 1440)) return null; + return { value, unit }; +}; + +const getIn = (obj: any, path: Array) => + path.reduce((acc, key) => (acc && key in acc ? acc[key] : undefined), obj); + +const getClusterSetting = (root: any, nested: string[], dotted: string) => { + const tryLevel = (lvl: any) => { + if (!lvl) return undefined; + const a = getIn(lvl, nested); + if (a !== undefined) return a; + if (dotted in (lvl.settings ?? {})) return lvl.settings[dotted]; + if (dotted in lvl) return lvl[dotted]; + return undefined; + }; + return [root?.persistent, root?.transient, root?.defaults] + .map(tryLevel) + .find((v) => v !== undefined); +}; + +const unwrapClusterPayload = (res: any) => res?.response?.body ?? res?.body ?? res; + +export default function Configuration({ + groupBySettings, + dataRetentionSettings, + configInfo, + core, + depsStart, + params, + dataSourceManagement, +}: Props) { const history = useHistory(); const location = useLocation(); - - const [metric, setMetric] = useState<'latency' | 'cpu' | 'memory'>('latency'); - const [isEnabled, setIsEnabled] = useState(false); - const [topNSize, setTopNSize] = useState(latencySettings.currTopN); - const [windowSize, setWindowSize] = useState(latencySettings.currWindowSize); - const [time, setTime] = useState(latencySettings.currTimeUnit); - const [groupBy, setGroupBy] = useState(groupBySettings.groupBy); const { dataSource, setDataSource } = useContext(DataSourceContext)!; - const [deleteAfterDays, setDeleteAfterDays] = useState(dataRetentionSettings.deleteAfterDays); - const [exporterType, setExporterTypeType] = useState(dataRetentionSettings.exporterType); - const [metricSettingsMap, setMetricSettingsMap] = useState({ - latency: latencySettings, - cpu: cpuSettings, - memory: memorySettings, - }); + // form state + const [metric, setMetric] = useState('latency'); + const [isEnabled, setIsEnabled] = useState(false); + const [topNSize, setTopNSize] = useState(10); + const [windowRaw, setWindowRaw] = useState('5m'); + // derived window controls state (kept in sync with windowRaw) + const [time, setTime] = useState(5); + const [timeUnit, setTimeUnit] = useState('m'); + const [groupBy, setGroupBy] = useState(groupBySettings.groupBy ?? 'none'); + const [exporterType, setExporterType] = useState( + dataRetentionSettings.exporterType ?? EXPORTER_TYPE.none + ); + const [deleteAfterDays, setDeleteAfterDays] = useState( + toInt(dataRetentionSettings.deleteAfterDays ?? 7) + ); - const [groupBySettingMap, setGroupBySettingMap] = useState({ - groupBy: groupBySettings, + // baselines (for isChanged) + const [baseline, setBaseline] = useState({ + isEnabled: false, + topNSize: 10, + windowRaw: '5m', + groupBy: groupBySettings.groupBy ?? 'none', + exporterType: dataRetentionSettings.exporterType ?? EXPORTER_TYPE.none, + deleteAfterDays: toInt(dataRetentionSettings.deleteAfterDays ?? 7), }); - const [dataRetentionSettingMap, setDataRetentionSettingMap] = useState({ - dataRetention: dataRetentionSettings, - }); + // status cards + const [statusLatency, setStatusLatency] = useState(false); + const [statusCpu, setStatusCpu] = useState(false); + const [statusMemory, setStatusMemory] = useState(false); - useEffect(() => { - setMetricSettingsMap({ - latency: latencySettings, - cpu: cpuSettings, - memory: memorySettings, - }); - }, [latencySettings, cpuSettings, memorySettings, groupBySettings]); - - const newOrReset = useCallback(() => { - const currMetric = metricSettingsMap[metric]; - setTopNSize(currMetric.currTopN); - setWindowSize(currMetric.currWindowSize); - setTime(currMetric.currTimeUnit); - setIsEnabled(currMetric.isEnabled); - // setExporterTypeType(currMetric.exporterType); - }, [metric, metricSettingsMap]); + const [fetchError, setFetchError] = useState(null); - useEffect(() => { - newOrReset(); - }, [newOrReset, metricSettingsMap]); + const loadFromCluster = useCallback(async () => { + setFetchError(null); + try { + const dsId = (dataSource as any)?.id; + const res = await core.http.get('/api/cluster_settings', { + query: { include_defaults: true, dataSourceId: dsId }, + }); + const payload = unwrapClusterPayload(res); + + const readMetric = (key: MetricKey) => { + const base = `search.insights.top_queries.${key}`; + const enabled = getClusterSetting( + payload, + ['search', 'insights', 'top_queries', key, 'enabled'], + `${base}.enabled` + ); + const topN = getClusterSetting( + payload, + ['search', 'insights', 'top_queries', key, 'top_n_size'], + `${base}.top_n_size` + ); + const win = getClusterSetting( + payload, + ['search', 'insights', 'top_queries', key, 'window_size'], + `${base}.window_size` + ); + return { + enabled: parseBool(enabled ?? false), + topN: toInt(topN ?? 10), + window: String(win ?? '5m'), + }; + }; + + const mLatency = readMetric('latency'); + const mCpu = readMetric('cpu'); + const mMem = readMetric('memory'); + + // statuses + setStatusLatency(mLatency.enabled); + setStatusCpu(mCpu.enabled); + setStatusMemory(mMem.enabled); + + // group by / exporter + const groupByVal = + getClusterSetting( + payload, + ['search', 'insights', 'top_queries', 'grouping', 'group_by'], + 'search.insights.top_queries.grouping.group_by' + ) ?? 'none'; + + const exporterTypeVal = + getClusterSetting( + payload, + ['search', 'insights', 'top_queries', 'exporter', 'type'], + 'search.insights.top_queries.exporter.type' + ) ?? EXPORTER_TYPE.none; + const deleteAfterDaysVal = toInt( + getClusterSetting( + payload, + ['search', 'insights', 'top_queries', 'exporter', 'delete_after_days'], + 'search.insights.top_queries.exporter.delete_after_days' + ) ?? 7 + ); + + // seed the current form from the selected metric + const current = { latency: mLatency, cpu: mCpu, memory: mMem }[metric]; + setIsEnabled(current.enabled); + setTopNSize(current.topN); + setWindowRaw(current.window); + setGroupBy(String(groupByVal)); + setExporterType(String(exporterTypeVal)); + setDeleteAfterDays(deleteAfterDaysVal); + + setBaseline({ + isEnabled: current.enabled, + topNSize: current.topN, + windowRaw: current.window, + groupBy: String(groupByVal), + exporterType: String(exporterTypeVal), + deleteAfterDays: deleteAfterDaysVal, + }); + } catch (e: any) { + setFetchError(e?.message || 'Failed to load cluster settings'); + } + }, [core.http, dataSource, metric]); + + // initial + on DS switch + on metric change useEffect(() => { - setGroupBySettingMap({ - groupBy: groupBySettings, - }); - setGroupBy(groupBySettings.groupBy); - }, [groupBySettings]); + loadFromCluster(); + }, [loadFromCluster]); // loadFromCluster depends on `metric`, so this also reacts to metric changes + // keep number/unit controls in sync when windowRaw changes (including after fetch) useEffect(() => { - setDataRetentionSettingMap({ - dataRetention: dataRetentionSettings, - }); - setDeleteAfterDays(dataRetentionSettings.deleteAfterDays); - setExporterTypeType(dataRetentionSettings.exporterType); - }, [dataRetentionSettings]); + const p = parseWindowSizeStrict(windowRaw); + if (p) { + setTime(p.value); + setTimeUnit(p.unit); + } + }, [windowRaw]); + // breadcrumbs useEffect(() => { core.chrome.setBreadcrumbs([ { @@ -143,83 +280,87 @@ const Configuration = ({ ]); }, [core.chrome, history, location]); - const onMetricChange = (e: any) => { - setMetric(e.target.value); - }; + const isValidWindow = useMemo(() => !!parseWindowSizeStrict(`${time}${timeUnit}`), [ + time, + timeUnit, + ]); - const onEnabledChange = (e: any) => { - setIsEnabled(e.target.checked); - }; + const isChanged = useMemo(() => { + return ( + isEnabled !== baseline.isEnabled || + topNSize !== baseline.topNSize || + windowRaw.trim().toLowerCase() !== baseline.windowRaw.trim().toLowerCase() || + groupBy !== baseline.groupBy || + exporterType !== baseline.exporterType || + deleteAfterDays !== baseline.deleteAfterDays + ); + }, [baseline, deleteAfterDays, exporterType, groupBy, isEnabled, topNSize, windowRaw]); - const onTopNSizeChange = (e: React.ChangeEvent) => { - setTopNSize(e.target.value); - }; + const onSave = () => { + const parsed = parseWindowSizeStrict(`${time}${timeUnit}`); + if (!parsed) return; - const onWindowSizeChange = ( - e: React.ChangeEvent | React.ChangeEvent - ) => { - setWindowSize(e.target.value); + configInfo( + false, + isEnabled, + metric, + clamp(topNSize, 1, 100), + parsed.value, + parsed.unit, + exporterType, + groupBy, + clamp(deleteAfterDays, 1, 180) + ); + history.push(QUERY_INSIGHTS); }; - const onTimeChange = (e: React.ChangeEvent) => { - setTime(e.target.value); + const onCancel = () => { + setIsEnabled(baseline.isEnabled); + setTopNSize(baseline.topNSize); + setWindowRaw(baseline.windowRaw); + setGroupBy(baseline.groupBy); + setExporterType(baseline.exporterType); + setDeleteAfterDays(baseline.deleteAfterDays); }; - const onExporterTypeChange = (e: React.ChangeEvent) => { - setExporterTypeType(e.target.value); - }; + const formRowPadding = { padding: '0px 0px 20px' }; + const enabledSymb = Enabled; + const disabledSymb = Disabled; - const onGroupByChange = (e: React.ChangeEvent) => { - setGroupBy(e.target.value); - }; + const TIME_UNITS_TEXT = useMemo( + () => [ + { value: 'm', text: 'm' }, + { value: 'h', text: 'h' }, + ], + [] + ); - const onDeleteAfterDaysChange = (e: React.ChangeEvent) => { - setDeleteAfterDays(e.target.value); + const onTimeChange = (nextUnit: UnitUI) => { + setTimeUnit(nextUnit); + setWindowRaw(`${time}${nextUnit}`); }; - const MinutesBox = () => ( - - ); - - const HoursBox = () => ( + const WindowChoice = () => ( { + const v = clamp( + toInt(e.target.value), + timeUnit === 'h' ? 1 : 1, + timeUnit === 'h' ? 24 : 1440 + ); + setTime(v); + setWindowRaw(`${v}${timeUnit}`); + }} + aria-label="Window size value" + data-test-subj="window-size-value" + isInvalid={isEnabled && !isValidWindow} /> ); - const WindowChoice = time === TIME_UNITS_TEXT[0].value ? MinutesBox : HoursBox; - - const isChanged = - isEnabled !== metricSettingsMap[metric].isEnabled || - topNSize !== metricSettingsMap[metric].currTopN || - windowSize !== metricSettingsMap[metric].currWindowSize || - time !== metricSettingsMap[metric].currTimeUnit || - groupBy !== groupBySettingMap.groupBy.groupBy || - exporterType !== dataRetentionSettingMap.dataRetention.exporterType || - deleteAfterDays !== dataRetentionSettingMap.dataRetention.deleteAfterDays; - - const isValid = (() => { - const nVal = parseInt(topNSize, 10); - if (nVal < 1 || nVal > 100) return false; - if (time === TIME_UNITS_TEXT[0].value) return true; - const windowVal = parseInt(windowSize, 10); - return windowVal >= 1 && windowVal <= 24; - })(); - - const formRowPadding = { padding: '0px 0px 20px' }; - const enabledSymb = Enabled; - const disabledSymb = Disabled; - return (
{}} onSelectedDataSource={() => { configInfo(true); + void loadFromCluster(); }} dataSourcePickerReadOnly={false} /> + -

Top n queries monitoring configuration settings

+

Top N queries monitoring configuration settings

+ Metric Type, @@ -258,19 +402,21 @@ const Configuration = ({ /> - + setMetric(e.target.value as MetricKey)} + aria-label="Metric type" /> + Enabled, @@ -280,23 +426,21 @@ const Configuration = ({ /> - - - - - + + setIsEnabled(e.target.checked)} + data-test-subj="top-n-metric-toggle" + aria-label="Enable metric" + /> {isEnabled ? ( <> Value of N (count), @@ -310,17 +454,17 @@ const Configuration = ({ setTopNSize(clamp(toInt(e.target.value), 1, 100))} /> + onTimeChange(e.target.value as UnitUI)} + aria-label="Window size unit" />
@@ -359,6 +504,7 @@ const Configuration = ({ ) : null} + @@ -376,7 +522,7 @@ const Configuration = ({ - {latencySettings.isEnabled ? enabledSymb : disabledSymb} + {statusLatency ? enabledSymb : disabledSymb} @@ -385,7 +531,7 @@ const Configuration = ({ - {cpuSettings.isEnabled ? enabledSymb : disabledSymb} + {statusCpu ? enabledSymb : disabledSymb} @@ -394,13 +540,14 @@ const Configuration = ({ - {memorySettings.isEnabled ? enabledSymb : disabledSymb} + {statusMemory ? enabledSymb : disabledSymb} + @@ -414,11 +561,11 @@ const Configuration = ({ Group By, - description: ' Specify the group by type.', + description: 'Specify the group by type.', }, ]} /> @@ -430,7 +577,8 @@ const Configuration = ({ required={true} options={GROUP_BY_OPTIONS} value={groupBy} - onChange={onGroupByChange} + onChange={(e) => setGroupBy(e.target.value)} + aria-label="Group by" /> @@ -453,13 +601,14 @@ const Configuration = ({ - {groupBySettings.groupBy === 'similarity' ? enabledSymb : disabledSymb} + {groupBy === 'similarity' ? enabledSymb : disabledSymb} + @@ -489,7 +638,8 @@ const Configuration = ({ required={true} options={EXPORTER_TYPES_LIST} value={exporterType} - onChange={onExporterTypeChange} + onChange={(e) => setExporterType(e.target.value)} + aria-label="Exporter type" /> @@ -510,8 +660,11 @@ const Configuration = ({ disabled={exporterType !== EXPORTER_TYPE.localIndex} min={1} max={180} - value={exporterType !== EXPORTER_TYPE.localIndex ? '' : deleteAfterDays} - onChange={onDeleteAfterDaysChange} + value={ + exporterType !== EXPORTER_TYPE.localIndex ? undefined : deleteAfterDays + } + onChange={(e) => setDeleteAfterDays(clamp(toInt(e.target.value), 1, 180))} + aria-label="Delete after days" /> @@ -520,6 +673,7 @@ const Configuration = ({ + @@ -541,45 +695,31 @@ const Configuration = ({ - {isChanged && isValid ? ( + + {isChanged && ( - + Cancel { - configInfo( - false, - isEnabled, - metric, - topNSize, - windowSize, - time, - exporterType, - groupBy, - deleteAfterDays - ); - return history.push(QUERY_INSIGHTS); - }} + onClick={onSave} + isDisabled={isEnabled && !isValidWindow} > Save - ) : null} + )}
); -}; - -// eslint-disable-next-line import/no-default-export -export default Configuration; +} diff --git a/public/pages/Configuration/__snapshots__/Configuration.test.tsx.snap b/public/pages/Configuration/__snapshots__/Configuration.test.tsx.snap index 9d484aeb..707ae49b 100644 --- a/public/pages/Configuration/__snapshots__/Configuration.test.tsx.snap +++ b/public/pages/Configuration/__snapshots__/Configuration.test.tsx.snap @@ -1,974 +1,117 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Configuration Component renders with default settings: should match default settings snapshot 1`] = ` -
-
-
-
-
-
-
-

- Top n queries monitoring configuration settings -

-
-
-
-
-
-
-

- Metric Type -

-
-
- Specify the metric type to set settings for. -
-
-
-
-
-
-
-
- -
- - - -
-
-
-
-
-
-
-
-
-

- Enabled -

-
-
- Enable/disable top N query monitoring by latency. -
-
-
-
-
-
-
-
-
- - -
-
-
-
-
-
-
-
-

- Value of N (count) -

-
-
- Specify the value of N. N is the number of queries to be collected within the window size. -
-
-
-
-
-
- -
-
-
-
- -
-
-
- Max allowed limit 100. -
-
-
-
-
-
-
-

- Window size -

-
-
- The duration during which the Top N queries are collected. -
-
-
-
-
-
- -
-
-
-
-
-
- -
- - - -
-
-
-
-
-
-
- -
- - - -
-
-
-
-
-
- Max allowed limit 24 hours. -
-
-
-
-
-
-
-
-
-
-
-
-

- Statuses for configuration metrics -

-
-
-
-
-
- Latency -
-
-
-
-
-
-
- -
-
- Enabled -
-
-
-
-
-
-
-
- CPU Usage -
-
-
-
-
-
-
- -
-
- Disabled -
-
-
-
-
-
-
-
- Memory -
-
-
-
-
-
-
- -
-
- Disabled -
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- Top n queries grouping configuration settings -

-
-
-
-
-
-
-

- Group By -

-
-
- Specify the group by type. -
-
-
-
-
-
-
-
- -
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-

- Statuses for group by -

-
-
-
-
-
- Group By -
-
-
-
-
-
-
- -
-
- Disabled -
-
-
-
-
-
-
-
-
+exports[`Configuration Component renders with default settings should match default settings snapshot 1`] = ` +
+
+ Mock DataSourceMenu +
+
-
-

- Query Insights export and data retention settings +

+ Top N queries monitoring configuration settings

+
+
-
-
-
-

- Exporter -

-
-
- Configure a sink for exporting Query Insights data. -
-
-
+

+ Metric Type +

+ +
+ Specify the metric type to set settings for. +
+ +
+
-
- -
+
-
-
-
-
-
-
-
-
-

- Delete After (days) -

-
-
- Number of days to retain Query Insights data. -
-
-
-
-
-
-
+ CPU + + +
- + + +
@@ -977,76 +120,72 @@ exports[`Configuration Component renders with default settings: should match def
-
+
+
+
-

- Statuses for data retention +

+ Statuses for configuration metrics

+
+
- Exporter + Latency
+
+
+ -
- -
+ + + Enabled +
@@ -1056,7 +195,7 @@ exports[`Configuration Component renders with default settings: should match def
`; -exports[`Configuration Component updates state when toggling metrics and enables Save button when changes are made: should match settings snapshot after toggling 1`] = ` +exports[`Configuration Component renders with default settings: should match default settings snapshot 1`] = `
- Top n queries monitoring configuration settings + Top N queries monitoring configuration settings
-
-
-
-
-
-
- -
- - - -
-
-
+
- Max allowed limit 24 hours. + Examples: 15m, 45m, 1h, 6h.
@@ -1521,7 +589,7 @@ exports[`Configuration Component updates state when toggling metrics and enables >
- Specify the group by type. + Specify the group by type.
@@ -1709,7 +777,6 @@ exports[`Configuration Component updates state when toggling metrics and enables
@@ -1979,7 +1047,6 @@ exports[`Configuration Component updates state when toggling metrics and enables
@@ -2055,7 +1123,7 @@ exports[`Configuration Component updates state when toggling metrics and enables >