mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[ML] Daylight saving time calendar events (#193605)](https://github.com/elastic/kibana/pull/193605) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"James Gowdy","email":"jgowdy@elastic.co"},"sourceCommit":{"committedDate":"2024-10-07T13:35:25Z","message":"[ML] Daylight saving time calendar events (#193605)\n\nAdds new pages for creating and managing DST calendars.\r\nCloses https://github.com/elastic/kibana/issues/189469\r\n\r\nNew section added to Settings home page.\r\n\r\n\r\n\r\nNew page for listing DST calendars. The original calendar page does not\r\nshow DST calendars.\r\n\r\n\r\n\r\nNew page for creating DST calendars.\r\nThe ability to apply to all jobs and add a description has been removed.\r\nIt is not possible manually add events. Events are automatically\r\ngenerated for a selected time zone.\r\n<img width=\"1170\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/557b8d39-6c17-448a-aa30-a282d8a424a7\">\r\n\r\nIf the selected time zone does not observe daylight savings, an info\r\ncallout is displayed\r\n<img width=\"1178\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/627043bf-0368-4ab3-8ca7-1931f9622387\">\r\n\r\n\r\nA new DST calendar section is added to all AD job wizards.\r\n\r\n","sha":"2881b0423d099649243ee01887f27a1fb6dbffe7","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":[":ml","Feature:Anomaly Detection","v9.0.0","release_note:feature","v8.16.0","backport:version"],"title":"[ML] Daylight saving time calendar events","number":193605,"url":"https://github.com/elastic/kibana/pull/193605","mergeCommit":{"message":"[ML] Daylight saving time calendar events (#193605)\n\nAdds new pages for creating and managing DST calendars.\r\nCloses https://github.com/elastic/kibana/issues/189469\r\n\r\nNew section added to Settings home page.\r\n\r\n\r\n\r\nNew page for listing DST calendars. The original calendar page does not\r\nshow DST calendars.\r\n\r\n\r\n\r\nNew page for creating DST calendars.\r\nThe ability to apply to all jobs and add a description has been removed.\r\nIt is not possible manually add events. Events are automatically\r\ngenerated for a selected time zone.\r\n<img width=\"1170\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/557b8d39-6c17-448a-aa30-a282d8a424a7\">\r\n\r\nIf the selected time zone does not observe daylight savings, an info\r\ncallout is displayed\r\n<img width=\"1178\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/627043bf-0368-4ab3-8ca7-1931f9622387\">\r\n\r\n\r\nA new DST calendar section is added to all AD job wizards.\r\n\r\n","sha":"2881b0423d099649243ee01887f27a1fb6dbffe7"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/193605","number":193605,"mergeCommit":{"message":"[ML] Daylight saving time calendar events (#193605)\n\nAdds new pages for creating and managing DST calendars.\r\nCloses https://github.com/elastic/kibana/issues/189469\r\n\r\nNew section added to Settings home page.\r\n\r\n\r\n\r\nNew page for listing DST calendars. The original calendar page does not\r\nshow DST calendars.\r\n\r\n\r\n\r\nNew page for creating DST calendars.\r\nThe ability to apply to all jobs and add a description has been removed.\r\nIt is not possible manually add events. Events are automatically\r\ngenerated for a selected time zone.\r\n<img width=\"1170\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/557b8d39-6c17-448a-aa30-a282d8a424a7\">\r\n\r\nIf the selected time zone does not observe daylight savings, an info\r\ncallout is displayed\r\n<img width=\"1178\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/627043bf-0368-4ab3-8ca7-1931f9622387\">\r\n\r\n\r\nA new DST calendar section is added to all AD job wizards.\r\n\r\n","sha":"2881b0423d099649243ee01887f27a1fb6dbffe7"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: James Gowdy <jgowdy@elastic.co>
This commit is contained in:
parent
19631389a4
commit
8811474e3c
41 changed files with 1144 additions and 390 deletions
|
@ -62,8 +62,11 @@ export const ML_PAGES = {
|
|||
ANOMALY_DETECTION_MODULES_VIEW_OR_CREATE: 'modules/check_view_or_create',
|
||||
SETTINGS: 'settings',
|
||||
CALENDARS_MANAGE: 'settings/calendars_list',
|
||||
CALENDARS_DST_MANAGE: 'settings/calendars_dst_list',
|
||||
CALENDARS_NEW: 'settings/calendars_list/new_calendar',
|
||||
CALENDARS_DST_NEW: 'settings/calendars_dst_list/new_calendar',
|
||||
CALENDARS_EDIT: 'settings/calendars_list/edit_calendar',
|
||||
CALENDARS_DST_EDIT: 'settings/calendars_dst_list/edit_calendar',
|
||||
FILTER_LISTS_MANAGE: 'settings/filter_lists',
|
||||
FILTER_LISTS_NEW: 'settings/filter_lists/new_filter_list',
|
||||
FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list',
|
||||
|
|
|
@ -5,16 +5,24 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export type CalendarId = string;
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
export interface Calendar {
|
||||
calendar_id: CalendarId;
|
||||
export type MlCalendarId = string;
|
||||
|
||||
export interface MlCalendar {
|
||||
calendar_id: MlCalendarId;
|
||||
description: string;
|
||||
events: any[];
|
||||
job_ids: string[];
|
||||
total_job_count?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCalendar extends Calendar {
|
||||
calendarId: CalendarId;
|
||||
export interface UpdateCalendar extends MlCalendar {
|
||||
calendarId: MlCalendarId;
|
||||
}
|
||||
|
||||
export type MlCalendarEvent = estypes.MlCalendarEvent & {
|
||||
force_time_shift?: number;
|
||||
skip_result?: boolean;
|
||||
skip_model_update?: boolean;
|
||||
};
|
||||
|
|
|
@ -56,7 +56,9 @@ export type MlGenericUrlState = MLPageState<
|
|||
| typeof ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB
|
||||
| typeof ML_PAGES.OVERVIEW
|
||||
| typeof ML_PAGES.CALENDARS_MANAGE
|
||||
| typeof ML_PAGES.CALENDARS_DST_MANAGE
|
||||
| typeof ML_PAGES.CALENDARS_NEW
|
||||
| typeof ML_PAGES.CALENDARS_DST_NEW
|
||||
| typeof ML_PAGES.FILTER_LISTS_MANAGE
|
||||
| typeof ML_PAGES.FILTER_LISTS_NEW
|
||||
| typeof ML_PAGES.SETTINGS
|
||||
|
@ -247,6 +249,14 @@ export type CalendarEditUrlState = MLPageState<
|
|||
}
|
||||
>;
|
||||
|
||||
export type CalendarDstEditUrlState = MLPageState<
|
||||
typeof ML_PAGES.CALENDARS_DST_EDIT,
|
||||
{
|
||||
calendarId: string;
|
||||
globalState?: MlCommonGlobalState;
|
||||
}
|
||||
>;
|
||||
|
||||
export type FilterEditUrlState = MLPageState<
|
||||
typeof ML_PAGES.FILTER_LISTS_EDIT,
|
||||
{
|
||||
|
@ -277,6 +287,7 @@ export type MlLocatorState =
|
|||
| DataFrameAnalyticsUrlState
|
||||
| DataFrameAnalyticsExplorationUrlState
|
||||
| CalendarEditUrlState
|
||||
| CalendarDstEditUrlState
|
||||
| FilterEditUrlState
|
||||
| MlGenericUrlState
|
||||
| NotificationsUrlState
|
||||
|
|
|
@ -45,7 +45,7 @@ import type { CREATED_BY_LABEL } from '../../../../../../common/constants/new_jo
|
|||
import { JOB_TYPE, SHARED_RESULTS_INDEX_NAME } from '../../../../../../common/constants/new_job';
|
||||
import { collectAggs } from './util/general';
|
||||
import { filterRuntimeMappings } from './util/filter_runtime_mappings';
|
||||
import type { Calendar } from '../../../../../../common/types/calendars';
|
||||
import type { MlCalendar } from '../../../../../../common/types/calendars';
|
||||
import { mlCalendarService } from '../../../../services/calendar_service';
|
||||
import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils';
|
||||
import { getFirstKeyInObject } from '../../../../../../common/util/object_utils';
|
||||
|
@ -58,7 +58,7 @@ export class JobCreator {
|
|||
protected _indexPatternTitle: IndexPatternTitle = '';
|
||||
protected _indexPatternDisplayName: string = '';
|
||||
protected _job_config: Job;
|
||||
protected _calendars: Calendar[];
|
||||
protected _calendars: MlCalendar[];
|
||||
protected _datafeed_config: Datafeed;
|
||||
protected _detectors: Detector[];
|
||||
protected _influencers: string[];
|
||||
|
@ -271,11 +271,11 @@ export class JobCreator {
|
|||
this._job_config.groups = groups;
|
||||
}
|
||||
|
||||
public get calendars(): Calendar[] {
|
||||
public get calendars(): MlCalendar[] {
|
||||
return this._calendars;
|
||||
}
|
||||
|
||||
public set calendars(calendars: Calendar[]) {
|
||||
public set calendars(calendars: MlCalendar[]) {
|
||||
this._calendars = calendars;
|
||||
}
|
||||
|
||||
|
|
|
@ -43,12 +43,16 @@ export const AdditionalSection: FC<Props> = ({ additionalExpanded, setAdditional
|
|||
<CustomUrlsSelection />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiFlexGroup gutterSize="xl" style={{ marginLeft: '0px', marginRight: '0px' }}>
|
||||
<EuiFlexItem>
|
||||
<CalendarsSelection />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem />
|
||||
<EuiFlexItem>
|
||||
<CalendarsSelection isDst={true} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</section>
|
||||
</EuiAccordion>
|
||||
|
|
|
@ -20,15 +20,24 @@ import {
|
|||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
filterCalendarsForDst,
|
||||
separateCalendarsByType,
|
||||
} from '../../../../../../../../../settings/calendars/dst_utils';
|
||||
import { JobCreatorContext } from '../../../../../job_creator_context';
|
||||
import { Description } from './description';
|
||||
import { PLUGIN_ID } from '../../../../../../../../../../../common/constants/app';
|
||||
import type { Calendar } from '../../../../../../../../../../../common/types/calendars';
|
||||
import type { MlCalendar } from '../../../../../../../../../../../common/types/calendars';
|
||||
import { useMlApi, useMlKibana } from '../../../../../../../../../contexts/kibana';
|
||||
import { GLOBAL_CALENDAR } from '../../../../../../../../../../../common/constants/calendars';
|
||||
import { ML_PAGES } from '../../../../../../../../../../../common/constants/locator';
|
||||
import { DescriptionDst } from './description_dst';
|
||||
|
||||
export const CalendarsSelection: FC = () => {
|
||||
interface Props {
|
||||
isDst?: boolean;
|
||||
}
|
||||
|
||||
export const CalendarsSelection: FC<Props> = ({ isDst = false }) => {
|
||||
const {
|
||||
services: {
|
||||
application: { getUrlForApp },
|
||||
|
@ -37,19 +46,22 @@ export const CalendarsSelection: FC = () => {
|
|||
const mlApi = useMlApi();
|
||||
|
||||
const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext);
|
||||
const [selectedCalendars, setSelectedCalendars] = useState<Calendar[]>(jobCreator.calendars);
|
||||
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<Calendar>>>(
|
||||
[]
|
||||
const [selectedCalendars, setSelectedCalendars] = useState<MlCalendar[]>(
|
||||
filterCalendarsForDst(jobCreator.calendars, isDst)
|
||||
);
|
||||
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<Calendar>>>([]);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
Array<EuiComboBoxOptionOption<MlCalendar>>
|
||||
>([]);
|
||||
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<MlCalendar>>>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function loadCalendars() {
|
||||
setIsLoading(true);
|
||||
const calendars = (await mlApi.calendars()).filter(
|
||||
const { calendars, calendarsDst } = separateCalendarsByType(await mlApi.calendars());
|
||||
const filteredCalendars = (isDst ? calendarsDst : calendars).filter(
|
||||
(c) => c.job_ids.includes(GLOBAL_CALENDAR) === false
|
||||
);
|
||||
setOptions(calendars.map((c) => ({ label: c.calendar_id, value: c })));
|
||||
setOptions(filteredCalendars.map((c) => ({ label: c.calendar_id, value: c })));
|
||||
setSelectedOptions(selectedCalendars.map((c) => ({ label: c.calendar_id, value: c })));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@ -60,12 +72,14 @@ export const CalendarsSelection: FC = () => {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
jobCreator.calendars = selectedCalendars;
|
||||
const { calendars, calendarsDst } = separateCalendarsByType(jobCreator.calendars);
|
||||
const otherCalendars = isDst ? calendars : calendarsDst;
|
||||
jobCreator.calendars = [...selectedCalendars, ...otherCalendars];
|
||||
jobCreatorUpdate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCalendars.join()]);
|
||||
|
||||
const comboBoxProps: EuiComboBoxProps<Calendar> = {
|
||||
const comboBoxProps: EuiComboBoxProps<MlCalendar> = {
|
||||
async: true,
|
||||
options,
|
||||
selectedOptions,
|
||||
|
@ -77,11 +91,13 @@ export const CalendarsSelection: FC = () => {
|
|||
};
|
||||
|
||||
const manageCalendarsHref = getUrlForApp(PLUGIN_ID, {
|
||||
path: ML_PAGES.CALENDARS_MANAGE,
|
||||
path: isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE,
|
||||
});
|
||||
|
||||
const Desc = isDst ? DescriptionDst : Description;
|
||||
|
||||
return (
|
||||
<Description>
|
||||
<Desc>
|
||||
<EuiFlexGroup gutterSize="xs" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiComboBox {...comboBoxProps} data-test-subj="mlJobWizardComboBoxCalendars" />
|
||||
|
@ -119,6 +135,6 @@ export const CalendarsSelection: FC = () => {
|
|||
/>
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
</Description>
|
||||
</Desc>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiDescribedFormGroup, EuiFormRow, EuiLink } from '@elastic/eui';
|
||||
import { useMlKibana } from '../../../../../../../../../contexts/kibana';
|
||||
|
||||
export const DescriptionDst: FC<PropsWithChildren<unknown>> = memo(({ children }) => {
|
||||
const {
|
||||
services: { docLinks },
|
||||
} = useMlKibana();
|
||||
const docsUrl = docLinks.links.ml.calendars;
|
||||
const title = i18n.translate(
|
||||
'xpack.ml.newJob.wizard.jobDetailsStep.additionalSection.calendarsDstSelection.title',
|
||||
{
|
||||
defaultMessage: 'DST Calendars',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<EuiDescribedFormGroup
|
||||
title={<h3>{title}</h3>}
|
||||
description={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.jobDetailsStep.additionalSection.calendarsDstSelection.description"
|
||||
defaultMessage="A list of scheduled events you want to ignore, taking into account daylight saving time shifts. {learnMoreLink}"
|
||||
values={{
|
||||
learnMoreLink: (
|
||||
<EuiLink href={docsUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.newJob.wizard.jobDetailsStep.additionalSection.calendarsDstSelection.learnMoreLinkText"
|
||||
defaultMessage="Learn more"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFormRow>
|
||||
<>{children}</>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
});
|
|
@ -128,6 +128,14 @@ export const CALENDAR_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
|
|||
deepLinkId: 'ml:calendarSettings',
|
||||
});
|
||||
|
||||
export const CALENDAR_DST_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
|
||||
text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', {
|
||||
defaultMessage: 'Calendar DST management',
|
||||
}),
|
||||
href: '/settings/calendars_dst_list',
|
||||
deepLinkId: 'ml:calendarSettings',
|
||||
});
|
||||
|
||||
export const FILTER_LISTS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
|
||||
text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', {
|
||||
defaultMessage: 'Filter lists',
|
||||
|
@ -160,6 +168,7 @@ const breadcrumbs = {
|
|||
CHANGE_POINT_DETECTION,
|
||||
CREATE_JOB_BREADCRUMB,
|
||||
CALENDAR_MANAGEMENT_BREADCRUMB,
|
||||
CALENDAR_DST_MANAGEMENT_BREADCRUMB,
|
||||
FILTER_LISTS_BREADCRUMB,
|
||||
SUPPLIED_CONFIGURATIONS,
|
||||
};
|
||||
|
|
|
@ -31,7 +31,7 @@ export const calendarListRouteFactory = (
|
|||
title: i18n.translate('xpack.ml.settings.calendarList.docTitle', {
|
||||
defaultMessage: 'Calendars',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} />,
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} isDst={false} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath),
|
||||
|
@ -40,7 +40,24 @@ export const calendarListRouteFactory = (
|
|||
],
|
||||
});
|
||||
|
||||
const PageWrapper: FC<PageProps> = () => {
|
||||
export const calendarDstListRouteFactory = (
|
||||
navigateToPath: NavigateToPath,
|
||||
basePath: string
|
||||
): MlRoute => ({
|
||||
path: createPath(ML_PAGES.CALENDARS_DST_MANAGE),
|
||||
title: i18n.translate('xpack.ml.settings.calendarList.docTitle', {
|
||||
defaultMessage: 'Calendars',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} isDst={true} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB'),
|
||||
],
|
||||
});
|
||||
|
||||
const PageWrapper: FC<PageProps & { isDst: boolean }> = ({ isDst }) => {
|
||||
const { context } = useRouteResolver('full', ['canGetCalendars'], { getMlNodeCount });
|
||||
|
||||
useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });
|
||||
|
@ -52,7 +69,7 @@ const PageWrapper: FC<PageProps> = () => {
|
|||
|
||||
return (
|
||||
<PageLoader context={context}>
|
||||
<CalendarsList {...{ canCreateCalendar, canDeleteCalendar }} />
|
||||
<CalendarsList {...{ canCreateCalendar, canDeleteCalendar, isDst }} />
|
||||
</PageLoader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,7 +14,6 @@ import type { NavigateToPath } from '../../../contexts/kibana';
|
|||
import type { MlRoute, PageProps } from '../../router';
|
||||
import { createPath, PageLoader } from '../../router';
|
||||
import { useRouteResolver } from '../../use_resolver';
|
||||
import { usePermissionCheck } from '../../../capabilities/check_capabilities';
|
||||
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
|
||||
import { ML_PAGES } from '../../../../../common/constants/locator';
|
||||
import { getMlNodeCount } from '../../../ml_nodes_check';
|
||||
|
@ -26,6 +25,7 @@ enum MODE {
|
|||
|
||||
interface NewCalendarPageProps extends PageProps {
|
||||
mode: MODE;
|
||||
isDst: boolean;
|
||||
}
|
||||
|
||||
const NewCalendar = dynamic(async () => ({
|
||||
|
@ -40,7 +40,7 @@ export const newCalendarRouteFactory = (
|
|||
title: i18n.translate('xpack.ml.settings.createCalendar.docTitle', {
|
||||
defaultMessage: 'Create Calendar',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.NEW} />,
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.NEW} isDst={false} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath),
|
||||
|
@ -62,7 +62,7 @@ export const editCalendarRouteFactory = (
|
|||
title: i18n.translate('xpack.ml.settings.editCalendar.docTitle', {
|
||||
defaultMessage: 'Edit Calendar',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.EDIT} />,
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.EDIT} isDst={false} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath),
|
||||
|
@ -75,7 +75,50 @@ export const editCalendarRouteFactory = (
|
|||
],
|
||||
});
|
||||
|
||||
const PageWrapper: FC<NewCalendarPageProps> = ({ location, mode }) => {
|
||||
export const newCalendarDstRouteFactory = (
|
||||
navigateToPath: NavigateToPath,
|
||||
basePath: string
|
||||
): MlRoute => ({
|
||||
path: createPath(ML_PAGES.CALENDARS_DST_NEW),
|
||||
title: i18n.translate('xpack.ml.settings.createCalendarDst.docTitle', {
|
||||
defaultMessage: 'Create DST Calendar',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.NEW} isDst={true} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB', navigateToPath, basePath),
|
||||
{
|
||||
text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.createLabel', {
|
||||
defaultMessage: 'Create',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const editCalendarDstRouteFactory = (
|
||||
navigateToPath: NavigateToPath,
|
||||
basePath: string
|
||||
): MlRoute => ({
|
||||
path: createPath(ML_PAGES.CALENDARS_DST_EDIT, '/:calendarId'),
|
||||
title: i18n.translate('xpack.ml.settings.editCalendarDst.docTitle', {
|
||||
defaultMessage: 'Edit DST Calendar',
|
||||
}),
|
||||
render: (props, deps) => <PageWrapper {...props} deps={deps} mode={MODE.EDIT} isDst={true} />,
|
||||
breadcrumbs: [
|
||||
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath),
|
||||
getBreadcrumbWithUrlForApp('CALENDAR_DST_MANAGEMENT_BREADCRUMB', navigateToPath, basePath),
|
||||
{
|
||||
text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.editLabel', {
|
||||
defaultMessage: 'Edit',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const PageWrapper: FC<NewCalendarPageProps> = ({ location, mode, isDst }) => {
|
||||
let calendarId: string | undefined;
|
||||
if (mode === MODE.EDIT) {
|
||||
const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/);
|
||||
|
@ -86,14 +129,9 @@ const PageWrapper: FC<NewCalendarPageProps> = ({ location, mode }) => {
|
|||
|
||||
useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });
|
||||
|
||||
const [canCreateCalendar, canDeleteCalendar] = usePermissionCheck([
|
||||
'canCreateCalendar',
|
||||
'canDeleteCalendar',
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLoader context={context}>
|
||||
<NewCalendar {...{ calendarId, canCreateCalendar, canDeleteCalendar }} />
|
||||
<NewCalendar calendarId={calendarId} isDst={isDst} />
|
||||
</PageLoader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ import { toMountPoint } from '@kbn/react-kibana-mount';
|
|||
import { PLUGIN_ID } from '../../../common/constants/app';
|
||||
import { useMlKibana } from '../contexts/kibana';
|
||||
import type { MlRoute } from './router';
|
||||
import { ML_PAGES } from '../../locator';
|
||||
|
||||
/**
|
||||
* Provides an active route of the ML app.
|
||||
|
@ -30,8 +31,9 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => {
|
|||
/**
|
||||
* Temp fix for routes with params.
|
||||
*/
|
||||
const editCalendarMatch = useRouteMatch('/settings/calendars_list/edit_calendar/:calendarId');
|
||||
const editFilterMatch = useRouteMatch('/settings/filter_lists/edit_filter_list/:filterId');
|
||||
const editCalendarMatch = useRouteMatch(`/${ML_PAGES.CALENDARS_EDIT}/:calendarId`);
|
||||
const editCalendarDstMatch = useRouteMatch(`/${ML_PAGES.CALENDARS_DST_EDIT}/:calendarId`);
|
||||
const editFilterMatch = useRouteMatch(`/${ML_PAGES.FILTER_LISTS_EDIT}/:filterId`);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const routesMap = useMemo(() => keyBy(routesList, 'path'), []);
|
||||
|
@ -40,6 +42,9 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => {
|
|||
if (editCalendarMatch) {
|
||||
return routesMap[editCalendarMatch.path];
|
||||
}
|
||||
if (editCalendarDstMatch) {
|
||||
return routesMap[editCalendarDstMatch.path];
|
||||
}
|
||||
if (editFilterMatch) {
|
||||
return routesMap[editFilterMatch.path];
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Calendar, CalendarId } from '../../../common/types/calendars';
|
||||
import type { MlCalendar, MlCalendarId } from '../../../common/types/calendars';
|
||||
import type { JobId } from '../../../common/types/anomaly_detection_jobs';
|
||||
import type { MlApi } from './ml_api_service';
|
||||
|
||||
|
@ -16,7 +16,7 @@ class CalendarService {
|
|||
* @param calendar
|
||||
* @param jobId
|
||||
*/
|
||||
async assignNewJobId(mlApi: MlApi, calendar: Calendar, jobId: JobId) {
|
||||
async assignNewJobId(mlApi: MlApi, calendar: MlCalendar, jobId: JobId) {
|
||||
const { calendar_id: calendarId } = calendar;
|
||||
try {
|
||||
await mlApi.updateCalendar({
|
||||
|
@ -38,7 +38,7 @@ class CalendarService {
|
|||
* Fetches calendars by the list of ids.
|
||||
* @param calendarIds
|
||||
*/
|
||||
async fetchCalendarsByIds(mlApi: MlApi, calendarIds: CalendarId[]): Promise<Calendar[]> {
|
||||
async fetchCalendarsByIds(mlApi: MlApi, calendarIds: MlCalendarId[]): Promise<MlCalendar[]> {
|
||||
try {
|
||||
const calendars = await mlApi.calendars({ calendarIds });
|
||||
return Array.isArray(calendars) ? calendars : [calendars];
|
||||
|
|
|
@ -20,7 +20,7 @@ import type {
|
|||
} from '../../../../common/types/ml_server_info';
|
||||
import type { MlCapabilitiesResponse } from '../../../../common/types/capabilities';
|
||||
import type { RecognizeModuleResult } from '../../../../common/types/modules';
|
||||
import type { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars';
|
||||
import type { MlCalendar, MlCalendarId, UpdateCalendar } from '../../../../common/types/calendars';
|
||||
import type { BucketSpanEstimatorData } from '../../../../common/types/job_service';
|
||||
import type {
|
||||
Job,
|
||||
|
@ -555,9 +555,9 @@ export function mlApiProvider(httpService: HttpService) {
|
|||
/**
|
||||
* Gets a list of calendars
|
||||
* @param obj
|
||||
* @returns {Promise<Calendar[]>}
|
||||
* @returns {Promise<MlCalendar[]>}
|
||||
*/
|
||||
calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) {
|
||||
calendars(obj?: { calendarId?: MlCalendarId; calendarIds?: MlCalendarId[] }) {
|
||||
const { calendarId, calendarIds } = obj || {};
|
||||
let calendarIdsPathComponent = '';
|
||||
if (calendarId) {
|
||||
|
@ -565,14 +565,14 @@ export function mlApiProvider(httpService: HttpService) {
|
|||
} else if (calendarIds) {
|
||||
calendarIdsPathComponent = `/${calendarIds.join(',')}`;
|
||||
}
|
||||
return httpService.http<Calendar[]>({
|
||||
return httpService.http<MlCalendar[]>({
|
||||
path: `${ML_INTERNAL_BASE_PATH}/calendars${calendarIdsPathComponent}`,
|
||||
method: 'GET',
|
||||
version: '1',
|
||||
});
|
||||
},
|
||||
|
||||
addCalendar(obj: Calendar) {
|
||||
addCalendar(obj: MlCalendar) {
|
||||
const body = JSON.stringify(obj);
|
||||
return httpService.http<any>({
|
||||
path: `${ML_INTERNAL_BASE_PATH}/calendars`,
|
||||
|
|
|
@ -27,11 +27,13 @@ import { AnomalyDetectionSettingsContext } from './anomaly_detection_settings_co
|
|||
import { useToastNotificationService } from '../services/toast_notification_service';
|
||||
import { ML_PAGES } from '../../../common/constants/locator';
|
||||
import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url';
|
||||
import { separateCalendarsByType } from './calendars/dst_utils';
|
||||
|
||||
export const AnomalyDetectionSettings: FC = () => {
|
||||
const mlApi = useMlApi();
|
||||
|
||||
const [calendarsCount, setCalendarsCount] = useState(0);
|
||||
const [calendarsDstCount, setCalendarsDstCount] = useState(0);
|
||||
const [filterListsCount, setFilterListsCount] = useState(0);
|
||||
|
||||
const { canGetFilters, canCreateFilter, canGetCalendars, canCreateCalendar } = useContext(
|
||||
|
@ -40,7 +42,9 @@ export const AnomalyDetectionSettings: FC = () => {
|
|||
|
||||
const { displayErrorToast } = useToastNotificationService();
|
||||
const redirectToCalendarList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE);
|
||||
const redirectToCalendarDstList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_DST_MANAGE);
|
||||
const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW);
|
||||
const redirectToNewCalendarDstPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_DST_NEW);
|
||||
const redirectToFilterLists = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_MANAGE);
|
||||
const redirectToNewFilterListPage = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_NEW);
|
||||
|
||||
|
@ -53,8 +57,9 @@ export const AnomalyDetectionSettings: FC = () => {
|
|||
// Obtain the counts of calendars and filter lists.
|
||||
if (canGetCalendars === true) {
|
||||
try {
|
||||
const calendars = await mlApi.calendars();
|
||||
const { calendarsDst, calendars } = separateCalendarsByType(await mlApi.calendars());
|
||||
setCalendarsCount(calendars.length);
|
||||
setCalendarsDstCount(calendarsDst.length);
|
||||
} catch (e) {
|
||||
displayErrorToast(
|
||||
e,
|
||||
|
@ -94,7 +99,7 @@ export const AnomalyDetectionSettings: FC = () => {
|
|||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup gutterSize="xl">
|
||||
<EuiFlexItem grow={5}>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
|
@ -160,7 +165,79 @@ export const AnomalyDetectionSettings: FC = () => {
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={5}>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.anomalyDetection.calendarsDstTitle"
|
||||
defaultMessage="DST Calendars"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText size="s">
|
||||
<EuiTextColor color="subdued">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.anomalyDetection.calendarsDstText"
|
||||
defaultMessage="DST calendars contain a list of scheduled events for which you do not want to generate anomalies, taking into account daylight saving time shifts that may cause events to occur one hour earlier or later."
|
||||
/>
|
||||
</p>
|
||||
</EuiTextColor>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup alignItems="center">
|
||||
{canGetCalendars && (
|
||||
<EuiFlexItem grow={false} style={{ display: 'block' }}>
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.anomalyDetection.calendarsDstSummaryCount"
|
||||
defaultMessage="You have {calendarsCountBadge} {calendarsDstCount, plural, one {calendar} other {calendars}}"
|
||||
values={{
|
||||
calendarsCountBadge: <EuiBadge>{calendarsDstCount}</EuiBadge>,
|
||||
calendarsDstCount,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="mlCalendarsDstMngButton"
|
||||
flush="left"
|
||||
color="primary"
|
||||
onClick={redirectToCalendarDstList}
|
||||
isDisabled={canGetCalendars === false}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.anomalyDetection.manageCalendarsDstLink"
|
||||
defaultMessage="Manage"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="mlCalendarsDstCreateButton"
|
||||
flush="left"
|
||||
color="primary"
|
||||
onClick={redirectToNewCalendarDstPage}
|
||||
isDisabled={canCreateCalendar === false}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.anomalyDetection.createCalendarDstLink"
|
||||
defaultMessage="Create"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<EuiFlexGroup gutterSize="xl">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
|
@ -225,6 +302,7 @@ export const AnomalyDetectionSettings: FC = () => {
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem />
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getDSTChangeDates, createDstEvents } from './dst_utils';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
describe('getDSTChangeDates', () => {
|
||||
it('should return correct DST change dates for a given timezone and year', () => {
|
||||
const timezone = 'America/New_York';
|
||||
const year = 2023;
|
||||
const { start, end } = getDSTChangeDates(timezone, year);
|
||||
|
||||
expect(start).not.toBeNull();
|
||||
expect(end).not.toBeNull();
|
||||
|
||||
expect(moment(start).isDST()).toBe(true);
|
||||
expect(moment(end).isDST()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null for start and end if no DST changes are found', () => {
|
||||
const timezone = 'Asia/Tokyo';
|
||||
const year = 2023;
|
||||
const { start, end } = getDSTChangeDates(timezone, year);
|
||||
|
||||
expect(start).toBeNull();
|
||||
expect(end).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle edge cases around the start and end of the year', () => {
|
||||
const timezone = 'Europe/London';
|
||||
const year = 2023;
|
||||
const { start, end } = getDSTChangeDates(timezone, year);
|
||||
|
||||
expect(start).not.toBeNull();
|
||||
expect(end).not.toBeNull();
|
||||
|
||||
if (start && end) {
|
||||
expect(moment(start).isDST()).toBe(true);
|
||||
expect(moment(end).isDST()).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDstEvents', () => {
|
||||
it('should create DST events for a given timezone', () => {
|
||||
const timezone = 'America/New_York';
|
||||
const events = createDstEvents(timezone);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
events.forEach((event) => {
|
||||
expect(event).toHaveProperty('event_id');
|
||||
expect(event).toHaveProperty('description');
|
||||
expect(event).toHaveProperty('start_time');
|
||||
expect(event).toHaveProperty('end_time');
|
||||
expect(event).toHaveProperty('skip_result', false);
|
||||
expect(event).toHaveProperty('skip_model_update', false);
|
||||
expect(event).toHaveProperty('force_time_shift');
|
||||
expect(event.description).toMatch(/(Winter|Summer) \d{4}/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct number of DST events', () => {
|
||||
const timezone = 'Europe/London';
|
||||
const events = createDstEvents(timezone);
|
||||
|
||||
// Each year should have 2 events (start and end of DST)
|
||||
const expectedNumberOfEvents = 20 * 2;
|
||||
expect(events.length).toBe(expectedNumberOfEvents);
|
||||
});
|
||||
|
||||
it('should handle timezones with no DST changes', () => {
|
||||
const timezone = 'Asia/Tokyo';
|
||||
const events = createDstEvents(timezone);
|
||||
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { Moment } from 'moment-timezone';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import type { MlCalendar, MlCalendarEvent } from '../../../../common/types/calendars';
|
||||
import { generateTempId } from './edit/utils';
|
||||
|
||||
const YEARS_OF_DST_EVENTS = 20;
|
||||
|
||||
function addZeroPadding(num: number) {
|
||||
return num < 10 ? `0${num}` : num;
|
||||
}
|
||||
|
||||
const DST_CHANGE_DESCRIPTIONS = {
|
||||
WINTER: i18n.translate('xpack.ml.calendarsEdit.dstChangeDescriptionWinter', {
|
||||
defaultMessage: 'Winter',
|
||||
}),
|
||||
SUMMER: i18n.translate('xpack.ml.calendarsEdit.dstChangeDescriptionSummer', {
|
||||
defaultMessage: 'Summer',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
function createDstEvent(time: Moment, year: number, shiftSecs: number) {
|
||||
return {
|
||||
event_id: generateTempId(),
|
||||
description: `${
|
||||
shiftSecs > 0 ? DST_CHANGE_DESCRIPTIONS.SUMMER : DST_CHANGE_DESCRIPTIONS.WINTER
|
||||
} ${year}`,
|
||||
start_time: time.valueOf(),
|
||||
end_time: time.add(2, 'days').valueOf(),
|
||||
skip_result: false,
|
||||
skip_model_update: false,
|
||||
force_time_shift: shiftSecs,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDSTChangeDates(timezone: string, year: number) {
|
||||
let start: Moment | null = null;
|
||||
let end: Moment | null = null;
|
||||
|
||||
for (let month = 1; month < 13; month++) {
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const date = moment.tz(
|
||||
`${year}-${addZeroPadding(month)}-${addZeroPadding(day)} 09:00:00`,
|
||||
timezone
|
||||
);
|
||||
if (date.isValid() === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!start && date.isDST()) {
|
||||
// loop over hours
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const date2 = moment.tz(
|
||||
`${year}-${addZeroPadding(month)}-${addZeroPadding(day)} ${addZeroPadding(hour)}:00:00`,
|
||||
timezone
|
||||
);
|
||||
if (date2.isDST() === true) {
|
||||
start = date2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start && !end && date.isDST() === false) {
|
||||
// loop over hours
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const date2 = moment.tz(
|
||||
`${year}-${addZeroPadding(month)}-${addZeroPadding(day)} ${addZeroPadding(hour)}:00:00`,
|
||||
timezone
|
||||
);
|
||||
if (date2.isDST() === false) {
|
||||
end = date2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end, year };
|
||||
}
|
||||
|
||||
function generateDSTChangeDates(
|
||||
timezone: string,
|
||||
years: number
|
||||
): {
|
||||
dates: Array<{ start: Moment | null; end: Moment | null; year: number }>;
|
||||
shiftSecs: number;
|
||||
} {
|
||||
const thisYear = new Date().getFullYear();
|
||||
const endYear = thisYear + years;
|
||||
const dates = [];
|
||||
for (let year = thisYear; year < endYear; year++) {
|
||||
const dstChanges = getDSTChangeDates(timezone, year);
|
||||
dates.push(dstChanges);
|
||||
}
|
||||
const janDate = moment.tz(`${thisYear}-01-10 09:00:00`, timezone);
|
||||
const juneDate = moment.tz(`${thisYear}-06-10 09:00:00`, timezone);
|
||||
const diffMins = juneDate.utcOffset() - janDate.utcOffset();
|
||||
const shiftSecs = diffMins * 60;
|
||||
return { dates, shiftSecs };
|
||||
}
|
||||
|
||||
export function createDstEvents(timezone: string) {
|
||||
const { dates, shiftSecs } = generateDSTChangeDates(timezone, YEARS_OF_DST_EVENTS);
|
||||
return dates.reduce<MlCalendarEvent[]>((acc, date) => {
|
||||
if (!date.start || !date.end) {
|
||||
return acc;
|
||||
}
|
||||
acc.push(createDstEvent(date.start, date.year, shiftSecs));
|
||||
acc.push(createDstEvent(date.end, date.year, -shiftSecs));
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function isDstCalendar(calendar: MlCalendar) {
|
||||
return calendar.events.some((event) => {
|
||||
return event.force_time_shift !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterCalendarsForDst(calendars: MlCalendar[], isDst: boolean) {
|
||||
return calendars.filter((calendar) => {
|
||||
return isDstCalendar(calendar) === isDst;
|
||||
});
|
||||
}
|
||||
|
||||
export function separateCalendarsByType(allCalendars: MlCalendar[]) {
|
||||
const calendarsDst: MlCalendar[] = [];
|
||||
const calendars: MlCalendar[] = [];
|
||||
allCalendars.forEach((calendar) => {
|
||||
if (isDstCalendar(calendar)) {
|
||||
calendarsDst.push(calendar);
|
||||
} else {
|
||||
calendars.push(calendar);
|
||||
}
|
||||
});
|
||||
return { calendarsDst, calendars };
|
||||
}
|
||||
|
||||
export function generateTimeZones() {
|
||||
const zones = moment.tz.names();
|
||||
return zones;
|
||||
}
|
|
@ -34,37 +34,9 @@ exports[`CalendarForm Renders calendar form 1`] = `
|
|||
value=""
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Description"
|
||||
id="xpack.ml.calendarsEdit.calendarForm.descriptionLabel"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="mlCalendarDescriptionInput"
|
||||
disabled={false}
|
||||
name="description"
|
||||
onChange={[MockFunction]}
|
||||
value=""
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiSwitch
|
||||
checked={false}
|
||||
data-test-subj="mlCalendarApplyToAllJobsSwitch"
|
||||
disabled={false}
|
||||
label={
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
defaultMessage="Apply calendar to all jobs"
|
||||
id="xpack.ml.calendarsEdit.calendarForm.allJobsLabel"
|
||||
/>
|
||||
}
|
||||
name="switch"
|
||||
/>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
|
@ -128,14 +100,12 @@ exports[`CalendarForm Renders calendar form 1`] = `
|
|||
}
|
||||
>
|
||||
<EventsTable
|
||||
canCreateCalendar={true}
|
||||
canDeleteCalendar={true}
|
||||
eventsList={Array []}
|
||||
onDeleteClick={[MockFunction]}
|
||||
saving={false}
|
||||
showImportModal={[MockFunction]}
|
||||
showNewEventModal={[MockFunction]}
|
||||
showSearchBar={true}
|
||||
showSearchBar={false}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer
|
||||
|
|
|
@ -12,6 +12,10 @@ import { CalendarForm } from './calendar_form';
|
|||
jest.mock('../../../../contexts/kibana/use_create_url', () => ({
|
||||
useCreateAndNavigateToMlLink: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
|
||||
const testProps = {
|
||||
calendarId: '',
|
||||
canCreateCalendar: true,
|
||||
|
|
|
@ -5,9 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import type { FC } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { EuiSwitchEvent, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiComboBox,
|
||||
|
@ -21,17 +23,21 @@ import {
|
|||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { EventsTable } from '../events_table';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { usePermissionCheck } from '../../../../capabilities/check_capabilities';
|
||||
import { ML_PAGES } from '../../../../../../common/constants/locator';
|
||||
import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url';
|
||||
import { MlPageHeader } from '../../../../components/page_header';
|
||||
import { DstEventGenerator } from './dst_event_generator';
|
||||
import { EventsTable } from '../events_table';
|
||||
|
||||
function EditHeader({ calendarId, description }) {
|
||||
const EditHeader: FC<{ calendarId: string; description: string }> = ({
|
||||
calendarId,
|
||||
description,
|
||||
}) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<MlPageHeader>
|
||||
<span data-test-subj={'mlCalendarTitle'}>
|
||||
<FormattedMessage
|
||||
|
@ -49,20 +55,47 @@ function EditHeader({ calendarId, description }) {
|
|||
<EuiSpacer size="l" />
|
||||
</>
|
||||
) : null}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
calendarId: string;
|
||||
description: string;
|
||||
eventsList: estypes.MlCalendarEvent[];
|
||||
groupIdOptions: EuiComboBoxOptionOption[];
|
||||
isEdit: boolean;
|
||||
isNewCalendarIdValid: boolean;
|
||||
jobIdOptions: EuiComboBoxOptionOption[];
|
||||
onCalendarIdChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onCreate: () => void;
|
||||
onCreateGroupOption: (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[]) => void;
|
||||
onDescriptionChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onEdit: () => void;
|
||||
onEventDelete: (eventId: string) => void;
|
||||
onGroupSelection: (selectedOptions: any) => void;
|
||||
showImportModal: () => void;
|
||||
onJobSelection: (selectedOptions: any) => void;
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
selectedGroupOptions: EuiComboBoxOptionOption[];
|
||||
selectedJobOptions: EuiComboBoxOptionOption[];
|
||||
showNewEventModal: () => void;
|
||||
isGlobalCalendar: boolean;
|
||||
onGlobalCalendarChange: (e: EuiSwitchEvent) => void;
|
||||
addEvents: (events: estypes.MlCalendarEvent[]) => void;
|
||||
clearEvents: () => void;
|
||||
isDst: boolean;
|
||||
}
|
||||
|
||||
export const CalendarForm = ({
|
||||
export const CalendarForm: FC<Props> = ({
|
||||
calendarId,
|
||||
canCreateCalendar,
|
||||
canDeleteCalendar,
|
||||
description,
|
||||
eventsList,
|
||||
groupIds,
|
||||
groupIdOptions,
|
||||
isEdit,
|
||||
isNewCalendarIdValid,
|
||||
jobIds,
|
||||
jobIdOptions,
|
||||
onCalendarIdChange,
|
||||
onCreate,
|
||||
onCreateGroupOption,
|
||||
|
@ -79,7 +112,12 @@ export const CalendarForm = ({
|
|||
showNewEventModal,
|
||||
isGlobalCalendar,
|
||||
onGlobalCalendarChange,
|
||||
addEvents,
|
||||
clearEvents,
|
||||
isDst,
|
||||
}) => {
|
||||
const [canCreateCalendar] = usePermissionCheck(['canCreateCalendar']);
|
||||
const [timezone, setTimezone] = useState<string | undefined>(undefined);
|
||||
const msg = i18n.translate('xpack.ml.calendarsEdit.calendarForm.allowedCharactersDescription', {
|
||||
defaultMessage:
|
||||
'Use lowercase alphanumerics (a-z and 0-9), hyphens or underscores; ' +
|
||||
|
@ -92,20 +130,38 @@ export const CalendarForm = ({
|
|||
saving ||
|
||||
!isNewCalendarIdValid ||
|
||||
calendarId === '' ||
|
||||
loading === true;
|
||||
const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE);
|
||||
loading === true ||
|
||||
(isDst && eventsList.length === 0);
|
||||
const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(
|
||||
isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE
|
||||
);
|
||||
|
||||
const addDstEvents = useCallback(
|
||||
(events: estypes.MlCalendarEvent[]) => {
|
||||
clearEvents();
|
||||
addEvents(events);
|
||||
},
|
||||
[addEvents, clearEvents]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiForm data-test-subj={`mlCalendarForm${isEdit === true ? 'Edit' : 'New'}`}>
|
||||
{isEdit === true ? (
|
||||
<EditHeader calendarId={calendarId} description={description} />
|
||||
) : (
|
||||
<Fragment>
|
||||
<>
|
||||
<MlPageHeader>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.createCalendarTitle"
|
||||
defaultMessage="Create new calendar"
|
||||
/>
|
||||
{isDst ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.createCalendarDstTitle"
|
||||
defaultMessage="Create new DST calendar"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.createCalendarTitle"
|
||||
defaultMessage="Create new calendar"
|
||||
/>
|
||||
)}
|
||||
</MlPageHeader>
|
||||
<EuiFormRow
|
||||
label={
|
||||
|
@ -122,47 +178,51 @@ export const CalendarForm = ({
|
|||
name="calendarId"
|
||||
value={calendarId}
|
||||
onChange={onCalendarIdChange}
|
||||
disabled={isEdit === true || saving === true || loading === true}
|
||||
disabled={saving === true || loading === true}
|
||||
data-test-subj="mlCalendarIdInput"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.descriptionLabel"
|
||||
defaultMessage="Description"
|
||||
{isDst === false ? (
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.descriptionLabel"
|
||||
defaultMessage="Description"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={onDescriptionChange}
|
||||
disabled={saving === true || loading === true}
|
||||
data-test-subj="mlCalendarDescriptionInput"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={onDescriptionChange}
|
||||
disabled={isEdit === true || saving === true || loading === true}
|
||||
data-test-subj="mlCalendarDescriptionInput"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
</Fragment>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiSwitch
|
||||
name="switch"
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.allJobsLabel"
|
||||
defaultMessage="Apply calendar to all jobs"
|
||||
/>
|
||||
}
|
||||
checked={isGlobalCalendar}
|
||||
onChange={onGlobalCalendarChange}
|
||||
disabled={saving === true || canCreateCalendar === false || loading === true}
|
||||
data-test-subj="mlCalendarApplyToAllJobsSwitch"
|
||||
/>
|
||||
{isDst === false ? (
|
||||
<EuiSwitch
|
||||
name="switch"
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.allJobsLabel"
|
||||
defaultMessage="Apply calendar to all jobs"
|
||||
/>
|
||||
}
|
||||
checked={isGlobalCalendar}
|
||||
onChange={onGlobalCalendarChange}
|
||||
disabled={saving === true || canCreateCalendar === false || loading === true}
|
||||
data-test-subj="mlCalendarApplyToAllJobsSwitch"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isGlobalCalendar === false && (
|
||||
{isGlobalCalendar === false ? (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
|
@ -175,7 +235,7 @@ export const CalendarForm = ({
|
|||
}
|
||||
>
|
||||
<EuiComboBox
|
||||
options={jobIds}
|
||||
options={jobIdOptions}
|
||||
selectedOptions={selectedJobOptions}
|
||||
onChange={onJobSelection}
|
||||
isDisabled={saving === true || canCreateCalendar === false || loading === true}
|
||||
|
@ -193,7 +253,7 @@ export const CalendarForm = ({
|
|||
>
|
||||
<EuiComboBox
|
||||
onCreateOption={onCreateGroupOption}
|
||||
options={groupIds}
|
||||
options={groupIdOptions}
|
||||
selectedOptions={selectedGroupOptions}
|
||||
onChange={onGroupSelection}
|
||||
isDisabled={saving === true || canCreateCalendar === false || loading === true}
|
||||
|
@ -201,30 +261,46 @@ export const CalendarForm = ({
|
|||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.eventsLabel"
|
||||
defaultMessage="Events"
|
||||
/>
|
||||
isDst ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.dstEventsLabel"
|
||||
defaultMessage="Time zone of data"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.eventsLabel"
|
||||
defaultMessage="Events"
|
||||
/>
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EventsTable
|
||||
canCreateCalendar={canCreateCalendar}
|
||||
canDeleteCalendar={canDeleteCalendar}
|
||||
eventsList={eventsList}
|
||||
onDeleteClick={onEventDelete}
|
||||
showImportModal={showImportModal}
|
||||
showNewEventModal={showNewEventModal}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
showSearchBar
|
||||
/>
|
||||
<>
|
||||
{isDst ? (
|
||||
<DstEventGenerator
|
||||
addEvents={addDstEvents}
|
||||
setTimezone={setTimezone}
|
||||
isDisabled={saving === true || canCreateCalendar === false || loading === true}
|
||||
/>
|
||||
) : null}
|
||||
<EventsTable
|
||||
eventsList={eventsList}
|
||||
onDeleteClick={onEventDelete}
|
||||
showImportModal={showImportModal}
|
||||
showNewEventModal={showNewEventModal}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
showSearchBar={isDst === false}
|
||||
timezone={timezone}
|
||||
isDst={isDst}
|
||||
/>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
|
@ -260,30 +336,3 @@ export const CalendarForm = ({
|
|||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
CalendarForm.propTypes = {
|
||||
calendarId: PropTypes.string.isRequired,
|
||||
canCreateCalendar: PropTypes.bool.isRequired,
|
||||
canDeleteCalendar: PropTypes.bool.isRequired,
|
||||
description: PropTypes.string,
|
||||
groupIds: PropTypes.array.isRequired,
|
||||
isEdit: PropTypes.bool.isRequired,
|
||||
isNewCalendarIdValid: PropTypes.bool.isRequired,
|
||||
jobIds: PropTypes.array.isRequired,
|
||||
onCalendarIdChange: PropTypes.func.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onCreateGroupOption: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onEventDelete: PropTypes.func.isRequired,
|
||||
onGroupSelection: PropTypes.func.isRequired,
|
||||
showImportModal: PropTypes.func.isRequired,
|
||||
onJobSelection: PropTypes.func.isRequired,
|
||||
saving: PropTypes.bool.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
selectedGroupOptions: PropTypes.array.isRequired,
|
||||
selectedJobOptions: PropTypes.array.isRequired,
|
||||
showNewEventModal: PropTypes.func.isRequired,
|
||||
isGlobalCalendar: PropTypes.bool.isRequired,
|
||||
onGlobalCalendarChange: PropTypes.func.isRequired,
|
||||
};
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import {
|
||||
EuiCallOut,
|
||||
EuiComboBox,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import type { FC } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { createDstEvents, generateTimeZones } from '../../dst_utils';
|
||||
|
||||
interface Props {
|
||||
addEvents: (events: estypes.MlCalendarEvent[]) => void;
|
||||
setTimezone: (timezone: string) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const DstEventGenerator: FC<Props> = ({ addEvents, setTimezone, isDisabled }) => {
|
||||
const [selectedTimeZones, setSelectedTimeZones] = useState<
|
||||
Array<EuiComboBoxOptionOption<string>>
|
||||
>([]);
|
||||
const [eventsCount, setEventsCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTimeZones.length > 0) {
|
||||
setTimezone(selectedTimeZones[0].value!);
|
||||
const events = createDstEvents(selectedTimeZones[0].value!);
|
||||
addEvents(events);
|
||||
setEventsCount(events.length);
|
||||
} else {
|
||||
addEvents([]);
|
||||
setEventsCount(null);
|
||||
}
|
||||
}, [addEvents, selectedTimeZones, setTimezone]);
|
||||
|
||||
const timeZoneOptions = useMemo(() => {
|
||||
return generateTimeZones().map((tz) => {
|
||||
return {
|
||||
label: tz,
|
||||
value: tz,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.dstEventsHelpText"
|
||||
defaultMessage="The selected time zone should match the time zone of the data."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFlexGroup wrap gutterSize="s">
|
||||
<EuiFlexItem grow={false} css={{ width: '400px' }}>
|
||||
<EuiComboBox
|
||||
placeholder="Select time zone"
|
||||
singleSelection={{ asPlainText: true }}
|
||||
options={timeZoneOptions}
|
||||
selectedOptions={selectedTimeZones}
|
||||
onChange={setSelectedTimeZones}
|
||||
isDisabled={isDisabled === true}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
|
||||
{eventsCount === 0 ? (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
iconType="iInCircle"
|
||||
size="s"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.dstEventGenerator.noTimeZonesAvailableTitle"
|
||||
defaultMessage="No DST events available"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.calendarsEdit.calendarForm.dstEventGenerator.noTimeZonesAvailableDescription"
|
||||
defaultMessage="Some time zones do not observe daylight saving time."
|
||||
/>
|
||||
</div>
|
||||
</EuiCallOut>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -17,13 +17,17 @@ exports[`EventsTable Renders events table with no search bar 1`] = `
|
|||
},
|
||||
Object {
|
||||
"field": "start_time",
|
||||
"name": "Start",
|
||||
"name": <span>
|
||||
Start
|
||||
</span>,
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "end_time",
|
||||
"name": "End",
|
||||
"name": <span>
|
||||
End
|
||||
</span>,
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
|
@ -62,7 +66,7 @@ exports[`EventsTable Renders events table with no search bar 1`] = `
|
|||
Object {
|
||||
"sort": Object {
|
||||
"direction": "asc",
|
||||
"field": "description",
|
||||
"field": "start_time",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -88,13 +92,17 @@ exports[`EventsTable Renders events table with search bar 1`] = `
|
|||
},
|
||||
Object {
|
||||
"field": "start_time",
|
||||
"name": "Start",
|
||||
"name": <span>
|
||||
Start
|
||||
</span>,
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "end_time",
|
||||
"name": "End",
|
||||
"name": <span>
|
||||
End
|
||||
</span>,
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
},
|
||||
|
@ -167,7 +175,7 @@ exports[`EventsTable Renders events table with search bar 1`] = `
|
|||
Object {
|
||||
"sort": Object {
|
||||
"direction": "asc",
|
||||
"field": "description",
|
||||
"field": "start_time",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,10 @@ import { shallowWithIntl } from '@kbn/test-jest-helpers';
|
|||
import React from 'react';
|
||||
import { EventsTable } from './events_table';
|
||||
|
||||
jest.mock('../../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
|
||||
const testProps = {
|
||||
canCreateCalendar: true,
|
||||
eventsList: [
|
||||
|
|
|
@ -5,19 +5,25 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Fragment } from 'react';
|
||||
import moment from 'moment';
|
||||
import type { FC } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import moment from 'moment-timezone';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { EuiButton, EuiButtonEmpty, EuiInMemoryTable, EuiSpacer } from '@elastic/eui';
|
||||
import { EuiButton, EuiButtonEmpty, EuiIconTip, EuiInMemoryTable, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { TIME_FORMAT } from '@kbn/ml-date-utils';
|
||||
import { usePermissionCheck } from '../../../../capabilities/check_capabilities';
|
||||
|
||||
function DeleteButton({ onClick, testSubj, disabled }) {
|
||||
const DeleteButton: FC<{
|
||||
onClick: () => void;
|
||||
testSubj: string;
|
||||
disabled: boolean;
|
||||
}> = ({ onClick, testSubj, disabled }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
color="danger"
|
||||
|
@ -30,13 +36,23 @@ function DeleteButton({ onClick, testSubj, disabled }) {
|
|||
defaultMessage="Delete"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
eventsList: estypes.MlCalendarEvent[];
|
||||
onDeleteClick: (eventId: string) => void;
|
||||
showImportModal: () => void;
|
||||
showNewEventModal: () => void;
|
||||
showSearchBar?: boolean;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
timezone?: string;
|
||||
isDst: boolean;
|
||||
}
|
||||
|
||||
export const EventsTable = ({
|
||||
canCreateCalendar,
|
||||
canDeleteCalendar,
|
||||
export const EventsTable: FC<Props> = ({
|
||||
eventsList,
|
||||
onDeleteClick,
|
||||
showSearchBar,
|
||||
|
@ -44,19 +60,32 @@ export const EventsTable = ({
|
|||
showNewEventModal,
|
||||
loading,
|
||||
saving,
|
||||
timezone,
|
||||
isDst,
|
||||
}) => {
|
||||
const sorting = {
|
||||
sort: {
|
||||
field: 'description',
|
||||
direction: 'asc',
|
||||
},
|
||||
};
|
||||
const [canCreateCalendar, canDeleteCalendar] = usePermissionCheck([
|
||||
'canCreateCalendar',
|
||||
'canDeleteCalendar',
|
||||
]);
|
||||
|
||||
const pagination = {
|
||||
initialPageSize: 5,
|
||||
pageSizeOptions: [5, 10],
|
||||
};
|
||||
|
||||
const formatEventDate = useCallback(
|
||||
(timeMs: number) => {
|
||||
if (timezone === undefined) {
|
||||
const time = moment(timeMs);
|
||||
return time.format(TIME_FORMAT);
|
||||
}
|
||||
|
||||
const time = moment.tz(timeMs, timezone);
|
||||
return time.toLocaleString();
|
||||
},
|
||||
[timezone]
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
field: 'description',
|
||||
|
@ -69,35 +98,66 @@ export const EventsTable = ({
|
|||
},
|
||||
{
|
||||
field: 'start_time',
|
||||
name: i18n.translate('xpack.ml.calendarsEdit.eventsTable.startColumnName', {
|
||||
defaultMessage: 'Start',
|
||||
}),
|
||||
name: (
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.calendarsEdit.eventsTable.startColumnName', {
|
||||
defaultMessage: 'Start',
|
||||
})}
|
||||
{isDst ? (
|
||||
<>
|
||||
|
||||
<EuiIconTip
|
||||
size="s"
|
||||
color="subdued"
|
||||
type="questionInCircle"
|
||||
className="eui-alignTop"
|
||||
content={i18n.translate('xpack.ml.calendarsEdit.eventsTable.startColumnTooltip', {
|
||||
defaultMessage: 'The start time of the daylight savings change event.',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
sortable: true,
|
||||
render: (timeMs) => {
|
||||
const time = moment(timeMs);
|
||||
return time.format(TIME_FORMAT);
|
||||
},
|
||||
render: formatEventDate,
|
||||
},
|
||||
{
|
||||
field: 'end_time',
|
||||
name: i18n.translate('xpack.ml.calendarsEdit.eventsTable.endColumnName', {
|
||||
defaultMessage: 'End',
|
||||
}),
|
||||
name: (
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.calendarsEdit.eventsTable.endColumnName', {
|
||||
defaultMessage: 'End',
|
||||
})}
|
||||
{isDst ? (
|
||||
<>
|
||||
|
||||
<EuiIconTip
|
||||
size="s"
|
||||
color="subdued"
|
||||
type="questionInCircle"
|
||||
className="eui-alignTop"
|
||||
content={i18n.translate('xpack.ml.calendarsEdit.eventsTable.endColumnTooltip', {
|
||||
defaultMessage:
|
||||
'The end time of the daylight savings change event. 2 days after the start time.',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
sortable: true,
|
||||
render: (timeMs) => {
|
||||
const time = moment(timeMs);
|
||||
return time.format(TIME_FORMAT);
|
||||
},
|
||||
render: formatEventDate,
|
||||
},
|
||||
{
|
||||
field: '',
|
||||
name: '',
|
||||
render: (event) => (
|
||||
render: (event: estypes.MlCalendarEvent) => (
|
||||
<DeleteButton
|
||||
testSubj="mlCalendarEventDeleteButton"
|
||||
disabled={canDeleteCalendar === false || saving === true || loading === true}
|
||||
onClick={() => {
|
||||
onDeleteClick(event.event_id);
|
||||
onDeleteClick(event.event_id!);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
@ -140,38 +200,25 @@ export const EventsTable = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiInMemoryTable
|
||||
<EuiInMemoryTable<estypes.MlCalendarEvent>
|
||||
items={eventsList}
|
||||
itemId="event_id"
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
sorting={sorting}
|
||||
sorting={{
|
||||
sort: {
|
||||
field: 'start_time',
|
||||
direction: 'asc',
|
||||
},
|
||||
}}
|
||||
search={showSearchBar ? search : undefined}
|
||||
data-test-subj="mlCalendarEventsTable"
|
||||
rowProps={(item) => ({
|
||||
'data-test-subj': `mlCalendarEventListRow row-${item.description}`,
|
||||
})}
|
||||
/>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
EventsTable.propTypes = {
|
||||
canCreateCalendar: PropTypes.bool,
|
||||
canDeleteCalendar: PropTypes.bool,
|
||||
eventsList: PropTypes.array.isRequired,
|
||||
onDeleteClick: PropTypes.func.isRequired,
|
||||
showImportModal: PropTypes.func,
|
||||
showNewEventModal: PropTypes.func,
|
||||
showSearchBar: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
saving: PropTypes.bool,
|
||||
};
|
||||
|
||||
EventsTable.defaultProps = {
|
||||
showSearchBar: false,
|
||||
canCreateCalendar: true,
|
||||
canDeleteCalendar: true,
|
||||
};
|
|
@ -9,6 +9,10 @@ import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers';
|
|||
import React from 'react';
|
||||
import { ImportModal } from './import_modal';
|
||||
|
||||
jest.mock('../../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
|
||||
const testProps = {
|
||||
addImportedEvents: jest.fn(),
|
||||
closeImportModal: jest.fn(),
|
||||
|
|
|
@ -24,8 +24,6 @@ exports[`ImportedEvents Renders imported events 1`] = `
|
|||
grow={false}
|
||||
>
|
||||
<EventsTable
|
||||
canCreateCalendar={true}
|
||||
canDeleteCalendar={true}
|
||||
eventsList={
|
||||
Array [
|
||||
Object {
|
||||
|
@ -38,7 +36,6 @@ exports[`ImportedEvents Renders imported events 1`] = `
|
|||
]
|
||||
}
|
||||
onDeleteClick={[MockFunction]}
|
||||
showSearchBar={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiSpacer
|
||||
|
|
|
@ -9,6 +9,5 @@ import type { FC } from 'react';
|
|||
|
||||
declare const NewCalendar: FC<{
|
||||
calendarId?: string;
|
||||
canCreateCalendar: boolean;
|
||||
canDeleteCalendar: boolean;
|
||||
isDst: boolean;
|
||||
}>;
|
||||
|
|
|
@ -25,8 +25,7 @@ import { HelpMenu } from '../../../components/help_menu';
|
|||
class NewCalendarUI extends Component {
|
||||
static propTypes = {
|
||||
calendarId: PropTypes.string,
|
||||
canCreateCalendar: PropTypes.bool.isRequired,
|
||||
canDeleteCalendar: PropTypes.bool.isRequired,
|
||||
isDst: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -66,7 +65,12 @@ class NewCalendarUI extends Component {
|
|||
application: { navigateToUrl },
|
||||
},
|
||||
} = this.props.kibana;
|
||||
await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.CALENDARS_MANAGE}`, true);
|
||||
await navigateToUrl(
|
||||
`${basePath.get()}/app/ml/${
|
||||
this.props.isDst ? ML_PAGES.CALENDARS_DST_MANAGE : ML_PAGES.CALENDARS_MANAGE
|
||||
}`,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
async formSetup() {
|
||||
|
@ -219,6 +223,11 @@ class NewCalendarUI extends Component {
|
|||
description: event.description,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
...(event.skip_result !== undefined ? { skip_result: event.skip_result } : {}),
|
||||
...(event.skip_model_update !== undefined
|
||||
? { skip_model_update: event.skip_model_update }
|
||||
: {}),
|
||||
...(event.force_time_shift !== undefined ? { force_time_shift: event.force_time_shift } : {}),
|
||||
}));
|
||||
|
||||
// set up calendar
|
||||
|
@ -308,6 +317,19 @@ class NewCalendarUI extends Component {
|
|||
}));
|
||||
};
|
||||
|
||||
addEvents = (events) => {
|
||||
this.setState((prevState) => ({
|
||||
events: [...prevState.events, ...events],
|
||||
isNewEventModalVisible: false,
|
||||
}));
|
||||
};
|
||||
|
||||
clearEvents = () => {
|
||||
this.setState(() => ({
|
||||
events: [],
|
||||
}));
|
||||
};
|
||||
|
||||
addImportedEvents = (events) => {
|
||||
this.setState((prevState) => ({
|
||||
events: [...prevState.events, ...events],
|
||||
|
@ -354,16 +376,14 @@ class NewCalendarUI extends Component {
|
|||
<EuiPageBody>
|
||||
<CalendarForm
|
||||
calendarId={selectedCalendar ? selectedCalendar.calendar_id : formCalendarId}
|
||||
canCreateCalendar={this.props.canCreateCalendar}
|
||||
canDeleteCalendar={this.props.canDeleteCalendar}
|
||||
description={selectedCalendar ? selectedCalendar.description : description}
|
||||
eventsList={events}
|
||||
groupIds={groupIdOptions}
|
||||
groupIdOptions={groupIdOptions}
|
||||
isEdit={selectedCalendar !== undefined}
|
||||
isNewCalendarIdValid={
|
||||
selectedCalendar || isNewCalendarIdValid === null ? true : isNewCalendarIdValid
|
||||
}
|
||||
jobIds={jobIdOptions}
|
||||
jobIdOptions={jobIdOptions}
|
||||
onCalendarIdChange={this.onCalendarIdChange}
|
||||
onCreate={this.onCreate}
|
||||
onDescriptionChange={this.onDescriptionChange}
|
||||
|
@ -380,6 +400,9 @@ class NewCalendarUI extends Component {
|
|||
showNewEventModal={this.showNewEventModal}
|
||||
isGlobalCalendar={isGlobalCalendar}
|
||||
onGlobalCalendarChange={this.onGlobalCalendarChange}
|
||||
addEvents={this.addEvents}
|
||||
clearEvents={this.clearEvents}
|
||||
isDst={this.props.isDst}
|
||||
/>
|
||||
{modal}
|
||||
</EuiPageBody>
|
||||
|
|
|
@ -31,6 +31,9 @@ jest.mock('../../../capabilities/get_capabilities', () => ({
|
|||
jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({
|
||||
mlNodesAvailable: () => true,
|
||||
}));
|
||||
jest.mock('../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
|
||||
const calendarsMock = [
|
||||
{
|
||||
|
@ -115,16 +118,11 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
|
|||
|
||||
import { NewCalendar } from './new_calendar';
|
||||
|
||||
const props = {
|
||||
canCreateCalendar: true,
|
||||
canDeleteCalendar: true,
|
||||
};
|
||||
|
||||
describe('NewCalendar', () => {
|
||||
test('Renders new calendar form', () => {
|
||||
const { getByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<NewCalendar {...props} />
|
||||
<NewCalendar isDst={false} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
|
@ -134,7 +132,7 @@ describe('NewCalendar', () => {
|
|||
test('Import modal button is disabled', () => {
|
||||
const { getByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<NewCalendar {...props} />
|
||||
<NewCalendar isDst={false} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
|
@ -146,7 +144,7 @@ describe('NewCalendar', () => {
|
|||
test('New event modal button is disabled', async () => {
|
||||
const { getByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<NewCalendar {...props} />
|
||||
<NewCalendar isDst={false} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
|
@ -158,7 +156,7 @@ describe('NewCalendar', () => {
|
|||
test('isDuplicateId returns true if form calendar id already exists in calendars', async () => {
|
||||
const { getByTestId, queryByTestId, getByText } = render(
|
||||
<IntlProvider locale="en">
|
||||
<NewCalendar {...props} />
|
||||
<NewCalendar isDst={false} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
|
@ -187,20 +185,4 @@ describe('NewCalendar', () => {
|
|||
'Cannot create calendar with id [this-is-a-new-calendar] as it already exists.'
|
||||
);
|
||||
});
|
||||
|
||||
test('Save button is disabled if canCreateCalendar is false', () => {
|
||||
const noCreateProps = {
|
||||
...props,
|
||||
canCreateCalendar: false,
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
<NewCalendar {...noCreateProps} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const saveButton = getByTestId('mlSaveCalendarButton');
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isJobIdValid } from '../../../../../common/util/job_utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
function getJobIds(mlApi) {
|
||||
import { isJobIdValid } from '../../../../../common/util/job_utils';
|
||||
import type { MlApi } from '../../../services/ml_api_service';
|
||||
|
||||
function getJobIds(mlApi: MlApi) {
|
||||
return new Promise((resolve, reject) => {
|
||||
mlApi.jobs
|
||||
.jobsSummary()
|
||||
.jobsSummary([])
|
||||
.then((resp) => {
|
||||
resolve(resp.map((job) => job.id));
|
||||
})
|
||||
|
@ -23,13 +25,14 @@ function getJobIds(mlApi) {
|
|||
values: { err },
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(errorMessage);
|
||||
reject(errorMessage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupIds(mlApi) {
|
||||
function getGroupIds(mlApi: MlApi) {
|
||||
return new Promise((resolve, reject) => {
|
||||
mlApi.jobs
|
||||
.groups()
|
||||
|
@ -44,13 +47,14 @@ function getGroupIds(mlApi) {
|
|||
values: { err },
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(errorMessage);
|
||||
reject(errorMessage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCalendars(mlApi) {
|
||||
function getCalendars(mlApi: MlApi) {
|
||||
return new Promise((resolve, reject) => {
|
||||
mlApi
|
||||
.calendars()
|
||||
|
@ -65,13 +69,14 @@ function getCalendars(mlApi) {
|
|||
values: { err },
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(errorMessage);
|
||||
reject(errorMessage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarSettingsData(mlApi) {
|
||||
export function getCalendarSettingsData(mlApi: MlApi) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const [jobIds, groupIds, calendars] = await Promise.all([
|
||||
|
@ -86,13 +91,14 @@ export function getCalendarSettingsData(mlApi) {
|
|||
calendars,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCalendarId(calendarId) {
|
||||
export function validateCalendarId(calendarId: string) {
|
||||
let valid = true;
|
||||
|
||||
if (calendarId === '' || calendarId === undefined) {
|
|
@ -82,7 +82,7 @@ exports[`CalendarListsHeader renders header 1`] = `
|
|||
Object {
|
||||
"br": <br />,
|
||||
"learnMoreLink": <EuiLink
|
||||
href="jest-metadata-mock-url"
|
||||
href={[MockFunction]}
|
||||
target="_blank"
|
||||
>
|
||||
<Memo(MemoizedFormattedMessage)
|
||||
|
|
|
@ -10,4 +10,5 @@ import type { FC } from 'react';
|
|||
declare const CalendarsList: FC<{
|
||||
canCreateCalendar: boolean;
|
||||
canDeleteCalendar: boolean;
|
||||
isDst: boolean;
|
||||
}>;
|
||||
|
|
|
@ -18,11 +18,13 @@ import { deleteCalendars } from './delete_calendars';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { withKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { HelpMenu } from '../../../components/help_menu';
|
||||
import { isDstCalendar } from '../dst_utils';
|
||||
|
||||
export class CalendarsListUI extends Component {
|
||||
static propTypes = {
|
||||
canCreateCalendar: PropTypes.bool.isRequired,
|
||||
canDeleteCalendar: PropTypes.bool.isRequired,
|
||||
isDst: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -42,7 +44,9 @@ export class CalendarsListUI extends Component {
|
|||
this.setState({ loading: true });
|
||||
|
||||
try {
|
||||
const calendars = await mlApi.calendars();
|
||||
const calendars = (await mlApi.calendars()).filter(
|
||||
(calendar) => isDstCalendar(calendar) === this.props.isDst
|
||||
);
|
||||
|
||||
this.setState({
|
||||
calendars,
|
||||
|
@ -146,6 +150,7 @@ export class CalendarsListUI extends Component {
|
|||
<CalendarsListHeader
|
||||
totalCount={calendars.length}
|
||||
refreshCalendars={this.loadCalendars}
|
||||
isDst={this.props.isDst}
|
||||
/>
|
||||
<CalendarsListTable
|
||||
loading={loading}
|
||||
|
@ -156,6 +161,7 @@ export class CalendarsListUI extends Component {
|
|||
mlNodesAvailable={nodesAvailable}
|
||||
setSelectedCalendarList={this.setSelectedCalendarList}
|
||||
itemsSelected={selectedForDeletion.length > 0}
|
||||
isDst={this.props.isDst}
|
||||
/>
|
||||
{destroyModal}
|
||||
</div>
|
||||
|
|
|
@ -43,6 +43,9 @@ jest.mock('../../../capabilities/get_capabilities', () => ({
|
|||
jest.mock('../../../ml_nodes_check/check_ml_nodes', () => ({
|
||||
mlNodesAvailable: () => true,
|
||||
}));
|
||||
jest.mock('../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
|
||||
const mockCalendars = [
|
||||
{
|
||||
|
@ -114,6 +117,7 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
|
|||
const props = {
|
||||
canCreateCalendar: true,
|
||||
canDeleteCalendar: true,
|
||||
isDst: false,
|
||||
};
|
||||
|
||||
describe('CalendarsList', () => {
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* React component for the header section of the calendars list page.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { withKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { MlPageHeader } from '../../../components/page_header';
|
||||
|
||||
function CalendarsListHeaderUI({ totalCount, refreshCalendars, kibana }) {
|
||||
const docsUrl = kibana.services.docLinks.links.ml.calendars;
|
||||
return (
|
||||
<>
|
||||
<MlPageHeader>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsTitle"
|
||||
defaultMessage="Calendars"
|
||||
/>
|
||||
</MlPageHeader>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="baseline">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTextColor color="subdued">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsListTotalCount"
|
||||
defaultMessage="{totalCount} in total"
|
||||
values={{
|
||||
totalCount,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
iconType="refresh"
|
||||
onClick={refreshCalendars}
|
||||
data-test-subj="mlCalendarListRefreshButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.refreshButtonLabel"
|
||||
defaultMessage="Refresh"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<p>
|
||||
<EuiTextColor color="subdued">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDescription"
|
||||
defaultMessage="Calendars contain a list of scheduled events for which you do not want to generate anomalies,
|
||||
such as planned system outages or public holidays. The same calendar can be assigned to multiple jobs.{br}{learnMoreLink}"
|
||||
values={{
|
||||
br: <br />,
|
||||
learnMoreLink: (
|
||||
<EuiLink href={docsUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDescription.learnMoreLinkText"
|
||||
defaultMessage="Learn more"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
CalendarsListHeaderUI.propTypes = {
|
||||
totalCount: PropTypes.number.isRequired,
|
||||
refreshCalendars: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const CalendarsListHeader = withKibana(CalendarsListHeaderUI);
|
|
@ -15,6 +15,16 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
|
|||
return comp;
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../capabilities/check_capabilities', () => ({
|
||||
usePermissionCheck: () => [true, true],
|
||||
}));
|
||||
jest.mock('../../../contexts/kibana/kibana_context', () => ({
|
||||
useMlKibana: () => ({
|
||||
services: {
|
||||
docLinks: { links: { ml: { calendars: jest.fn() } } },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CalendarListsHeader', () => {
|
||||
const refreshCalendars = jest.fn(() => {});
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* React component for the header section of the calendars list page.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { MlPageHeader } from '../../../components/page_header';
|
||||
import { useMlKibana } from '../../../contexts/kibana/kibana_context';
|
||||
|
||||
interface Props {
|
||||
isDst: boolean;
|
||||
totalCount: number;
|
||||
refreshCalendars: () => void;
|
||||
}
|
||||
|
||||
export const CalendarsListHeader: FC<Props> = ({ totalCount, refreshCalendars, isDst }) => {
|
||||
const {
|
||||
services: {
|
||||
docLinks: { links },
|
||||
},
|
||||
} = useMlKibana();
|
||||
const docsUrl = links.ml.calendars;
|
||||
return (
|
||||
<>
|
||||
<MlPageHeader>
|
||||
{isDst ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsTitle"
|
||||
defaultMessage="Daylight savings time calendars"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsTitle"
|
||||
defaultMessage="Calendars"
|
||||
/>
|
||||
)}
|
||||
</MlPageHeader>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="baseline">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTextColor color="subdued">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsListTotalCount"
|
||||
defaultMessage="{totalCount} in total"
|
||||
values={{
|
||||
totalCount,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
iconType="refresh"
|
||||
onClick={refreshCalendars}
|
||||
data-test-subj="mlCalendarListRefreshButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.refreshButtonLabel"
|
||||
defaultMessage="Refresh"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<p>
|
||||
<EuiTextColor color="subdued">
|
||||
{isDst ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDstDescription"
|
||||
defaultMessage="DST calendars contain a list of scheduled events for which you do not want to generate anomalies, taking into account daylight saving time shifts that may cause events to occur one hour earlier or later. The same calendar can be assigned to multiple jobs.{br}{learnMoreLink}"
|
||||
values={{
|
||||
br: <br />,
|
||||
learnMoreLink: (
|
||||
<EuiLink href={docsUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDstDescription.learnMoreLinkText"
|
||||
defaultMessage="Learn more"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDescription"
|
||||
defaultMessage="Calendars contain a list of scheduled events for which you do not want to generate anomalies,
|
||||
such as planned system outages or public holidays. The same calendar can be assigned to multiple jobs.{br}{learnMoreLink}"
|
||||
values={{
|
||||
br: <br />,
|
||||
learnMoreLink: (
|
||||
<EuiLink href={docsUrl} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.settings.calendars.listHeader.calendarsDescription.learnMoreLinkText"
|
||||
defaultMessage="Learn more"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -26,8 +26,11 @@ export const CalendarsListTable = ({
|
|||
canDeleteCalendar,
|
||||
mlNodesAvailable,
|
||||
itemsSelected,
|
||||
isDst,
|
||||
}) => {
|
||||
const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW);
|
||||
const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(
|
||||
isDst ? ML_PAGES.CALENDARS_DST_NEW : ML_PAGES.CALENDARS_NEW
|
||||
);
|
||||
|
||||
const sorting = {
|
||||
sort: {
|
||||
|
@ -51,7 +54,10 @@ export const CalendarsListTable = ({
|
|||
truncateText: true,
|
||||
scope: 'row',
|
||||
render: (id) => (
|
||||
<Link to={`/${ML_PAGES.CALENDARS_EDIT}/${id}`} data-test-subj="mlEditCalendarLink">
|
||||
<Link
|
||||
to={`/${isDst ? ML_PAGES.CALENDARS_DST_EDIT : ML_PAGES.CALENDARS_EDIT}/${id}`}
|
||||
data-test-subj="mlEditCalendarLink"
|
||||
>
|
||||
{id}
|
||||
</Link>
|
||||
),
|
||||
|
|
|
@ -27,6 +27,24 @@ export function formatEditCalendarUrl(
|
|||
return url;
|
||||
}
|
||||
|
||||
export function formatEditCalendarDstUrl(
|
||||
appBasePath: string,
|
||||
pageState: CalendarEditUrlState['pageState']
|
||||
): string {
|
||||
let url = `${appBasePath}/${ML_PAGES.CALENDARS_DST_EDIT}`;
|
||||
if (pageState) {
|
||||
const { globalState, calendarId } = pageState;
|
||||
if (calendarId !== undefined) {
|
||||
url = `${url}/${calendarId}`;
|
||||
}
|
||||
if (globalState) {
|
||||
url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function formatEditFilterUrl(
|
||||
appBasePath: string,
|
||||
pageState: FilterEditUrlState['pageState']
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
formatGenericMlUrl,
|
||||
formatEditCalendarUrl,
|
||||
formatEditFilterUrl,
|
||||
formatEditCalendarDstUrl,
|
||||
} from './formatters';
|
||||
import {
|
||||
formatTrainedModelsManagementUrl,
|
||||
|
@ -114,7 +115,9 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
|
|||
case ML_PAGES.FILTER_LISTS_MANAGE:
|
||||
case ML_PAGES.FILTER_LISTS_NEW:
|
||||
case ML_PAGES.CALENDARS_MANAGE:
|
||||
case ML_PAGES.CALENDARS_DST_MANAGE:
|
||||
case ML_PAGES.CALENDARS_NEW:
|
||||
case ML_PAGES.CALENDARS_DST_NEW:
|
||||
path = formatGenericMlUrl('', params.page, params.pageState);
|
||||
break;
|
||||
case ML_PAGES.FILTER_LISTS_EDIT:
|
||||
|
@ -122,6 +125,8 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
|
|||
break;
|
||||
case ML_PAGES.CALENDARS_EDIT:
|
||||
path = formatEditCalendarUrl('', params.pageState);
|
||||
case ML_PAGES.CALENDARS_DST_EDIT:
|
||||
path = formatEditCalendarDstUrl('', params.pageState);
|
||||
break;
|
||||
case ML_PAGES.NOTIFICATIONS:
|
||||
path = formatNotificationsUrl('', params.pageState);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue