diff --git a/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx b/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx new file mode 100644 index 000000000..72b814b02 --- /dev/null +++ b/public/components/CreateForecasterButtons/CreateForecasterButtons.tsx @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSmallButton } from '@elastic/eui'; +import React from 'react'; +import { APP_PATH, FORECASTING_FEATURE_NAME } from '../../utils/constants'; +import { useLocation } from 'react-router-dom'; +import { constructHrefWithDataSourceId, getDataSourceFromURL } from '../../pages/utils/helpers'; + +export const CreateForecasterButtons = () => { + const location = useLocation(); + const MDSQueryParams = getDataSourceFromURL(location); + const dataSourceId = MDSQueryParams.dataSourceId; + + const createForecasterUrl = `${FORECASTING_FEATURE_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.CREATE_FORECASTER}`, dataSourceId, false); + + return ( + + + + Create forecaster + + + + ); +}; diff --git a/public/components/FormattedFormRow/FormattedFormRow.tsx b/public/components/FormattedFormRow/FormattedFormRow.tsx index 6c5bb25a6..7500b83cd 100644 --- a/public/components/FormattedFormRow/FormattedFormRow.tsx +++ b/public/components/FormattedFormRow/FormattedFormRow.tsx @@ -16,7 +16,7 @@ type FormattedFormRowProps = { title?: string; formattedTitle?: ReactNode; children: ReactElement; - hint?: string | string[]; + hint?: string | string[] | ReactNode | ReactNode[]; isInvalid?: boolean; error?: ReactNode | ReactNode[]; fullWidth?: boolean; @@ -42,7 +42,8 @@ export const FormattedFormRow = (props: FormattedFormRowProps) => { )) : null; - const { formattedTitle, linkToolTip, ...euiFormRowProps } = props; + // Extract hintLink to avoid passing it to EuiCompressedFormRow as an unknown prop + const { formattedTitle, hintLink, linkToolTip, ...euiFormRowProps } = props; return ( { + const location = useLocation(); + const MDSQueryParams = getDataSourceFromURL(location); + const dataSourceId = MDSQueryParams.dataSourceId; + + const refreshForecastersUrl = `${FORECASTING_FEATURE_NAME}#` + constructHrefWithDataSourceId(`${APP_PATH.LIST_FORECASTERS}`, dataSourceId, false); + + return ( + + + + Refresh + + + + ); +}; \ No newline at end of file diff --git a/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx b/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx new file mode 100644 index 000000000..027db835e --- /dev/null +++ b/public/components/RefreshForecastersButton/__tests__/RefreshForecastersButton.test.tsx @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { RefreshForecastersButton } from '../RefreshForecastersButton'; +import { getDataSourceEnabled, } from '../../../services'; + +// Mock the module +jest.mock('../../../services', () => ({ + ...jest.requireActual('../../../services'), + getDataSourceEnabled: jest.fn(), +})); + +describe(' spec', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + href: 'http://test.com', + pathname: '/', + search: '', + hash: '', + }, + writable: true + }); + }); + + beforeEach(() => { + // Mock the return value + (getDataSourceEnabled as jest.Mock).mockReturnValue({ + enabled: false, // or true, depending on what you want to test + }); + }); + + test('renders component', () => { + const history = createMemoryHistory(); + const { container, getByText } = render( + + + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByText('Refresh')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap b/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap new file mode 100644 index 000000000..ee880e13b --- /dev/null +++ b/public/components/RefreshForecastersButton/__tests__/__snapshots__/RefreshForecastersButton.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders component 1`] = ` + +`; diff --git a/public/forecasting_app.tsx b/public/forecasting_app.tsx new file mode 100644 index 000000000..4a5713bb0 --- /dev/null +++ b/public/forecasting_app.tsx @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { CoreStart, AppMountParameters } from '../../../src/core/public'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Route } from 'react-router-dom'; +import { ForecastMain } from './pages/main/ForecastMain'; +import { Provider } from 'react-redux'; +import configureStore from './redux/configureStore'; +import { CoreServicesContext } from './components/CoreServices/CoreServices'; + +export function renderApp(coreStart: CoreStart, params: AppMountParameters, landingPage: string | undefined, hideInAppSideNavBar: boolean) { + const http = coreStart.http; + const store = configureStore(http); + + // Load Chart's dark mode CSS (if applicable) + const isDarkMode = coreStart.uiSettings.get('theme:darkMode') || false; + if (isDarkMode) { + require('@elastic/charts/dist/theme_only_dark.css'); + } else { + require('@elastic/charts/dist/theme_only_light.css'); + } + + ReactDOM.render( + + + ( + + + + )} + /> + + , + params.element + ); + return () => ReactDOM.unmountComponentAtNode(params.element); +} diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index 74a0a1e69..09896fbf4 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -11,7 +11,7 @@ import { InitProgress } from '../../server/models/interfaces'; import { DATA_TYPES } from '../utils/constants'; -import { DETECTOR_STATE } from '../../server/utils/constants'; +import { DETECTOR_STATE, FORECASTER_STATE } from '../../server/utils/constants'; import { Duration } from 'moment'; import moment from 'moment'; import { MDSQueryParams } from '../../server/models/types'; @@ -231,6 +231,56 @@ export type DetectorListItem = { detectorType?: string; }; +export type Forecaster = { + primaryTerm: number; + seqNo: number; + id: string; + name: string; + description: string; + timeField: string; + indices: string[]; + resultIndex?: string; + resultIndexMinAge?: number; + resultIndexMinSize?: number; + resultIndexTtl?: number; + flattenCustomResultIndex?: boolean; + filterQuery: { [key: string]: any }; + featureAttributes: FeatureAttributes[]; + windowDelay: { period: Schedule }; + forecastInterval: { period: Schedule }; + shingleSize: number; + uiMetadata: UiMetaData; + lastUpdateTime: number; + enabled?: boolean; + enabledTime?: number; + disabledTime?: number; + curState: FORECASTER_STATE; // combined state of realTime and runOnce + stateError: string; // combined error of realTime and runOnce + initProgress?: InitProgress; // realTime + categoryField?: string[]; + taskId?: string; // runOnce + taskState?: FORECASTER_STATE; // runOnce + taskProgress?: number; // runOnce + taskError?: string; // runOnce + lastStateUpdateTime?: number; + imputationOption?: ImputationOption; + suggestedSeasonality?: number; + recencyEmphasis?: number; + horizon?: number; + history?: number; + lastUiBreakingChangeTime?: number; +}; + +export type ForecasterListItem = { + id: string; + name: string; + indices: string[]; + curState: FORECASTER_STATE; + realTimeLastUpdateTime: number; + runOnceLastUpdateTime: number; + stateError: string; +}; + export type EntityData = { name: string; value: string; @@ -342,3 +392,31 @@ export interface MDSStates { queryParams: MDSQueryParams; selectedDataSourceId: string | undefined; } + +/* export type ForecastData = { + dataQuality: number; + forecasterId?: string; + endTime: number; + startTime: number; + plotTime?: number; + entity?: EntityData[]; + features?: { [key: string]: ForecastFeatureAggregationData }; + aggInterval?: string; +}; */ + +export type ForecastFeatureAggregationData = { + data: number; + name?: string; + endTime: number; + startTime: number; + plotTime?: number; + lowerBound: number[]; + upperBound: number[]; + forecastValue: number[]; + forecastStartTime: number[]; + forecastEndTime: number[]; +}; + +/* export type ForecastResult = { + forecastResult: ForecastData[]; +}; */ diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx b/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx new file mode 100644 index 000000000..f66f234ee --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/AdvancedSettings.tsx @@ -0,0 +1,345 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { useEffect, useState } from 'react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiText, + EuiTitle, + EuiCompressedFieldNumber, + EuiSpacer, + EuiCompressedSelect, + EuiSmallButtonIcon, + EuiCompressedFieldText, + EuiToolTip, + EuiIcon, + EuiBadge, +} from '@elastic/eui'; +import { Field, FieldProps, FieldArray } from 'formik'; +import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; +import { + FORECASTER_DOCS_LINK, + FIELD_MAX_WIDTH, + INPUT_SLIDER_WIDTH, +} from '../../../../utils/constants'; +import { + isInvalid, + getError, + validatePositiveInteger, + validatePositiveDecimal, + validateEmptyOrPositiveInteger, +} from '../../../../utils/utils'; +import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow'; +import { SparseDataOptionValue } from '../../utils/constants'; +import '../../index.scss'; +import React, { ReactElement } from 'react'; +import { toNumberOrEmpty } from '../../utils/helpers'; + +interface AdvancedSettingsProps { + isEditable?: boolean; +} + +export function AdvancedSettings({ isEditable = true }: AdvancedSettingsProps) { + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + + const sparseDataOptions = [ + { value: SparseDataOptionValue.IGNORE, text: 'Ignore missing value' }, + { value: SparseDataOptionValue.PREVIOUS_VALUE, text: 'Previous value' }, + { value: SparseDataOptionValue.SET_TO_ZERO, text: 'Set to zero' }, + { value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' }, + ]; + + return ( + + + { + setShowAdvancedSettings(!showAdvancedSettings); + }} + style={{ cursor: isEditable ? 'pointer' : 'not-allowed' }} + /> + + +

Advanced model parameters

+
+ + } + hideBody={!showAdvancedSettings} + bodyStyles={{ marginTop: '-16px' }} + > + {showAdvancedSettings ? : null} + {showAdvancedSettings ? ( + <> + {/* --------------------- Shingle size --------------------- */} + + {({ field, form }: FieldProps) => ( + + {/* Wrap the number input + badge in a single FlexGroup */} + + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + intervals + + +
+
+ )} +
+ + {/* ----------------- Suggested seasonality ----------------- */} + + {({ field, form }: FieldProps) => ( + + + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + intervals + + +
+
+ )} +
+ + {/* ------------------- Recency emphasis ------------------- */} + + {({ field, form }: FieldProps) => ( + + Recency emphasis is like the "window size" in a classic + moving average, except that it gradually stops considering older + data rather than dropping it all at once. In a fixed moving average of + size{' '}W, each data point stays in the sample for exactly + {' '}W steps, then is removed entirely. By contrast, a higher + recency emphasis on average retains a data point in the sample + for more steps, but with an exponential decay—recent data + gets the most weight, and older data slowly fades rather than abruptly + dropping. +
+
+ Mathematically, the "lifetime" of each data point (how many steps it stays + influential) follows an approximate exponential distribution. The + recency emphasis value is the mean of that distribution, i.e., the + average number of steps a point remains in the sample. A bigger emphasis + makes forecasts react more slowly to recent changes (like having a larger + window size), while a smaller emphasis adapts faster but risks overreacting + to short-term noise. The default is 2560, and you must have + at least 1. + , + ]} + hintLink={FORECASTER_DOCS_LINK} + isInvalid={isInvalid(field.name, form)} + error={getError(field.name, form)} + > + + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + intervals + + +
+
+ )} +
+ + {/* ---------------- Sparse data handling ---------------- */} + + {({ field, form }: FieldProps) => { + // If "Custom value" is selected, ensure at least one row is present + useEffect(() => { + if ( + field.value === SparseDataOptionValue.CUSTOM_VALUE && + (!form.values.imputationOption?.custom_value || + form.values.imputationOption.custom_value.length === 0) + ) { + form.setFieldValue('imputationOption.custom_value', [ + { featureName: '', value: undefined }, + ]); + } + }, [field.value, form]); + + return ( + <> + +
+ +
+
+ + {field.value === SparseDataOptionValue.CUSTOM_VALUE && ( +
+ + +
Custom value
+
+ + + {(arrayHelpers) => ( + <> + {form.values.imputationOption.custom_value?.map( + (_, index) => ( + + + + {({ field }: FieldProps) => ( +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + value={field.value || ''} + /> +
+ )} +
+
+
+ ) + )} + + )} +
+
+ )} + + ); + }} +
+ + ) : null} +
+ ); +} diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx b/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx new file mode 100644 index 000000000..e052608e8 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx @@ -0,0 +1,364 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import { AdvancedSettings } from '../AdvancedSettings'; +import { SparseDataOptionValue } from '../../../utils/constants'; + +const defaultInitialValues = { + shingleSize: '', + suggestedSeasonality: '', + recencyEmphasis: '', + imputationOption: { + imputationMethod: SparseDataOptionValue.IGNORE, + custom_value: [], + }, +}; + +const renderWithFormik = (initialValues = defaultInitialValues, props = {}) => { + return render( + + {() => } + + ); +}; + +const expandPanel = () => { + // Find the arrow icon - it could have cursor: pointer (editable) or cursor: not-allowed (non-editable) + const icons = screen.getAllByRole('img', { hidden: true }); + const arrowIcon = icons.find(icon => { + const style = window.getComputedStyle(icon); + // Look for the icon that has either cursor pointer or not-allowed (both are the arrow icon) + return style.cursor === 'pointer' || style.cursor === 'not-allowed'; + }); + + if (arrowIcon) { + fireEvent.click(arrowIcon); + } else { + // Fallback to clicking the first icon if we can't find the right one + fireEvent.click(icons[0]); + } +}; + +describe('AdvancedSettings Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Component rendering and collapsibility', () => { + test('renders collapsed by default', () => { + renderWithFormik(); + + expect(screen.getByText('Advanced model parameters')).toBeInTheDocument(); + expect(screen.queryByTestId('shingleSize')).not.toBeInTheDocument(); + expect(screen.queryByTestId('suggestedSeasonality')).not.toBeInTheDocument(); + expect(screen.queryByTestId('recencyEmphasis')).not.toBeInTheDocument(); + }); + + test('expands when arrow icon is clicked', () => { + renderWithFormik(); + + expandPanel(); + + expect(screen.getByTestId('shingleSize')).toBeInTheDocument(); + expect(screen.getByTestId('suggestedSeasonality')).toBeInTheDocument(); + expect(screen.getByTestId('recencyEmphasis')).toBeInTheDocument(); + }); + + test('icon is clickable even when not editable', () => { + renderWithFormik(defaultInitialValues, { isEditable: false }); + + expandPanel(); + + // Panel should expand even when not editable + expect(screen.getByTestId('shingleSize')).toBeInTheDocument(); + expect(screen.getByTestId('suggestedSeasonality')).toBeInTheDocument(); + expect(screen.getByTestId('recencyEmphasis')).toBeInTheDocument(); + }); + }); + + describe('Shingle size field', () => { + beforeEach(() => { + renderWithFormik(); + expandPanel(); + }); + + test('renders shingle size field with correct attributes', () => { + const shingleSizeInput = screen.getByTestId('shingleSize'); + + expect(shingleSizeInput).toBeInTheDocument(); + expect(shingleSizeInput).toHaveAttribute('min', '4'); + expect(shingleSizeInput).toHaveAttribute('max', '128'); + expect(shingleSizeInput).toHaveAttribute('placeholder', 'Shingle size'); + + // Check that at least one intervals badge exists + const intervalsBadges = screen.getAllByText('intervals'); + expect(intervalsBadges.length).toBeGreaterThan(0); + }); + + test('displays help text for shingle size', () => { + expect(screen.getByText(/Set the number of past forecast intervals/)).toBeInTheDocument(); + expect(screen.getByText(/shingle size to be in the range of 4 and 128/)).toBeInTheDocument(); + }); + + test('accepts valid shingle size values', async () => { + const shingleSizeInput = screen.getByTestId('shingleSize'); + + fireEvent.change(shingleSizeInput, { target: { value: '16' } }); + expect((shingleSizeInput as HTMLInputElement).value).toBe('16'); + }); + }); + + describe('Non-editable mode', () => { + test('shingle size is disabled when not editable', () => { + renderWithFormik(defaultInitialValues, { isEditable: false }); + expandPanel(); + + const shingleSizeInput = screen.getByTestId('shingleSize'); + expect(shingleSizeInput).toBeDisabled(); + }); + }); + + describe('Suggested seasonality field', () => { + beforeEach(() => { + renderWithFormik(); + expandPanel(); + }); + + test('renders suggested seasonality field with correct attributes', () => { + const seasonalityInput = screen.getByTestId('suggestedSeasonality'); + + expect(seasonalityInput).toBeInTheDocument(); + expect(seasonalityInput).toHaveAttribute('min', '8'); + expect(seasonalityInput).toHaveAttribute('max', '256'); + }); + + test('displays help text for suggested seasonality', () => { + expect(screen.getByText(/consistent seasonal variation of the data/)).toBeInTheDocument(); + expect(screen.getByText(/suggested seasonality to be in the range of 8 and 256/)).toBeInTheDocument(); + }); + + test('accepts valid seasonality values', async () => { + const seasonalityInput = screen.getByTestId('suggestedSeasonality'); + + fireEvent.change(seasonalityInput, { target: { value: '24' } }); + expect((seasonalityInput as HTMLInputElement).value).toBe('24'); + }); + }); + + describe('Recency emphasis field', () => { + beforeEach(() => { + renderWithFormik(); + expandPanel(); + }); + + test('renders recency emphasis field with correct attributes', () => { + const recencyInput = screen.getByTestId('recencyEmphasis'); + + expect(recencyInput).toBeInTheDocument(); + expect(recencyInput).toHaveAttribute('min', '1'); + }); + + test('displays detailed help text for recency emphasis', () => { + expect(screen.getByText(/window size.*in a classic moving average/)).toBeInTheDocument(); + expect(screen.getByText(/exponential decay/)).toBeInTheDocument(); + // Test for the parts separately since they're in different elements + expect(screen.getByText(/The default is/)).toBeInTheDocument(); + expect(screen.getByText('2560')).toBeInTheDocument(); + }); + + test('accepts valid recency emphasis values', async () => { + const recencyInput = screen.getByTestId('recencyEmphasis'); + + fireEvent.change(recencyInput, { target: { value: '2560' } }); + expect((recencyInput as HTMLInputElement).value).toBe('2560'); + }); + }); + + describe('Sparse data handling', () => { + beforeEach(() => { + renderWithFormik(); + expandPanel(); + }); + + test('renders sparse data handling dropdown with all options', () => { + const dropdown = screen.getByDisplayValue('Ignore missing value'); + + expect(dropdown).toBeInTheDocument(); + expect(screen.getByText('Choose how to handle missing data points.')).toBeInTheDocument(); + }); + + test('shows all sparse data options', () => { + const dropdown = screen.getByDisplayValue('Ignore missing value'); + + // Check that all options are available in the select + expect(dropdown.querySelector('option[value="ignore"]')).toBeInTheDocument(); + expect(dropdown.querySelector('option[value="previous_value"]')).toBeInTheDocument(); + expect(dropdown.querySelector('option[value="set_to_zero"]')).toBeInTheDocument(); + expect(dropdown.querySelector('option[value="custom_value"]')).toBeInTheDocument(); + }); + + test('changes sparse data handling option', () => { + const dropdown = screen.getByDisplayValue('Ignore missing value'); + + fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.PREVIOUS_VALUE } }); + expect(dropdown).toHaveValue(SparseDataOptionValue.PREVIOUS_VALUE); + }); + + test('shows custom value section when custom value is selected', () => { + const dropdown = screen.getByDisplayValue('Ignore missing value'); + + fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.CUSTOM_VALUE } }); + + expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Custom value')).toBeInTheDocument(); + }); + + test('hides custom value section when other options are selected', () => { + // First select custom value + const dropdown = screen.getByDisplayValue('Ignore missing value'); + fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.CUSTOM_VALUE } }); + expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument(); + + // Then select a different option + fireEvent.change(dropdown, { target: { value: SparseDataOptionValue.SET_TO_ZERO } }); + expect(screen.queryByRole('heading', { level: 5, name: 'Custom value' })).not.toBeInTheDocument(); + }); + }); + + describe('Custom value functionality', () => { + test('automatically adds custom value field when custom value option is selected', () => { + const initialValues = { + ...defaultInitialValues, + imputationOption: { + imputationMethod: SparseDataOptionValue.CUSTOM_VALUE, + custom_value: [], + }, + }; + + renderWithFormik(initialValues); + expandPanel(); + + expect(screen.getByRole('heading', { level: 5, name: 'Custom value' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Custom value')).toBeInTheDocument(); + }); + + test('custom value input accepts numeric values', async () => { + const initialValues = { + ...defaultInitialValues, + imputationOption: { + imputationMethod: SparseDataOptionValue.CUSTOM_VALUE, + custom_value: [{ data: '' }], + }, + }; + + renderWithFormik(initialValues); + expandPanel(); + + const customValueInput = screen.getByPlaceholderText('Custom value'); + fireEvent.change(customValueInput, { target: { value: '42.5' } }); + + expect((customValueInput as HTMLInputElement).value).toBe('42.5'); + }); + + test('custom value field is disabled when not editable', () => { + const initialValues = { + ...defaultInitialValues, + imputationOption: { + imputationMethod: SparseDataOptionValue.CUSTOM_VALUE, + custom_value: [{ data: '' }], + }, + }; + + renderWithFormik(initialValues, { isEditable: false }); + expandPanel(); + + const customValueInput = screen.getByPlaceholderText('Custom value'); + expect(customValueInput).toBeDisabled(); + }); + }); + + describe('Form integration', () => { + test('handles empty string values for numeric fields', () => { + renderWithFormik(); + expandPanel(); + + const shingleSizeInput = screen.getByTestId('shingleSize'); + fireEvent.change(shingleSizeInput, { target: { value: '' } }); + + expect((shingleSizeInput as HTMLInputElement).value).toBe(''); + }); + + test('converts string input to numbers for numeric fields', () => { + renderWithFormik(); + expandPanel(); + + const shingleSizeInput = screen.getByTestId('shingleSize'); + fireEvent.change(shingleSizeInput, { target: { value: '16' } }); + + expect((shingleSizeInput as HTMLInputElement).value).toBe('16'); + }); + + test('displays initial values correctly', () => { + const initialValues = { + shingleSize: 16, + suggestedSeasonality: 24, + recencyEmphasis: 2560, + imputationOption: { + imputationMethod: SparseDataOptionValue.PREVIOUS_VALUE, + custom_value: [], + }, + }; + + renderWithFormik(initialValues); + expandPanel(); + + expect((screen.getByTestId('shingleSize') as HTMLInputElement).value).toBe('16'); + expect((screen.getByTestId('suggestedSeasonality') as HTMLInputElement).value).toBe('24'); + expect((screen.getByTestId('recencyEmphasis') as HTMLInputElement).value).toBe('2560'); + expect(screen.getByDisplayValue('Previous value')).toBeInTheDocument(); + }); + }); + + describe('Accessibility and UX', () => { + test('has proper labels and help text', () => { + renderWithFormik(); + expandPanel(); + + // Use getAllByText and target the first occurrence (which should be the field label) + expect(screen.getByText('Shingle size')).toBeInTheDocument(); + expect(screen.getByText('Suggested seasonality')).toBeInTheDocument(); + expect(screen.getAllByText('Recency emphasis')[0]).toBeInTheDocument(); + expect(screen.getByText('Sparse data handling')).toBeInTheDocument(); + }); + + test('shows unit badges for numeric fields', () => { + renderWithFormik(); + expandPanel(); + + const intervalsBadges = screen.getAllByText('intervals'); + expect(intervalsBadges).toHaveLength(3); // shingle size, seasonality, recency emphasis + }); + + test('all fields are properly disabled in non-editable mode', () => { + renderWithFormik(defaultInitialValues, { isEditable: false }); + expandPanel(); + + expect(screen.getByTestId('shingleSize')).toBeDisabled(); + expect(screen.getByTestId('suggestedSeasonality')).toBeDisabled(); + expect(screen.getByTestId('recencyEmphasis')).toBeDisabled(); + expect(screen.getByDisplayValue('Ignore missing value')).toBeDisabled(); + }); + }); +}); diff --git a/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts b/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts new file mode 100644 index 000000000..0e6e7a49e --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/AdvancedSettings/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { AdvancedSettings } from './AdvancedSettings'; diff --git a/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx b/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx new file mode 100644 index 000000000..e5533c83c --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/CustomResultIndex/CustomResultIndex.tsx @@ -0,0 +1,334 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + EuiFlexItem, + EuiFlexGroup, + EuiText, + EuiLink, + EuiTitle, + EuiCompressedFieldText, + EuiCallOut, + EuiSpacer, + EuiCompressedFormRow, + EuiCompressedCheckbox, + EuiIcon, + EuiCompressedFieldNumber, +} from '@elastic/eui'; +import { Field, FieldProps, FormikProps, useFormikContext } from 'formik'; +import React, { useEffect, useState } from 'react'; +import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; +import { CUSTOM_FORECASTER_RESULT_INDEX_PREFIX } from '../../../../../server/utils/constants'; +import { BASE_DOCS_LINK, FORECASTER_DOCS_LINK } from '../../../../utils/constants'; +import { + isInvalid, + getError, + validateCustomResultIndex, + validateEmptyOrPositiveInteger, +} from '../../../../utils/utils'; +import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow'; +import { ModelConfigurationFormikValues } from '../../models/interfaces'; +import { DetailsFormikValues } from '../../../ForecastDetail/models/interface'; +import { get } from 'lodash'; + +interface CustomResultIndexProps { + useDefaultResultIndex?: boolean; + resultIndex?: string; + formikProps: FormikProps | FormikProps; + readOnly?: boolean; +} + +function CustomResultIndex(props: CustomResultIndexProps) { + const [enabled, setEnabled] = useState(!!props.resultIndex); + const [customResultIndexConditionsEnabled, setCustomResultIndexConditionsEnabled] = useState(true); + const customResultIndexMinAge = get(props.formikProps, 'values.resultIndexMinAge'); + const customResultIndexMinSize = get(props.formikProps, 'values.resultIndexMinSize'); + const customResultIndexTTL = get(props.formikProps, 'values.resultIndexTtl'); + const { setFieldValue } = useFormikContext(); + + useEffect(() => { + if (customResultIndexMinAge === undefined && customResultIndexMinSize === undefined && customResultIndexTTL === undefined) { + setCustomResultIndexConditionsEnabled(false); + } + + if (!customResultIndexConditionsEnabled) { + setFieldValue('resultIndexMinAge', ''); + setFieldValue('resultIndexMinSize', ''); + setFieldValue('resultIndexTtl', ''); + } + },[customResultIndexConditionsEnabled]) + + const hintTextStyle = { + color: '#69707d', + fontSize: '12px', + lineHeight: '16px', + fontWeight: 'normal', + fontFamily: 'Helvetica, sans-serif', + textAlign: 'left', + // Ensures long text wraps within the container and doesn't overflow + wordWrap: 'break-word', + maxWidth: '400px', +}; + + return ( + +

Custom result index

+ + } + subTitle={ + + Store forecaster results to your own index.{' '} + + Learn more + + + } + > + + {({ field, form }: FieldProps) => ( + + + { + if (!props.readOnly) { + if (enabled) { + form.setFieldValue('resultIndex', undefined); + } + setEnabled(!enabled); + } + }} + /> + + + + {/* Unlike anomaly detection, forecasting allows editing of all fields after creation */} + + {enabled ? ( + + + { + const userInput = e.target.value; + const fullValue = CUSTOM_FORECASTER_RESULT_INDEX_PREFIX + userInput; + form.setFieldValue(field.name, fullValue); + }} + /> + + + ) : null} + + )} + + + + + { enabled ? ( + + {({ field, form }: FieldProps) => ( + + + +

Flattening the custom result index will make it easier to query them on the dashboard. It also allows you to perform term aggregations on categorical fields.

+
+
+ )} +
) : null} +
+ + {enabled ? ( + + { + if (!props.readOnly) { + setCustomResultIndexConditionsEnabled(!customResultIndexConditionsEnabled); + } + }} + /> + + ) : null} + +
+ + { (enabled && customResultIndexConditionsEnabled) ? ( + {({ field, form }: FieldProps) => ( + + + + Min Index Age - optional +

+ } + hint={[ + `This setting would define a specific threshold for the age of an index. When this threshold is surpassed, a rollover will be triggered automatically.`, + ]} + isInvalid={isInvalid(field.name, form)} + error={getError(field.name, form)} + > + + + + + + +

days

+
+
+
+
+
+
+ )} +
) : null} + + {(enabled && customResultIndexConditionsEnabled) ? ( + {({ field, form }: FieldProps) => ( + + + + Min Index Size - optional +

+ } + hint={[ + `This setting would define a specific threshold for the size of an index. When this threshold is surpassed, a rollover will be triggered automatically.`, + ]} + isInvalid={isInvalid(field.name, form)} + error={getError(field.name, form)} + > + + + + + + +

MB

+
+
+
+
+
+
+ )} +
) : null} + + {(enabled && customResultIndexConditionsEnabled) ? ( + {({ field, form }: FieldProps) => ( + + + + Index TTL - optional +

+ } + hint={[ + `This setting would define the duration after which an index is considered expired and eligible for deletion.`, + ]} + isInvalid={isInvalid(field.name, form)} + error={getError(field.name, form)} + > + + + + + + +

days

+
+
+
+
+
+
+ )} +
) : null} +
+ ); +} + +export default CustomResultIndex; diff --git a/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts b/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts new file mode 100644 index 000000000..0183cb7ab --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/CustomResultIndex/index.ts @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import CustomResultIndex from './CustomResultIndex'; + +export { CustomResultIndex }; diff --git a/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx b/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx new file mode 100644 index 000000000..0fb6e9238 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/Settings/Settings.tsx @@ -0,0 +1,338 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { Field, FieldProps, FormikProps } from 'formik'; +import { + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, + EuiCompressedFieldNumber, + EuiRange, + EuiText, + EuiPanel, +} from '@elastic/eui'; +import { + isInvalid, + getError, + validatePositiveInteger, + validateNonNegativeInteger, +} from '../../../../utils/utils'; +import { FORECASTER_DOCS_LINK, FIELD_MAX_WIDTH, INPUT_SLIDER_WIDTH } from '../../../../utils/constants'; +import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow'; +import '../../index.scss'; +import { toNumberOrEmpty } from '../../utils/helpers'; + +interface SettingsProps { + isEditable?: boolean; +} + +export const Settings = ({ isEditable = true }: SettingsProps) => { + return ( + + {/* FORECASTING INTERVAL */} + + {({ field, form }: FieldProps) => { + const value = field.value ?? ''; + return ( + + + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + minutes + + +
+
+ ); + }} +
+ + + + {/* WINDOW DELAY */} + + {({ field, form }: FieldProps) => { + const value = field.value ?? ''; + return ( + + + {/* grow={false} in both the numeric field and unitprevents horizontal expansion, + keeping the numeric field at its natural width and allows the unit badge to sit directly next to it. + Otherwise, there is a gap between the numeric field and the unit badge. */} + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + minutes + + +
+
+ ); + }} +
+ + + + + {({ field, form }: FieldProps) => { + const [showHorizonDetails, setShowHorizonDetails] = useState(false); + + const interval = (form as FormikProps).values.interval; + const horizon = field.value ?? 0; + const value = field.value ?? ''; + + // Ref to the SLIDER area; if a click happens outside this ref, we close the slider + const sliderPanelRef = useRef(null); + + // UseEffect that only attaches the "outside click" listener when the slider is open + useEffect(() => { + if (!showHorizonDetails) return; + + function handleOutsideClick(e: MouseEvent) { + // If the click is not inside sliderPanelRef => close slider + if ( + sliderPanelRef.current && + !sliderPanelRef.current.contains(e.target as Node) + ) { + setShowHorizonDetails(false); + } + } + + // Note we use capture = true to catch the event early. Could also omit if it works. + document.addEventListener('mousedown', handleOutsideClick, true); + return () => { + document.removeEventListener('mousedown', handleOutsideClick, true); + }; + }, [showHorizonDetails]); + + // 2) If user moves slider => update field + function onSliderChange(newVal: number) { + form.setFieldValue(field.name, newVal); + } + + // For help text + function getHorizonHelpText(h: number, i: number) { + if (!i || !h) return 'A valid horizon is between 1 and 180.'; + const totalMinutes = h * i; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return ( + `${h} intervals = ${hours} hour${hours === 1 ? '' : 's'}${minutes ? ` ${minutes} minute${minutes === 1 ? '' : 's'}` : '' + } if the forecasting interval is ${i} minutes. A valid horizon is between 1 and 180.` + ); + } + + return ( + + {/* + Constrain the entire row for consistency with other fields. + The numeric field + badge is outside the slider ref. + */} +
+ + + {/* 3) onMouseDown to open the slider, with e.stopPropagation() */} +
+ { + // Prevent this click from bubbling to outside-click handler + e.stopPropagation(); + setShowHorizonDetails(true); + }} + onChange={(e) => { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + intervals + + +
+ + {/* + Conditionally render the slider in a ref-wrapped div + so that clicks inside don't close the slider. + */} + {showHorizonDetails && ( + <> + +
+ + onSliderChange(Number(e.currentTarget.value))} + showLabels + showRange + levels={[ + { min: 1, max: 40, color: 'success' }, + { min: 40, max: 180, color: 'warning' }, + ]} + aria-label="Horizon slider" + /> + + + Recommended horizon is up to 40 intervals. + + +
+ + )} +
+
+ ); + }} +
+ + + + {/* HISTORY */} + + {({ field, form }: FieldProps) => { + const interval = (form as FormikProps).values.interval; + const value = field.value ?? ''; + + function getHistoryHelpText(history: number, intv: number) { + if (!intv || !history) return 'Minimum history: 40 intervals.'; + const totalMinutes = history * intv; + const hours = Math.floor(totalMinutes / 60); + const mins = totalMinutes % 60; + return `${history} intervals = ${hours} hour${ + hours === 1 ? '' : 's' + }${ + mins ? ` ${mins} minute${mins === 1 ? '' : 's'}` : '' + } if the forecasting interval is ${intv} minutes. Minimum history: 40 intervals.`; + } + + return ( + + {/* gutterSize="none" ensures there's no default spacing between flex items */} + + + +
+ { + form.setFieldValue(field.name, toNumberOrEmpty(e.target.value)); + }} + /> +
+
+ + + intervals + + +
+
+ ); + }} +
+
+ ); +}; diff --git a/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx b/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx new file mode 100644 index 000000000..e5a18028a --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/Settings/__tests__/Settings.test.tsx @@ -0,0 +1,117 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render, fireEvent, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Settings } from '../Settings'; + +import { Formik } from 'formik'; + +describe(' spec', () => { + test('renders the component', () => { + const { container } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(container.firstChild).toMatchSnapshot(); + }); + test('shows error for empty interval when toggling focus/blur', async () => { + const { queryByText, findByText } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(queryByText('Required')).toBeNull(); + const intervalRow = screen.getByTitle('Forecasting interval'); + const intervalInput = within(intervalRow).getByRole('spinbutton'); + fireEvent.focus(intervalInput); + fireEvent.blur(intervalInput); + expect(findByText('Required')).not.toBeNull(); + }); + test('shows error for invalid interval when toggling focus/blur', async () => { + const { queryByText, findByText } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(queryByText('Required')).toBeNull(); + const intervalRow = screen.getByTitle('Forecasting interval'); + const intervalInput = within(intervalRow).getByRole('spinbutton'); + userEvent.type(intervalInput, '-1'); + fireEvent.blur(intervalInput); + expect(findByText('Must be a positive integer')).not.toBeNull(); + }); + test('shows error for interval of 0 when toggling focus/blur', async () => { + const { queryByText, findByText } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(queryByText('Required')).toBeNull(); + const intervalRow = screen.getByTitle('Forecasting interval'); + const intervalInput = within(intervalRow).getByRole('spinbutton'); + userEvent.type(intervalInput, '0'); + fireEvent.blur(intervalInput); + expect(findByText('Must be a positive integer')).not.toBeNull(); + }); + test('shows error for empty window delay when toggling focus/blur', async () => { + const { queryByText, findByText } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(queryByText('Required')).toBeNull(); + const windowDelayRow = screen.getByTitle('Window delay'); + const windowDelayInput = within(windowDelayRow).getByRole('spinbutton'); + fireEvent.focus(windowDelayInput); + fireEvent.blur(windowDelayInput); + expect(findByText('Required')).not.toBeNull(); + }); + test('shows error for invalid window delay when toggling focus/blur', async () => { + const { queryByText, findByText } = render( + + {() => ( +
+ +
+ )} +
+ ); + expect(queryByText('Required')).toBeNull(); + const windowDelayRow = screen.getByTitle('Window delay'); + const windowDelayInput = within(windowDelayRow).getByRole('spinbutton'); + userEvent.type(windowDelayInput, '-1'); + fireEvent.blur(windowDelayInput); + expect(findByText('Must be a non-negative integer')).not.toBeNull(); + }); +}); diff --git a/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap b/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap new file mode 100644 index 000000000..52cffd092 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap @@ -0,0 +1,494 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + minutes + + + +
+
+
+ The interval must be at least one minute. +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + minutes + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + intervals + + + +
+
+
+
+ Click the number field for more options. +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + intervals + + + +
+
+
+ Minimum history: 40 intervals. +
+
+
+
+`; diff --git a/public/pages/ConfigureForecastModel/components/Settings/index.ts b/public/pages/ConfigureForecastModel/components/Settings/index.ts new file mode 100644 index 000000000..210e06abb --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/Settings/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { Settings } from './Settings'; diff --git a/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx b/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx new file mode 100644 index 000000000..9fc83af9f --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/StorageSettings/StorageSettings.tsx @@ -0,0 +1,129 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +import React from 'react'; +import { + EuiPanel, + EuiRadio, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { FormikProps } from 'formik'; +import { get } from 'lodash'; +import { CustomResultIndex } from '../CustomResultIndex'; +import { ModelConfigurationFormikValues } from '../../models/interfaces'; +import { DetailsFormikValues } from '../../../ForecastDetail/models/interface'; + +interface StorageSettingsProps { + formikProps: FormikProps | FormikProps; + isEditable: boolean; + omitTitle?: boolean; +} + +export function StorageSettings(props: StorageSettingsProps) { + const { formikProps, isEditable, omitTitle = false } = props; + const { values, setFieldValue } = formikProps; + + // If resultIndex is empty, user is on "Default index"; otherwise "Custom index." + const selected = values.resultIndex ? 'custom' : 'default'; + + /** + * Let the user toggle between default and custom by setting/clearing + * resultIndex. If you leave resultIndex empty when user picks custom, + * you'll never switch off "Default index." + */ + const onChangeRadio = (option: 'default' | 'custom') => { + if (option === 'default') { + // Clear out resultIndex => "Default index" is selected + // Setting resultIndex to empty string would cause backend validation error + // as the index name must start with a predefined prefix. Use undefined to indicate + // default index selection. + setFieldValue('resultIndex', undefined); + } else { + // Set a placeholder if needed => "Custom index" is selected + // The user can then override it in the field + if (!values.resultIndex) { + setFieldValue('resultIndex', 'my_custom_forecast_index'); + } + } + }; + + return ( + + {!omitTitle && ( + <> + +

Storage

+

+ Define how to store and manage forecasting results. +

+
+ + + )} + + {/* Two panels side by side for Default vs. Custom */} + + + + Default index} + checked={selected === 'default'} + onChange={() => onChangeRadio('default')} + // FIXME: EuiRadio doesn't support readOnly prop. readOnly would be preferred as disabled makes the radio look gray. + // Consider creating a custom radio component that supports readOnly if this styling is important. + disabled={!isEditable} + /> + + +

+ The forecasting results are retained automatically + for at least 30 days. +

+
+
+
+ + + + Custom index} + checked={selected === 'custom'} + onChange={() => onChangeRadio('custom')} + // FIXME: EuiRadio doesn't support readOnly prop. readOnly would be preferred as disabled makes the radio look gray. + // Consider creating a custom radio component that supports readOnly if this styling is important. + disabled={!isEditable} + /> + + +

+ Route forecast results to your custom index. In a custom index, + you set the retention period and resource allocation. +

+
+
+
+
+ + + + {/* If resultIndex is non-empty => user selected "Custom index," so show the custom index field */} + {values.resultIndex && ( + + )} +
+ ); +} diff --git a/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts b/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts new file mode 100644 index 000000000..db1ebe72d --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/StorageSettings/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { StorageSettings } from './StorageSettings'; \ No newline at end of file diff --git a/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx new file mode 100644 index 000000000..eb083c4c0 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/SuggestParametersDialog.tsx @@ -0,0 +1,311 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { useState } from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiSmallButton, + EuiSmallButtonEmpty, + EuiFormRow, + EuiFieldNumber, + EuiRadioGroup, + EuiAccordion, + EuiText, + EuiCallOut, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { FormikProps } from 'formik'; + +import { suggestForecaster } from '../../../../redux/reducers/forecast'; +import { ForecasterDefinitionFormikValues } from '../../../DefineForecaster/models/interfaces'; +import { formikToForecasterForSuggestion } from '../../../DefineForecaster/utils/helpers'; +import { Forecaster } from '../../../../models/interfaces'; +import { ModelConfigurationFormikValues } from '../../models/interfaces'; + +interface SuggestParametersDialogProps { + onClose: () => void; + dataSourceId: string; + forecasterDefinitionValues: ForecasterDefinitionFormikValues; + formikProps: FormikProps; +} + +type DialogStep = 'config' | 'loading' | 'error' | 'result'; + +export const SuggestParametersDialog: React.FC = ({ + onClose, + dataSourceId, + forecasterDefinitionValues, + formikProps, // destructure the formik bag +}) => { + const dispatch = useDispatch(); + + const [suggestMode, setSuggestMode] = useState<'all' | 'provided'>('all'); + const [providedInterval, setProvidedInterval] = useState(15); + const [shingleSize, setShingleSize] = useState(8); + const [step, setStep] = useState('config'); + const [errorMsg, setErrorMsg] = useState(); + + const [suggestedInterval, setSuggestedInterval] = useState(); + const [suggestedHorizon, setSuggestedHorizon] = useState(); + const [suggestedHistory, setSuggestedHistory] = useState(); + const [suggestedWindowDelay, setSuggestedWindowDelay] = useState(); + + const radioOptions = [ + { id: 'all', label: 'Suggest interval, window delay, horizon, and history' }, + { id: 'provided', label: 'Suggest window delay, horizon, and history for the provided interval' }, + ]; + + const onChangeRadio = (optionId: string) => { + setSuggestMode(optionId as 'all' | 'provided'); + }; + + const onGenerateSuggestions = async () => { + setStep('loading'); + setErrorMsg(undefined); + + const intervalForForecaster = + suggestMode === 'all' ? undefined : providedInterval; + + const forecasterBody: Forecaster = formikToForecasterForSuggestion( + forecasterDefinitionValues, + intervalForForecaster, + shingleSize + ); + + const suggestionParams = + suggestMode === 'all' + ? 'forecast_interval,history,horizon,window_delay' + : 'history,horizon,window_delay'; + + try { + const resp: any = await dispatch( + suggestForecaster(forecasterBody, suggestionParams, dataSourceId) as any + ); + + if (!resp || !resp.response) { + setErrorMsg('Empty response from suggestForecaster.'); + setStep('error'); + } else if (resp.response.exception) { + setErrorMsg(resp.response.exception); + setStep('error'); + } else { + // Success: store suggested values in local state + const intervalVal = resp.response?.interval?.period?.interval; + setSuggestedInterval(typeof intervalVal === 'number' ? intervalVal : undefined); + setSuggestedHorizon(resp.response.horizon); + setSuggestedHistory(resp.response.history); + const wDelay = resp.response.windowDelay?.period?.interval; + setSuggestedWindowDelay(wDelay); + + setStep('result'); + } + } catch (err: any) { + setErrorMsg(err?.message || 'Error occurred while suggesting parameters'); + setStep('error'); + } + }; + + // The key part: update form fields + const onUseSuggestedParams = () => { + if (suggestedInterval != null) { + formikProps.setFieldValue('interval', suggestedInterval); + } + if (suggestedHorizon != null) { + formikProps.setFieldValue('horizon', suggestedHorizon); + } + if (suggestedHistory != null) { + formikProps.setFieldValue('history', suggestedHistory); + } + if (suggestedWindowDelay != null) { + formikProps.setFieldValue('windowDelay', suggestedWindowDelay); + } + onClose(); + }; + + const onBackToConfig = () => { + setStep('config'); + }; + + const isConfig = step === 'config'; + const isLoading = step === 'loading'; + const isError = step === 'error'; + const isResult = step === 'result'; + + return ( + + + +

Suggest parameters

+
+
+ + + {isConfig && ( + <> + +

+ Based on your data source and advanced parameters, OpenSearch can + recommend core parameters for the model. +

+
+ + + + + + + {suggestMode === 'provided' && ( + + setProvidedInterval(Number(e.target.value))} + append="minutes" + /> + + )} + + + + + + setShingleSize(Number(e.target.value))} + min={1} + max={128} + /> + + + + )} + + {isLoading && ( + <> + + +

Calculating model parameters...

+

The calculation might take a few minutes. Do not close this window.

+
+ + )} + + {isError && ( + +

{errorMsg}

+
+ )} + + {isResult && ( + <> + +

Based on your inputs, the suggested parameters are:

+
+ {suggestedInterval !== undefined ? ( + • Interval: {suggestedInterval} minutes
+ ) : ( + • Interval: Unable to determine a suitable interval
+ )} + + {suggestedHorizon !== undefined ? ( + + • Horizon: {suggestedHorizon} intervals + {suggestedInterval && ( + <> ( + {Math.floor((suggestedHorizon * suggestedInterval) / 60)} hours{' '} + {(suggestedHorizon * suggestedInterval) % 60} minutes) + + )} +
+
+ ) : ( + • Horizon: Unable to determine a suitable horizon
+ )} + + {suggestedHistory !== undefined ? ( + + • History: {suggestedHistory} intervals + {suggestedInterval && ( + <> ( + {Math.floor((suggestedHistory * suggestedInterval) / 60)} hours{' '} + {(suggestedHistory * suggestedInterval) % 60} minutes) + + )} +
+
+ ) : ( + • History: Unable to determine a suitable history window
+ )} + + {suggestedWindowDelay !== undefined ? ( + • Window delay: {suggestedWindowDelay} minutes + ) : ( + • Window delay: Unable to determine a suitable delay + )} +
+
+ + )} +
+ + + Cancel + + {isConfig && ( + + Generate suggestions + + )} + + {(isLoading || isError || isResult) && ( + + Back + + )} + + {isResult && ( + + Use suggested parameters + + )} + +
+ ); +}; diff --git a/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts new file mode 100644 index 000000000..22e96ee97 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/SuggestParametersDialog/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { SuggestParametersDialog } from './SuggestParametersDialog'; \ No newline at end of file diff --git a/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx b/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx new file mode 100644 index 000000000..5c88206ff --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/ValidationCallout/ValidationCallout.tsx @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { + EuiCallOut, +} from '@elastic/eui'; + +interface ValidationCalloutProps { + isLoading?: boolean; + validationResponse?: string; +} + +export const ValidationCallout = ({ + isLoading, + validationResponse, +}: ValidationCalloutProps) => { + if (validationResponse && !isLoading) { + return ( + + {validationResponse} + + ); + } + + return null; +}; \ No newline at end of file diff --git a/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts b/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts new file mode 100644 index 000000000..2bc32a8f1 --- /dev/null +++ b/public/pages/ConfigureForecastModel/components/ValidationCallout/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { ValidationCallout } from './ValidationCallout'; \ No newline at end of file diff --git a/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx b/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx new file mode 100644 index 000000000..5f00cfe17 --- /dev/null +++ b/public/pages/ConfigureForecastModel/containers/ConfigureForecastModel.tsx @@ -0,0 +1,528 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + EuiPageBody, + EuiFlexItem, + EuiFlexGroup, + EuiPage, + EuiSmallButton, + EuiSmallButtonEmpty, + EuiSpacer, + EuiText, + EuiBottomBar, +} from '@elastic/eui'; +import { Formik, FormikHelpers } from 'formik'; +import { get, isEmpty } from 'lodash'; +import React, { Fragment, useState, useEffect, ReactElement, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { RouteComponentProps, useLocation } from 'react-router-dom'; +import { useFetchForecasterInfo } from '../../CreateForecasterSteps/hooks/useFetchForecasterInfo'; +import { + BREADCRUMBS, + MDS_BREADCRUMBS, + FORECASTER_INSUFFICIENT_DATA_MESSAGE, + FORECASTER_VALIDATION_ERROR_MESSAGE, + FORECASTER_EMPTY_DATA_IDENTIFIER, +} from '../../../utils/constants'; +import { useHideSideNavBar } from '../../main/hooks/useHideSideNavBar'; +import { suggestForecaster, updateForecaster, createForecaster, getForecasterCount, testForecaster, getForecaster } from '../../../redux/reducers/forecast'; +import { + modelConfigurationToFormik, + focusOnImputationOption, +} from '../utils/helpers'; +import { formikToModelConfiguration } from '../utils/helpers'; +import { AdvancedSettings } from '../components/AdvancedSettings'; +import { CoreStart, MountPoint } from '../../../../../../src/core/public'; +import { CoreServicesContext } from '../../../components/CoreServices/CoreServices'; +import { prettifyErrorMessage } from '../../../../server/utils/helpers'; +import { + ModelConfigurationFormikValues, +} from '../models/interfaces'; +import { isActiveState, MAX_FORECASTER } from '../../../../server/utils/constants'; +import { getErrorMessage } from '../../../utils/utils'; +import { + constructHrefWithDataSourceId, + getDataSourceFromURL, +} from '../../utils/helpers'; +import { + getDataSourceManagementPlugin, + getDataSourceEnabled, + getNotifications, + getSavedObjectsClient, +} from '../../../services'; +import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_management/public'; +import { SparseDataOptionValue } from '../utils/constants'; +import { Settings } from '../components/Settings'; +import { ForecasterDefinitionFormikValues } from '../../DefineForecaster/models/interfaces'; +import { StorageSettings } from '../components/StorageSettings'; +import { formikToForecaster, formikToForecasterDefinition } from '../../ReviewAndCreate/utils/helpers'; +import { SuggestParametersDialog } from '../components/SuggestParametersDialog/SuggestParametersDialog'; +import { ValidationCallout } from '../components/ValidationCallout/ValidationCallout'; +import { DEFAULT_SHINGLE_SIZE, DEFAULT_OUTPUT_AFTER } from '../../../utils/constants' + + +interface ConfigureForecastModelRouterProps { + forecasterId?: string; +} + +interface ConfigureForecastModelProps + extends RouteComponentProps { + setStep?(stepNumber: number): void; + initialValues?: ModelConfigurationFormikValues; + setInitialValues?(initialValues: ModelConfigurationFormikValues): void; + forecasterDefinitionValues: ForecasterDefinitionFormikValues; + setActionMenu: (menuMount: MountPoint | undefined) => void; +} + +export function ConfigureForecastModel(props: ConfigureForecastModelProps) { + const core = React.useContext(CoreServicesContext) as CoreStart; + const dispatch = useDispatch(); + const location = useLocation(); + const MDSQueryParams = getDataSourceFromURL(location); + const dataSourceEnabled = getDataSourceEnabled().enabled; + const dataSourceId = MDSQueryParams.dataSourceId; + + useHideSideNavBar(true, false); + const forecasterId = get(props, 'match.params.forecasterId', ''); + const { forecaster, hasError } = useFetchForecasterInfo(forecasterId, dataSourceId) + + // Jump to top of page on first load + useEffect(() => { + scroll(0, 0); + }, []); + + useEffect(() => { + if (dataSourceEnabled) { + core.chrome.setBreadcrumbs([ + MDS_BREADCRUMBS.FORECASTING(dataSourceId), + MDS_BREADCRUMBS.CREATE_FORECASTER, + ]); + } else { + core.chrome.setBreadcrumbs([ + BREADCRUMBS.FORECASTING, + BREADCRUMBS.CREATE_FORECASTER, + ]); + } + }, [forecaster]); + + // If there's an error fetching the forecaster while editing a forecaster + // (e.g., forecaster doesn't exist), redirect to the forecaster list page + useEffect(() => { + if (hasError) { + if (dataSourceEnabled) { + props.history.push( + constructHrefWithDataSourceId('/forecasters', dataSourceId, false) + ); + } else { + props.history.push('/forecasters'); + } + } + }, [hasError]); + + // This variable indicates if validate API declared forecaster definition settings as valid + const [forecasterDefinitionMessageResponse, setForecasterDefinitionMessageResponse] = + useState(''); + + // This hook only gets called once as the page is rendered, sending a request to + // Forecast suggest API with the forecast values. This will either return suggest + // parameters or suggest has failed and callouts displaying what the issue is + // will be displayed instead. + useEffect(() => { + if (!props.forecasterDefinitionValues) { + return; // Early return if no values + } + + // When suggest API fails to find an interval, it throws a runtime exception with message: + // {"error":{"root_cause":[{"type":"runtime_exception","reason":"Fail to suggest parameters for null Exceptions: [Empty data. Cannot find a good interval.]"}],"type":"runtime_exception","reason":"Fail to suggest parameters for null Exceptions: [Empty data. Cannot find a good interval.]"},"status":500} + dispatch( + suggestForecaster( + formikToForecasterDefinition(props.forecasterDefinitionValues), + 'forecast_interval', + dataSourceId + ) + ) + .then((resp: any) => { + if (isEmpty(Object.keys(resp.response)) || !resp.response.hasOwnProperty('interval')) { + setForecasterDefinitionMessageResponse(FORECASTER_INSUFFICIENT_DATA_MESSAGE); + } + }) + .catch((err: any) => { + const errorMessage = getErrorMessage(err, FORECASTER_VALIDATION_ERROR_MESSAGE); + + if (errorMessage.includes(FORECASTER_EMPTY_DATA_IDENTIFIER)) { + setForecasterDefinitionMessageResponse(FORECASTER_INSUFFICIENT_DATA_MESSAGE); + } else { + setForecasterDefinitionMessageResponse(errorMessage); + } + }); + }, []); + + + // Using useRef instead of useState since useState updates state asynchronously, + // but we need the most up-to-date value immediately in handleCreateForecaster + // to determine whether to run the test after creation + const shouldRunOnceRef = useRef(false); + + const validateImputationOption = ( + formikValues: ModelConfigurationFormikValues, + errors: any + ) => { + const imputationOption = get(formikValues, 'imputationOption', null); + + + // Validate imputationOption when method is CUSTOM_VALUE + // No need to lidate that the custom value's feature name matches formikValues.featureName as we only have one feature + if (imputationOption && imputationOption.imputationMethod === SparseDataOptionValue.CUSTOM_VALUE) { + // Validate that the number of custom values matches the number of enabled features + if ((imputationOption.custom_value || []).length !== 1) { + errors.custom_value = + `The number of custom values (${(imputationOption.custom_value || []).length}) should be 1.`; + } + } + }; + + const handleFormValidation = async ( + values: ModelConfigurationFormikValues, + formikHelpers: FormikHelpers, + ) => { + formikHelpers.setSubmitting(true); + formikHelpers.setFieldTouched('shingleSize'); + formikHelpers.setFieldTouched('imputationOption'); + formikHelpers.setFieldTouched('interval'); + formikHelpers.setFieldTouched('windowDelay'); + formikHelpers.setFieldTouched('suggestedSeasonality'); + formikHelpers.setFieldTouched('recencyEmphasis'); + formikHelpers.setFieldTouched('resultIndex'); + formikHelpers.setFieldTouched('resultIndexMinAge'); + formikHelpers.setFieldTouched('resultIndexMinSize'); + formikHelpers.setFieldTouched('resultIndexTtl'); + formikHelpers.setFieldTouched('flattenCustomResultIndex'); + + formikHelpers.validateForm().then((errors) => { + // Call the extracted validation method + validateImputationOption(values, errors); + + if (isEmpty(errors)) { + + handleCreateForecaster(values, formikHelpers); + } else { + const customValueError = get(errors, 'custom_value') + if (customValueError) { + core.notifications.toasts.addDanger( + customValueError + ); + focusOnImputationOption(); + return; + } + + core.notifications.toasts.addDanger( + 'One or more input fields is invalid' + ); + } + }); + + formikHelpers.setSubmitting(false); + }; + + const handleCreateForecaster = async (values: ModelConfigurationFormikValues, + formikHelpers: FormikHelpers, + ) => { + try { + const forecasterFormikValues = { + ...values, + ...props.forecasterDefinitionValues, + } + formikHelpers.setSubmitting(true); + const forecasterToCreate = formikToForecaster(forecasterFormikValues); + + // interval is in minutes + const forecastInterval = values.interval || 0; + const history = values.history || 0; + let requiredConsecutiveIntervals; + if (forecastInterval > 0) { + const shingleSize = values.shingleSize ? values.shingleSize : DEFAULT_SHINGLE_SIZE; + requiredConsecutiveIntervals = history > 0 ? history : (DEFAULT_OUTPUT_AFTER + shingleSize); + } + + // Decide subtitle text + const subTitle = shouldRunOnceRef.current + ? `The test is initializing with historical data. This may take approximately 1–2 minutes if your data covers each of the last ${requiredConsecutiveIntervals} consecutive intervals.` + : "Start forecasting or run the test first."; + + dispatch(createForecaster(forecasterToCreate, dataSourceId)) + .then((response: any) => { + const forecasterId = response.response.id; + // if not running test, show success message and navigate + if (!shouldRunOnceRef.current) { + + // Call getForecaster to refresh forecaster state after creation + // This ensures useFetchForecasterInfo gets correct forecaster state + // Otherwise, ForecasterDetail's useEffect won't work as it depends on forecaster.curState + dispatch(getForecaster(forecasterId, dataSourceId)); + core.notifications.toasts.addSuccess({ + title: `${forecasterToCreate.name} forecast has been created`, + text: subTitle, + }); + props.history.push( + constructHrefWithDataSourceId( + `/forecasters/${forecasterId}/details`, + dataSourceId, + false + ) + ); + } + // Optionally run test + else { + dispatch( + //@ts-ignore + testForecaster( + forecasterId, + dataSourceId + ) + ) + .then((response: any) => { + // Call getForecaster to refresh forecaster state after creation + // This ensures useFetchForecasterInfo gets correct forecaster state + // Otherwise, ForecasterDetail's useEffect won't work as it depends on forecaster.curState + dispatch(getForecaster(forecasterId, dataSourceId)); + shouldRunOnceRef.current = false; // Reset after success + core.notifications.toasts.addSuccess({ + title: `${forecasterToCreate.name} forecast has been created`, + text: subTitle, + }); + props.history.push( + constructHrefWithDataSourceId( + `/forecasters/${forecasterId}/details`, + dataSourceId, + false + ) + ); + }) + .catch((err: any) => { + shouldRunOnceRef.current = false; // Reset after failure + core.notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem running the test.' + ) + ) + ); + }); + } + }) + .catch((err: any) => { + dispatch(getForecasterCount(dataSourceId)).then((response: any) => { + const totalForecasters = get(response, 'response.count', 0); + if (totalForecasters === MAX_FORECASTER) { + core.notifications.toasts.addDanger( + 'Cannot create forecaster - limit of ' + + MAX_FORECASTER + + ' forecasters reached' + ); + } else { + core.notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem creating the forecaster' + ) + ) + ); + } + }); + }); + } catch (e) { + console.error('Failed to create forecaster:', e); + core.notifications.toasts.addDanger( + 'There was a problem creating the forecaster' + ); + } finally { + formikHelpers.setSubmitting(false); + } + }; + + let renderDataSourceComponent: ReactElement | null = null; + if (dataSourceEnabled) { + const DataSourceMenu = + getDataSourceManagementPlugin()?.ui.getDataSourceMenu(); + renderDataSourceComponent = ( + + ); + } + + const [showSuggestDialog, setShowSuggestDialog] = useState(false); + + return ( + + {(formikProps) => ( + + {dataSourceEnabled && renderDataSourceComponent} + + +
+ +

+ { + 'Create forecaster'}{' '} +

+
+ + +

Add model parameters

+
+ + + +

+ Core parameters +
+ Define how often the forecast will generate the next value based on historical data and how far to + forecast into the future. +

+
+ + + setShowSuggestDialog(true)} + > + Suggest parameters + + + {/* Conditionally render the SuggestParametersDialog */} + {showSuggestDialog && ( + setShowSuggestDialog(false)} + dataSourceId={dataSourceId} + forecasterDefinitionValues={props.forecasterDefinitionValues!} + formikProps={formikProps} + /> + )} + + + + + + + +
+
+
+ + + + + { + props.history.push( + constructHrefWithDataSourceId( + '/forecasters', + dataSourceId, + false + ) + ); + + }} + > + Cancel + + + { + + { + //@ts-ignore + props.setStep(1); + }} + > + Previous + + + } + + { + shouldRunOnceRef.current = false; + formikProps.handleSubmit(); + }} + > + Create + + + + { + shouldRunOnceRef.current = true; + formikProps.handleSubmit(); + }} + > + Create and test + + + + +
+ )} +
+ ); +} diff --git a/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx b/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx new file mode 100644 index 000000000..4534408e3 --- /dev/null +++ b/public/pages/ConfigureForecastModel/containers/__tests__/ConfigureForecastModel.test.tsx @@ -0,0 +1,284 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { + Router, + RouteComponentProps, + Route, + Switch, +} from 'react-router-dom'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { ConfigureForecastModel } from '../ConfigureForecastModel'; +import configureStore from '../../../../redux/configureStore'; +import { httpClientMock, coreServicesMock } from '../../../../../test/mocks'; +import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices'; +import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../utils/constants'; +import { createMemoryHistory } from 'history'; + +// Mock services +jest.mock('../../../../services', () => ({ + ...jest.requireActual('../../../../services'), + getDataSourceEnabled: () => ({ enabled: false }), + getDataSourceManagementPlugin: () => undefined, + getSavedObjectsClient: () => ({}), + getNotifications: () => ({}), +})); + +// Mock the hooks +jest.mock('../../../CreateForecasterSteps/hooks/useFetchForecasterInfo', () => ({ + useFetchForecasterInfo: () => ({ + forecaster: undefined, + hasError: false, + }), +})); + +jest.mock('../../../main/hooks/useHideSideNavBar', () => ({ + useHideSideNavBar: jest.fn(), +})); + +// Mock helper functions to avoid data structure issues +jest.mock('../../../ReviewAndCreate/utils/helpers', () => ({ + ...jest.requireActual('../../../ReviewAndCreate/utils/helpers'), + formikToForecasterDefinition: jest.fn(() => ({ + name: 'Test Forecaster', + description: 'Test Description', + indices: ['test-index'], + timeField: 'timestamp', + })), +})); + +// Mock redux actions +jest.mock('../../../../redux/reducers/forecast', () => ({ + suggestForecaster: jest.fn(() => Promise.resolve({ response: { interval: 10 } })), + createForecaster: jest.fn(() => Promise.resolve({ response: { id: 'test-forecaster-id' } })), + testForecaster: jest.fn(() => Promise.resolve({ response: {} })), + getForecaster: jest.fn(() => Promise.resolve({ response: {} })), + getForecasterCount: jest.fn(() => Promise.resolve({ response: { count: 0 } })), + updateForecaster: jest.fn(() => Promise.resolve({ response: {} })), +})); + +// Mock the constructHrefWithDataSourceId function +jest.mock('../../../utils/helpers', () => ({ + ...jest.requireActual('../../../utils/helpers'), + constructHrefWithDataSourceId: jest.fn((href) => href), +})); + +const mockForecasterDefinitionValues = { + name: 'Test Forecaster', + description: 'Test Description', + index: [{ label: 'test-index' }], + timeField: 'timestamp', + filters: [], + filterQuery: '', + featureList: [{ + featureId: 'test-feature-id', + featureName: 'test-feature', + featureType: 'simple_aggs', + featureEnabled: true, + aggregationMethod: 'sum', + aggregationField: 'value', + aggregationQuery: '' + }], + categoryFieldEnabled: false, + categoryField: [] +}; + +const mockSetStep = jest.fn(); +const mockSetActionMenu = jest.fn(); + +const renderWithRouter = (props = {}) => { + const history = createMemoryHistory(); + const defaultProps = { + setStep: mockSetStep, + setActionMenu: mockSetActionMenu, + forecasterDefinitionValues: mockForecasterDefinitionValues, + initialValues: INITIAL_MODEL_CONFIGURATION_VALUES, + ...props, + }; + + return render( + + + + ( + + + + )} + /> + + + + ); +}; + +describe(' spec', () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); + console.warn = jest.fn(); + }); + + describe('Component rendering', () => { + test('renders the component with correct title', () => { + renderWithRouter(); + + expect(screen.getByText('Create forecaster')).toBeInTheDocument(); + expect(screen.getByText('Add model parameters')).toBeInTheDocument(); + expect(screen.getByText(/Core parameters/)).toBeInTheDocument(); + }); + + test('renders suggest parameters button', () => { + renderWithRouter(); + + expect(screen.getByTestId('suggestParametersButton')).toBeInTheDocument(); + expect(screen.getByText('Suggest parameters')).toBeInTheDocument(); + }); + + test('renders all main components', () => { + renderWithRouter(); + + // Check for main sections + expect(screen.getByText('Advanced model parameters')).toBeInTheDocument(); + expect(screen.getByText('Storage')).toBeInTheDocument(); + }); + + test('renders action buttons', () => { + renderWithRouter(); + + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + expect(screen.getByText('Create')).toBeInTheDocument(); + expect(screen.getByText('Create and test')).toBeInTheDocument(); + }); + }); + + describe('Suggest parameters functionality', () => { + test('opens suggest parameters dialog when button is clicked', async () => { + renderWithRouter(); + + const suggestButton = screen.getByTestId('suggestParametersButton'); + fireEvent.click(suggestButton); + + // Dialog should open - this might be in a portal, so use a more flexible query + await waitFor(() => { + // Look for dialog content that might be rendered + expect(screen.getByText('Suggest parameters', { selector: 'h2' })).toBeInTheDocument(); + }, { timeout: 3000 }); + }); + }); + + describe('Navigation', () => { + test('navigates to previous step when Previous button is clicked', () => { + renderWithRouter(); + + const previousButton = screen.getByTestId('configureModelPreviousButton'); + fireEvent.click(previousButton); + + expect(mockSetStep).toHaveBeenCalledWith(1); + }); + + test('navigates to forecasters list when Cancel button is clicked', () => { + const mockHistory = createMemoryHistory(); + const mockHistoryPush = jest.spyOn(mockHistory, 'push'); + + render( + + + + ( + + + + )} + /> + + + + ); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockHistoryPush).toHaveBeenCalledWith('/forecasters'); + }); + }); + + describe('Breadcrumb navigation', () => { + test('sets correct breadcrumbs on mount', () => { + renderWithRouter(); + + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + expect.objectContaining({ text: 'Forecasting' }), + expect.objectContaining({ text: 'Create forecaster' }), + ]); + }); + }); + + describe('Component integration', () => { + test('renders Settings component', () => { + renderWithRouter(); + + // Settings component should render its content + expect(screen.getByText('Forecasting interval')).toBeInTheDocument(); + }); + + test('renders AdvancedSettings component', () => { + renderWithRouter(); + + expect(screen.getByText('Advanced model parameters')).toBeInTheDocument(); + }); + + test('renders StorageSettings component', () => { + renderWithRouter(); + + expect(screen.getByText('Storage')).toBeInTheDocument(); + }); + }); + + describe('Form state management', () => { + test('initializes with provided initial values', () => { + const customInitialValues = { + ...INITIAL_MODEL_CONFIGURATION_VALUES, + interval: 15, + shingleSize: 16, + }; + + renderWithRouter({ + initialValues: customInitialValues, + }); + + // Component should render without errors with custom initial values + expect(screen.getByText('Create forecaster')).toBeInTheDocument(); + }); + + test('form is properly configured', () => { + renderWithRouter(); + + // Formik should be configured with enableReinitialize=true + // This ensures form updates when initialValues change + expect(screen.getByText('Create forecaster')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/pages/ConfigureForecastModel/index.scss b/public/pages/ConfigureForecastModel/index.scss new file mode 100644 index 000000000..ed21caa4f --- /dev/null +++ b/public/pages/ConfigureForecastModel/index.scss @@ -0,0 +1,5 @@ +.unit-badge { + height:32px; + line-height: 30px; + padding: 0 8px; +} \ No newline at end of file diff --git a/public/pages/ConfigureForecastModel/models/interfaces.ts b/public/pages/ConfigureForecastModel/models/interfaces.ts new file mode 100644 index 000000000..afdea0f96 --- /dev/null +++ b/public/pages/ConfigureForecastModel/models/interfaces.ts @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +// Formik values used when creating the model configuration +export interface ModelConfigurationFormikValues { + shingleSize: number; + imputationOption?: ImputationFormikValues; + interval: number | undefined; + windowDelay: number | undefined; + suggestedSeasonality?: number; + recencyEmphasis?: number; + resultIndexMinAge?: number | string; + resultIndexMinSize?: number | string; + resultIndexTtl?:number | string; + flattenCustomResultIndex?: boolean; + resultIndex?: string; + horizon?: number; + history?: number; +} + +export interface ImputationFormikValues { + imputationMethod?: string; + custom_value?: CustomValueFormikValues[]; +} + +export interface CustomValueFormikValues { + // forecasting has only one feature and we don't need to specify the feature name + featureName?: string; + data: number; +} diff --git a/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx b/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx new file mode 100644 index 000000000..b626eab92 --- /dev/null +++ b/public/pages/ConfigureForecastModel/utils/__tests__/helpers.test.tsx @@ -0,0 +1,218 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + Forecaster, + UNITS, +} from '../../../../models/interfaces'; +import { ImputationMethod, ImputationOption } from '../../../../models/types'; +import { + ModelConfigurationFormikValues +} from '../../models/interfaces'; +import { + clearModelConfiguration, + createImputationFormikValues, + focusOnCategoryField, + focusOnImputationOption, + formikToImputationMethod, + formikToImputationOption, + formikToModelConfiguration, + getCustomValueStrArray, + getShingleSizeFromObject, + imputationMethodToFormik, + modelConfigurationToFormik, +} from '../helpers'; +import { SparseDataOptionValue } from '../constants'; +import { DEFAULT_SHINGLE_SIZE } from '../../../../utils/constants'; + +// Helper to generate a forecaster object for tests +const getRandomForecaster = ( + imputation?: ImputationMethod, + hasDefaultFill: boolean = true +): Forecaster => { + let imputationOption: ImputationOption | undefined; + if (imputation) { + imputationOption = { method: imputation }; + if (imputation === ImputationMethod.FIXED_VALUES && hasDefaultFill) { + imputationOption.defaultFill = [ + { featureName: 'test-feature', data: 42 }, + ]; + } + } + + return { + id: 'test-forecaster-id', + name: 'test-forecaster', + description: 'Test forecaster description', + indices: ['test-index'], + uiMetadata: { features: { 'test-id': { featureType: 'simple_aggs', aggregationBy: 'sum', aggregationOf: 'value' }} }, + featureAttributes: [{ featureId: 'test-id', featureName: 'test-feature', featureEnabled: true, importance: 1, aggregationQuery: {} }], + forecastInterval: { period: { interval: 10, unit: UNITS.MINUTES } }, + windowDelay: { period: { interval: 5, unit: UNITS.MINUTES } }, + shingleSize: 8, + imputationOption: imputationOption, + }; +}; + +describe('ConfigureForecastModel helpers', () => { + describe('focusOnCategoryField and focusOnImputationOption', () => { + const mockElement = { focus: jest.fn() }; + const originalGetElementById = document.getElementById; + + beforeEach(() => { + document.getElementById = jest.fn().mockReturnValue(mockElement); + mockElement.focus.mockClear(); + }); + + afterAll(() => { + document.getElementById = originalGetElementById; + }); + + test('focusOnCategoryField should focus on the correct element', () => { + focusOnCategoryField(); + expect(document.getElementById).toHaveBeenCalledWith( + 'categoryFieldCheckbox' + ); + expect(mockElement.focus).toHaveBeenCalledTimes(1); + }); + + test('focusOnImputationOption should focus on the correct element', () => { + focusOnImputationOption(); + expect(document.getElementById).toHaveBeenCalledWith('imputationOption'); + expect(mockElement.focus).toHaveBeenCalledTimes(1); + }); + }); + + describe('getShingleSizeFromObject', () => { + test('should return shingle size if it exists', () => { + expect(getShingleSizeFromObject({ shingleSize: 10 })).toBe(10); + }); + test('should return default shingle size if it does not exist', () => { + expect(getShingleSizeFromObject({ otherProp: 'value' })).toBe( + DEFAULT_SHINGLE_SIZE + ); + }); + test('should return default shingle size for empty object', () => { + expect(getShingleSizeFromObject({})).toBe(DEFAULT_SHINGLE_SIZE); + }); + }); + + describe('clearModelConfiguration', () => { + test('should clear relevant fields from the forecaster', () => { + const forecaster = getRandomForecaster(); + forecaster.categoryField = ['some-field']; + const cleared = clearModelConfiguration(forecaster); + expect(cleared.featureAttributes).toEqual([]); + expect(cleared.uiMetadata.features).toEqual({}); + expect(cleared.categoryField).toBeUndefined(); + expect(cleared.shingleSize).toBe(DEFAULT_SHINGLE_SIZE); + }); + }); + + describe('imputationMethodToFormik and createImputationFormikValues', () => { + test('should handle FIXED_VALUES with default fill', () => { + const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES); + const formikValues = createImputationFormikValues(forecaster); + expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.CUSTOM_VALUE); + expect(formikValues.custom_value).toEqual([{ featureName: 'test-feature', data: 42 }]); + }); + test('should handle PREVIOUS_VALUE', () => { + const forecaster = getRandomForecaster(ImputationMethod.PREVIOUS); + const formikValues = createImputationFormikValues(forecaster); + expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.PREVIOUS_VALUE); + expect(formikValues.custom_value).toBeUndefined(); + }); + test('should handle ZERO', () => { + const forecaster = getRandomForecaster(ImputationMethod.ZERO); + const formikValues = createImputationFormikValues(forecaster); + expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.SET_TO_ZERO); + expect(formikValues.custom_value).toBeUndefined(); + }); + test('should handle undefined imputation method', () => { + const forecaster = getRandomForecaster(undefined); + const formikValues = createImputationFormikValues(forecaster); + expect(formikValues.imputationMethod).toBe(SparseDataOptionValue.IGNORE); + expect(formikValues.custom_value).toBeUndefined(); + }); + }); + + describe('modelConfigurationToFormik', () => { + test('should correctly convert a full forecaster to formik values', () => { + const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES); + const formikValues = modelConfigurationToFormik(forecaster); + expect(formikValues.shingleSize).toBe(8); + expect(formikValues.interval).toBe(10); + expect(formikValues.windowDelay).toBe(5); + expect(formikValues.imputationOption?.imputationMethod).toBe(SparseDataOptionValue.CUSTOM_VALUE); + }); + test('should handle empty forecaster object', () => { + const formikValues = modelConfigurationToFormik({} as Forecaster); + expect(formikValues.shingleSize).toBe(DEFAULT_SHINGLE_SIZE); + expect(formikValues.interval).toBe(undefined); + }); + }); + + describe('formikToImputationMethod and formikToImputationOption', () => { + test('should correctly convert formik values to ImputationOption for CUSTOM_VALUE', () => { + const formikValues = { imputationMethod: SparseDataOptionValue.CUSTOM_VALUE, custom_value: [{ featureName: 'test-feature', data: 123 }]}; + const option = formikToImputationOption('test-feature', formikValues); + expect(option?.method).toBe(ImputationMethod.FIXED_VALUES); + expect(option?.defaultFill).toEqual([{ featureName: 'test-feature', data: 123 }]); + }); + test('should handle PREVIOUS_VALUE', () => { + const formikValues = { imputationMethod: SparseDataOptionValue.PREVIOUS_VALUE }; + const option = formikToImputationOption('test-feature', formikValues); + expect(option?.method).toBe(ImputationMethod.PREVIOUS); + expect(option?.defaultFill).toBeUndefined(); + }); + test('should return undefined if featureName is missing', () => { + const formikValues = { imputationMethod: SparseDataOptionValue.CUSTOM_VALUE }; + const option = formikToImputationOption(undefined, formikValues); + expect(option).toBeUndefined(); + }); + test('should return undefined for IGNORE', () => { + const formikValues = { imputationMethod: SparseDataOptionValue.IGNORE }; + const option = formikToImputationOption('test-feature', formikValues); + expect(option).toBeUndefined(); + }); + }); + + describe('formikToModelConfiguration', () => { + test('should correctly convert formik values to a forecaster model', () => { + const formikValues: ModelConfigurationFormikValues = { + name: 'test', + description: '', + shingleSize: 4, + interval: 15, + windowDelay: 2, + imputationOption: { imputationMethod: SparseDataOptionValue.SET_TO_ZERO }, + }; + const forecaster = formikToModelConfiguration(formikValues, getRandomForecaster()); + expect(forecaster.shingleSize).toBe(4); + expect(forecaster.forecastInterval.period.interval).toBe(15); + expect(forecaster.windowDelay.period.interval).toBe(2); + expect(forecaster.imputationOption?.method).toBe(ImputationMethod.ZERO); + }); + }); + + describe('getCustomValueStrArray', () => { + test('should return formatted string array for custom values', () => { + const forecaster = getRandomForecaster(ImputationMethod.FIXED_VALUES); + const result = getCustomValueStrArray(SparseDataOptionValue.CUSTOM_VALUE, forecaster); + expect(result).toEqual(['test-feature: 42']); + }); + test('should return empty array for non-custom methods', () => { + const forecaster = getRandomForecaster(ImputationMethod.ZERO); + const result = getCustomValueStrArray(SparseDataOptionValue.SET_TO_ZERO, forecaster); + expect(result).toEqual([]); + }); + }); +}); diff --git a/public/pages/ConfigureForecastModel/utils/constants.tsx b/public/pages/ConfigureForecastModel/utils/constants.tsx new file mode 100644 index 000000000..3020b8376 --- /dev/null +++ b/public/pages/ConfigureForecastModel/utils/constants.tsx @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + ModelConfigurationFormikValues, +} from '../../ConfigureForecastModel/models/interfaces'; +import { DEFAULT_SHINGLE_SIZE } from '../../../utils/constants'; + +// FIXME: We intentionally leave some parameters undefined to encourage customers to click +// the "Suggest parameters" button rather than providing default values like "10 minutes" for interval +export const INITIAL_MODEL_CONFIGURATION_VALUES: ModelConfigurationFormikValues = + { + shingleSize: DEFAULT_SHINGLE_SIZE, + imputationOption: undefined, + interval: undefined, + windowDelay: undefined, + suggestedSeasonality: undefined, + recencyEmphasis: undefined, + resultIndex: undefined, + resultIndexMinAge: 7, + resultIndexMinSize: 51200, + resultIndexTtl: 60, + flattenCustomResultIndex: false, + }; + +// an enum for the sparse data handling options +export enum SparseDataOptionValue { + IGNORE = 'ignore', + PREVIOUS_VALUE = 'previous_value', + SET_TO_ZERO = 'set_to_zero', + CUSTOM_VALUE = 'custom_value', +} diff --git a/public/pages/ConfigureForecastModel/utils/helpers.ts b/public/pages/ConfigureForecastModel/utils/helpers.ts new file mode 100644 index 000000000..d26391c76 --- /dev/null +++ b/public/pages/ConfigureForecastModel/utils/helpers.ts @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { DATA_TYPES, DEFAULT_SHINGLE_SIZE } from '../../../utils/constants'; +import { + Forecaster, + UNITS, +} from '../../../models/interfaces'; +import { get, cloneDeep, isEmpty } from 'lodash'; +import { + ModelConfigurationFormikValues, + CustomValueFormikValues, + ImputationFormikValues, +} from '../../ConfigureForecastModel/models/interfaces'; +import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../ConfigureForecastModel/utils/constants'; +import { + ImputationMethod, + ImputationOption, +} from '../../../models/types'; +import { + SparseDataOptionValue +} from './constants' + +export const focusOnCategoryField = () => { + const component = document.getElementById('categoryFieldCheckbox'); + component?.focus(); +}; + +export const focusOnImputationOption = () => { + const component = document.getElementById('imputationOption'); + component?.focus(); +}; + +export const getShingleSizeFromObject = (obj: object) => { + return get(obj, 'shingleSize', DEFAULT_SHINGLE_SIZE); +}; + +export function clearModelConfiguration(forecast: Forecaster): Forecaster { + return { + ...forecast, + featureAttributes: [], + uiMetadata: { + ...forecast.uiMetadata, + features: {}, + }, + categoryField: undefined, + shingleSize: DEFAULT_SHINGLE_SIZE, + }; +} + +export function createImputationFormikValues( + forecaster: Forecaster +): ImputationFormikValues { + const imputationMethod = imputationMethodToFormik(forecaster); + let defaultFillArray: CustomValueFormikValues[] = []; + + if (SparseDataOptionValue.CUSTOM_VALUE === imputationMethod) { + const defaultFill = get(forecaster, 'imputationOption.defaultFill', null) as Array<{ featureName: string; data: number }> | null; + defaultFillArray = defaultFill + ? defaultFill.map(({ featureName, data }) => ({ + featureName, + data, + })) + : []; + } + + return { + imputationMethod: imputationMethod, + custom_value: SparseDataOptionValue.CUSTOM_VALUE === imputationMethod ? defaultFillArray : undefined, + }; +} + +export function modelConfigurationToFormik( + forecaster: Forecaster +): ModelConfigurationFormikValues { + const initialValues = cloneDeep(INITIAL_MODEL_CONFIGURATION_VALUES); + if (isEmpty(forecaster)) { + return initialValues; + } + + const imputationFormikValues = createImputationFormikValues(forecaster); + + return { + ...initialValues, + shingleSize: get(forecaster, 'shingleSize', DEFAULT_SHINGLE_SIZE), + imputationOption: imputationFormikValues, + interval: get(forecaster, 'forecastInterval.period.interval', 10), + windowDelay: get(forecaster, 'windowDelay.period.interval', 0), + suggestedSeasonality: get(forecaster, 'suggestedSeasonality', undefined), + recencyEmphasis: get(forecaster, 'recencyEmphasis', undefined), + resultIndexMinAge: get(forecaster, 'resultIndexMinAge', undefined), + resultIndexMinSize:get(forecaster, 'resultIndexMinSize', undefined), + resultIndexTtl: get(forecaster, 'resultIndexTtl', undefined), + flattenCustomResultIndex: get(forecaster, 'flattenCustomResultIndex', false), + resultIndex: forecaster.resultIndex, + horizon: get(forecaster, 'horizon', undefined), + history: get(forecaster, 'history', undefined), + }; +} + +export function formikToModelConfiguration( + values: ModelConfigurationFormikValues, + forecaster: Forecaster +): Forecaster { + let forecasterBody = { + ...forecaster, + shingleSize: values.shingleSize, + imputationOption: formikToImputationOption( + forecaster.featureAttributes?.[0]?.featureName, + values.imputationOption, + ), + forecastInterval: { + period: { interval: values.interval, unit: UNITS.MINUTES }, + }, + windowDelay: { + period: { interval: values.windowDelay, unit: UNITS.MINUTES }, + }, + resultIndexMinAge: values.resultIndexMinAge, + resultIndexMinSize: values.resultIndexMinSize, + resultIndexTtl: values.resultIndexTtl, + flattenCustomResultIndex: values.flattenCustomResultIndex, + resultIndex: values.resultIndex, + horizon: values.horizon, + history: values.history, + } as Forecaster; + + return forecasterBody; +} + +export function formikToImputationOption( + featureName: string | undefined, + imputationFormikValues?: ImputationFormikValues, +): ImputationOption | undefined { + // If no feature name is provided, return undefined + if (!featureName) return undefined; + + // Map the formik method to the imputation method; return undefined if method is not recognized. + const method = formikToImputationMethod(imputationFormikValues?.imputationMethod); + if (!method) return undefined; + + // Convert custom_value array to defaultFill if the method is FIXED_VALUES. + const defaultFill = method === ImputationMethod.FIXED_VALUES + ? imputationFormikValues?.custom_value?.map(({ data }) => ({ + featureName: featureName, + data, + })) + : undefined; + + // Construct and return the ImputationOption object. + return { method, defaultFill }; +} + +export function imputationMethodToFormik( + forecaster: Forecaster +): string { + var imputationMethod = get(forecaster, 'imputationOption.method', undefined) as ImputationMethod; + + switch (imputationMethod) { + case ImputationMethod.FIXED_VALUES: + return SparseDataOptionValue.CUSTOM_VALUE; + case ImputationMethod.PREVIOUS: + return SparseDataOptionValue.PREVIOUS_VALUE; + case ImputationMethod.ZERO: + return SparseDataOptionValue.SET_TO_ZERO; + default: + break; + } + + return SparseDataOptionValue.IGNORE; +} + +export function formikToImputationMethod( + formikValue: string | undefined +): ImputationMethod | undefined { + switch (formikValue) { + case SparseDataOptionValue.CUSTOM_VALUE: + return ImputationMethod.FIXED_VALUES; + case SparseDataOptionValue.PREVIOUS_VALUE: + return ImputationMethod.PREVIOUS; + case SparseDataOptionValue.SET_TO_ZERO: + return ImputationMethod.ZERO; + default: + return undefined; + } +} + +export const getCustomValueStrArray = (imputationMethodStr : string, forecaster: Forecaster): string[] => { + if (SparseDataOptionValue.CUSTOM_VALUE === imputationMethodStr) { + const defaultFill : Array<{ featureName: string; data: number }> = get(forecaster, 'imputationOption.defaultFill', []); + + return defaultFill + .map(({ featureName, data }) => `${featureName}: ${data}`); + } + return [] +} + +export const toNumberOrEmpty = (value: string): number | '' => { + return value === '' ? '' : Number(value); +}; diff --git a/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap b/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap index 4ad250105..15f9777a4 100644 --- a/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap +++ b/public/pages/ConfigureModel/containers/__tests__/__snapshots__/ConfigureModel.test.tsx.snap @@ -737,7 +737,6 @@ exports[` spec creating model configuration renders the compon
spec editing model configuration renders the compone
void; +} + +export const CreateForecasterSteps = (props: CreateForecasterStepsProps) => { + useHideSideNavBar(true, false); + + const [step1DefineDataStatus, setStep1DefineDataStatus] = + useState(undefined); + const [step2ConfigureModelStatus, setStep2ConfigureModelStatus] = + useState('disabled'); + + const [step1DefineDataFields, setStep1DefineDataFields] = + useState( + INITIAL_FORECASTER_DEFINITION_VALUES + ); + const [step2ConfigureModelFields, setStep2ConfigureModelFields] = + useState( + INITIAL_MODEL_CONFIGURATION_VALUES + ); + + const [curStep, setCurStep] = useState(1); + + // Hook to update the progress of the steps - undefined = blue, disabled = grey + useEffect(() => { + switch (curStep) { + case 1: + default: + setStep1DefineDataStatus(undefined); + setStep2ConfigureModelStatus('disabled'); + break; + case 2: + setStep1DefineDataStatus(undefined); + setStep2ConfigureModelStatus(undefined); + break; + } + }, [curStep]); + + const createSteps = [ + { + title: 'Define data source', + status: step1DefineDataStatus, + // EuiSteps requires a children prop, but we render the step content separately + // in the adjacent EuiFlexItem based on curStep + children: '', + }, + { + title: 'Add model parameters', + status: step2ConfigureModelStatus, + children: '', + }, + ]; + + return ( + + + + + + + {curStep === 1 ? ( + + ) : curStep === 2 ? ( + + ) : null} + + + + ); +}; diff --git a/public/pages/CreateForecasterSteps/hooks/useFetchForecasterInfo.ts b/public/pages/CreateForecasterSteps/hooks/useFetchForecasterInfo.ts new file mode 100644 index 000000000..d17af523b --- /dev/null +++ b/public/pages/CreateForecasterSteps/hooks/useFetchForecasterInfo.ts @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { get, isEmpty } from 'lodash'; +import { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Forecaster } from '../../../models/interfaces'; +import { AppState } from '../../../redux/reducers'; +import { GET_FORECASTER_LIST, GET_FORECASTER, getForecaster } from '../../../redux/reducers/forecast'; +import { getMappings } from '../../../redux/reducers/opensearch'; + +// A hook which gets required info in order to display a forecaster on OpenSearch Dashboards. +// 1. Get forecaster +// 2. Gets index mapping +export const useFetchForecasterInfo = ( + forecasterId: string, + dataSourceId: string +): { + forecaster: Forecaster; + hasError: boolean; + isLoadingForecaster: boolean; + errorMessage: string; +} => { + const dispatch = useDispatch(); + const forecaster = useSelector( + (state: AppState) => state.forecast.forecasters[forecasterId] + ); + const hasError = useSelector((state: AppState) => state.forecast.errorMessage); + const errorCall = useSelector((state: AppState) => state.forecast.errorCall); + const isForecasterRequesting = useSelector( + (state: AppState) => state.forecast.requesting + ); + const isIndicesRequesting = useSelector( + (state: AppState) => state.opensearch.requesting + ); + const selectedIndices = useMemo(() => get(forecaster, 'indices', []), [forecaster]); + + useEffect(() => { + const fetchForecaster = async () => { + if (!forecaster) { + await dispatch(getForecaster(forecasterId, dataSourceId)); + } + if (selectedIndices && selectedIndices.length > 0) { + await dispatch(getMappings(selectedIndices, dataSourceId)); + } + }; + if (forecasterId) { + fetchForecaster(); + } + }, [forecasterId, selectedIndices]); + return { + forecaster: forecaster || {}, + hasError: !isEmpty(hasError) && isEmpty(forecaster) && (errorCall === GET_FORECASTER || errorCall === GET_FORECASTER_LIST), + isLoadingForecaster: isForecasterRequesting || isIndicesRequesting, + errorMessage: hasError, + }; +}; diff --git a/public/pages/CreateForecasterSteps/index.scss b/public/pages/CreateForecasterSteps/index.scss new file mode 100644 index 000000000..5ff83299c --- /dev/null +++ b/public/pages/CreateForecasterSteps/index.scss @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +$font-stack: Helvetica; + +.optional { + color: #16191f; + font-size: 12px; + line-height: 16px; + line-height: 16px; + font-family: $font-stack; + text-align: left; + font-style: oblique; + font-weight: normal; +} + +@mixin mixin_sublabel($align) { + color: #69707d; + font-size: 12px; + line-height: 16px; + font-weight: normal; + font-family: $font-stack; + text-align: $align; +} + +.sublabel { + @include mixin_sublabel(left); +} + +.sublabel-center { + @include mixin_sublabel(center); +} + +.no-data-filter-rectangle { + text-align: center; + margin: auto; +} + +.no-data-filter-title { + color: #69707d; + font-family: $font-stack; + font-size: 14px; + font-weight: bold; + line-height: 17px; + text-align: center; +} + +.minutes { + height: 17px; + width: 62px; + color: #16191f; + font-family: $font-stack; + font-size: 14px; + letter-spacing: -0.07px; + line-height: 17px; +} diff --git a/public/pages/CreateForecasterSteps/index.ts b/public/pages/CreateForecasterSteps/index.ts new file mode 100644 index 000000000..08cc0239e --- /dev/null +++ b/public/pages/CreateForecasterSteps/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { CreateForecasterSteps as CreateForecasterSteps } from './containers/CreateForecasterSteps'; diff --git a/public/pages/CreateForecasterSteps/models/interfaces.ts b/public/pages/CreateForecasterSteps/models/interfaces.ts new file mode 100644 index 000000000..ad0db1dfb --- /dev/null +++ b/public/pages/CreateForecasterSteps/models/interfaces.ts @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { ForecasterDefinitionFormikValues } from '../../DefineForecaster/models/interfaces'; +import { ModelConfigurationFormikValues } from '../../ConfigureForecastModel/models/interfaces'; + +// Formik values used upon creation (includes all fields + those related to historical detector date range) +export interface CreateForecasterFormikValues + extends ForecasterDefinitionFormikValues, + ModelConfigurationFormikValues {} diff --git a/public/pages/CreateForecasterSteps/utils/constants.tsx b/public/pages/CreateForecasterSteps/utils/constants.tsx new file mode 100644 index 000000000..176752d37 --- /dev/null +++ b/public/pages/CreateForecasterSteps/utils/constants.tsx @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export type STEP_STATUS = + | 'incomplete' + | 'complete' + | 'warning' + | 'danger' + | 'disabled' + | undefined; diff --git a/public/pages/DefineDetector/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap b/public/pages/DefineDetector/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap index 592ae381b..d7f2c7ef5 100644 --- a/public/pages/DefineDetector/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap +++ b/public/pages/DefineDetector/components/Settings/__tests__/__snapshots__/Settings.test.tsx.snap @@ -57,7 +57,6 @@ exports[` spec renders the component 1`] = `
@@ -164,7 +163,6 @@ exports[` spec renders the component 1`] = `
Full creating detector definition renders the compon
@@ -856,7 +855,6 @@ exports[` Full creating detector definition renders the compon
FullEdit editing detector page renders the component
@@ -2020,7 +2017,6 @@ exports[` FullEdit editing detector page renders the component
empty creating detector definition renders the compo
@@ -3117,7 +3112,6 @@ exports[` empty creating detector definition renders the compo
empty editing detector definition renders the compon
@@ -4213,7 +4206,6 @@ exports[` empty editing detector definition renders the compon
{ + const { index, isEditable = true } = props; + const numberFields = getNumberFieldOptions(useSelector(getAllFields)); + const countableFields = getCountableFieldOptions(useSelector(getAllFields)); + return ( + + + {({ field, form }: FieldProps) => ( + + { + const currentValue = field.value; + const aggregationOf = get( + form, + `values.featureList.${index}.aggregationOf.0.type` + ); + if ( + currentValue === 'value_count' && + aggregationOf !== 'number' + ) { + form.setFieldValue( + `featureList.${index}.aggregationOf`, + undefined + ); + } + field.onChange(e); + }} + data-test-subj="aggregationType" + /> + + )} + + + + {({ field, form }: FieldProps) => ( + + { + const normalizedOptions = createdOption.trim(); + if (!normalizedOptions) return; + const customOption = [{ label: normalizedOptions }]; + form.setFieldValue( + `featureList.${index}.aggregationOf`, + customOption + ); + }} + //@ts-ignore + options={ + get(form, `values.featureList.${index}.aggregationBy`) === + 'value_count' + ? countableFields + : numberFields + } + {...field} + onClick={() => { + form.setFieldTouched( + `featureList.${index}.aggregationOf`, + true + ); + }} + onChange={(options: any) => { + form.setFieldValue( + `featureList.${index}.aggregationOf`, + options + ); + }} + /> + + )} + + + ); +}; diff --git a/public/pages/DefineForecaster/components/AggregationSelector/__tests__/AggregationSelector.test.tsx b/public/pages/DefineForecaster/components/AggregationSelector/__tests__/AggregationSelector.test.tsx new file mode 100644 index 000000000..cdb33af96 --- /dev/null +++ b/public/pages/DefineForecaster/components/AggregationSelector/__tests__/AggregationSelector.test.tsx @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { Formik } from 'formik'; +import { AggregationSelector } from '../AggregationSelector'; +import { + initialState, + mockedStore, +} from '../../../../../redux/utils/testUtils'; +import { FeaturesFormikValues } from '../../../models/interfaces'; +import { INITIAL_FEATURE_VALUES } from '../../../utils/constants'; + +const renderAggregationSelector = (initialValue: FeaturesFormikValues) => ({ + ...render( + + + {(formikProps) => ( +
+ +
+ )} +
+
+ ), +}); + +describe(' spec', () => { + describe('Empty results', () => { + test('renders component with aggregation types and defaults to empty', () => { + const { container } = renderAggregationSelector(INITIAL_FEATURE_VALUES); + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/public/pages/DefineForecaster/components/AggregationSelector/__tests__/__snapshots__/AggregationSelector.test.tsx.snap b/public/pages/DefineForecaster/components/AggregationSelector/__tests__/__snapshots__/AggregationSelector.test.tsx.snap new file mode 100644 index 000000000..84f310157 --- /dev/null +++ b/public/pages/DefineForecaster/components/AggregationSelector/__tests__/__snapshots__/AggregationSelector.test.tsx.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec Empty results renders component with aggregation types and defaults to empty 1`] = ` +
+
+
+ +
+
+
+
+ +
+ + + +
+
+
+
+ The aggregation method determines what constitutes an anomaly. For example, if you choose min(), the detector focuses on finding anomalies based on the minimum values of your feature. +
+
+
+
+
+ +
+
+ +
+
+`; diff --git a/public/pages/DefineForecaster/components/AggregationSelector/index.ts b/public/pages/DefineForecaster/components/AggregationSelector/index.ts new file mode 100644 index 000000000..acfda5896 --- /dev/null +++ b/public/pages/DefineForecaster/components/AggregationSelector/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { AggregationSelector } from './AggregationSelector'; diff --git a/public/pages/DefineForecaster/components/CustomAggregation/CustomAggregation.tsx b/public/pages/DefineForecaster/components/CustomAggregation/CustomAggregation.tsx new file mode 100644 index 000000000..9d7242d02 --- /dev/null +++ b/public/pages/DefineForecaster/components/CustomAggregation/CustomAggregation.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { EuiCompressedFormRow, EuiCodeEditor } from '@elastic/eui'; +import { Field, FieldProps } from 'formik'; +import { isInvalid, getError } from '../../../../utils/utils'; + +interface CustomAggregationProps { + index: number; + isEditable?: boolean; +} + +export const validateQuery = (value: string) => { + try { + JSON.parse(value); + } catch (err) { + console.log('Returning error', err); + return 'Invalid JSON'; + } +}; + +export const CustomAggregation = (props: CustomAggregationProps) => { + const { index, isEditable = true } = props; + return ( + + {({ field, form }: FieldProps) => ( + { + form.setFieldTouched( + `featureList.${props.index}.aggregationQuery`, + true + ); + }} + > + { + form.setFieldValue( + `featureList.${props.index}.aggregationQuery`, + query + ); + }} + onBlur={field.onBlur} + value={field.value} + /> + + )} + + ); +}; diff --git a/public/pages/DefineForecaster/components/CustomAggregation/__tests__/CustomAggregation.test.tsx b/public/pages/DefineForecaster/components/CustomAggregation/__tests__/CustomAggregation.test.tsx new file mode 100644 index 000000000..d0e48f743 --- /dev/null +++ b/public/pages/DefineForecaster/components/CustomAggregation/__tests__/CustomAggregation.test.tsx @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { CustomAggregation, validateQuery } from '../CustomAggregation'; +import { Provider } from 'react-redux'; +import { mockedStore } from '../../../../../redux/utils/testUtils'; +import { Formik } from 'formik'; +import { FeaturesFormikValues } from '../../../models/interfaces'; +import { INITIAL_FEATURE_VALUES } from '../../../utils/constants'; +import { CoreServicesContext } from '../../../../../components/CoreServices/CoreServices'; +import { coreServicesMock } from '../../../../../../test/mocks'; + +const renderWithFormik = (initialValue: FeaturesFormikValues) => ({ + ...render( + + + + {(formikProps) => ( +
+ +
+ )} +
+
+
+ ), +}); + +describe(' spec', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders the component', () => { + const { container } = renderWithFormik(INITIAL_FEATURE_VALUES); + expect(container.firstChild).toMatchSnapshot(); + }); + describe('validateQuery', () => { + test('should return undefined if valid query', () => { + expect(validateQuery('{}')).toBeUndefined(); + expect(validateQuery('{"a":{"b":{}}}')).toBeUndefined(); + }); + test('should return error message if invalid query', () => { + expect(validateQuery('hello')).toEqual('Invalid JSON'); + expect(validateQuery('{a : b: {}')).toEqual('Invalid JSON'); + }); + }); +}); diff --git a/public/pages/DefineForecaster/components/CustomAggregation/__tests__/__snapshots__/CustomAggregation.test.tsx.snap b/public/pages/DefineForecaster/components/CustomAggregation/__tests__/__snapshots__/CustomAggregation.test.tsx.snap new file mode 100644 index 000000000..e7b9cdb31 --- /dev/null +++ b/public/pages/DefineForecaster/components/CustomAggregation/__tests__/__snapshots__/CustomAggregation.test.tsx.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+ +
+
+
+ +
+