[8.x] [ML] Daylight saving time calendar events (#193605) (#195262)

# 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![image](https://github.com/user-attachments/assets/9165906f-e571-46be-a5ac-bf7dc9cd2801)\r\n\r\nNew
page for listing DST calendars. The original calendar page does
not\r\nshow DST
calendars.\r\n\r\n![image](https://github.com/user-attachments/assets/32a64a31-b4e5-4516-85fd-19e63aa9d5c4)\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![image](https://github.com/user-attachments/assets/6359192b-faac-4ffb-ad3e-b8193f40f02f)","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![image](https://github.com/user-attachments/assets/9165906f-e571-46be-a5ac-bf7dc9cd2801)\r\n\r\nNew
page for listing DST calendars. The original calendar page does
not\r\nshow DST
calendars.\r\n\r\n![image](https://github.com/user-attachments/assets/32a64a31-b4e5-4516-85fd-19e63aa9d5c4)\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![image](https://github.com/user-attachments/assets/6359192b-faac-4ffb-ad3e-b8193f40f02f)","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![image](https://github.com/user-attachments/assets/9165906f-e571-46be-a5ac-bf7dc9cd2801)\r\n\r\nNew
page for listing DST calendars. The original calendar page does
not\r\nshow DST
calendars.\r\n\r\n![image](https://github.com/user-attachments/assets/32a64a31-b4e5-4516-85fd-19e63aa9d5c4)\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![image](https://github.com/user-attachments/assets/6359192b-faac-4ffb-ad3e-b8193f40f02f)","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:
Kibana Machine 2024-10-08 02:39:56 +11:00 committed by GitHub
parent 19631389a4
commit 8811474e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1144 additions and 390 deletions

View file

@ -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',

View file

@ -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;
};

View file

@ -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

View file

@ -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;
}

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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>
);
});

View file

@ -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,
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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];
}

View file

@ -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];

View file

@ -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`,

View file

@ -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>
);

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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

View file

@ -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,

View file

@ -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,
};

View file

@ -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}
</>
);
};

View file

@ -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",
},
}
}

View file

@ -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: [

View file

@ -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 ? (
<>
&nbsp;
<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 ? (
<>
&nbsp;
<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,
};

View file

@ -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(),

View file

@ -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

View file

@ -9,6 +9,5 @@ import type { FC } from 'react';
declare const NewCalendar: FC<{
calendarId?: string;
canCreateCalendar: boolean;
canDeleteCalendar: boolean;
isDst: boolean;
}>;

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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) {

View file

@ -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)

View file

@ -10,4 +10,5 @@ import type { FC } from 'react';
declare const CalendarsList: FC<{
canCreateCalendar: boolean;
canDeleteCalendar: boolean;
isDst: boolean;
}>;

View file

@ -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>

View file

@ -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', () => {

View file

@ -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);

View file

@ -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(() => {});

View file

@ -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" />
</>
);
};

View file

@ -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>
),

View file

@ -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']

View file

@ -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);