[SR] Add SLM policies list and detail views (#41302) (#41848)

* Add new Policies tab

* Allow config to be passed to common callWithRequestFactory

* Add endpoints to retrieve slm policy(ies)

* add typing and deserialization for policy last success and failure details

* add policy list table

* add basic policy details, link to repository and filtered snapshots

* Add policy details view

* Convert hardcoded links to use navigation service

* link to policy details from snapshot details and change snapshot table filtering logic to exact match

* Address PR feedback
This commit is contained in:
Jen Huang 2019-07-23 21:40:53 -07:00 committed by GitHub
parent eb44e9b99c
commit b9fd8ba33a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1800 additions and 88 deletions

View file

@ -4,4 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { flatten } from './flatten';
export { serializeRestoreSettings } from './restore_settings_serialization';
export {
deserializeRestoreSettings,
serializeRestoreSettings,
} from './restore_settings_serialization';

View file

@ -3,7 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { serializeRestoreSettings } from './restore_settings_serialization';
import {
deserializeRestoreSettings,
serializeRestoreSettings,
} from './restore_settings_serialization';
describe('restore_settings_serialization()', () => {
it('should serialize blank restore settings', () => {
@ -35,6 +38,7 @@ describe('restore_settings_serialization()', () => {
partial: true,
indexSettings: '{"modified_setting":123}',
ignoreIndexSettings: ['setting1'],
ignoreUnavailable: true,
})
).toEqual({
indices: ['foo', 'bar'],
@ -44,6 +48,7 @@ describe('restore_settings_serialization()', () => {
partial: true,
index_settings: { modified_setting: 123 },
ignore_index_settings: ['setting1'],
ignore_unavailable: true,
});
});
@ -54,4 +59,47 @@ describe('restore_settings_serialization()', () => {
})
).toEqual({});
});
it('should deserialize blank restore settings', () => {
expect(deserializeRestoreSettings({})).toEqual({});
});
it('should deserialize partial restore settings', () => {
expect(deserializeRestoreSettings({})).toEqual({});
expect(
deserializeRestoreSettings({
indices: ['foo', 'bar'],
ignore_index_settings: ['setting1'],
partial: true,
})
).toEqual({
indices: ['foo', 'bar'],
ignoreIndexSettings: ['setting1'],
partial: true,
});
});
it('should deserialize full restore settings', () => {
expect(
deserializeRestoreSettings({
indices: ['foo', 'bar'],
rename_pattern: 'capture_pattern',
rename_replacement: 'replacement_pattern',
include_global_state: true,
partial: true,
index_settings: { modified_setting: 123 },
ignore_index_settings: ['setting1'],
ignore_unavailable: true,
})
).toEqual({
indices: ['foo', 'bar'],
renamePattern: 'capture_pattern',
renameReplacement: 'replacement_pattern',
includeGlobalState: true,
partial: true,
indexSettings: '{"modified_setting":123}',
ignoreIndexSettings: ['setting1'],
ignoreUnavailable: true,
});
});
});

View file

@ -23,6 +23,7 @@ export function serializeRestoreSettings(restoreSettings: RestoreSettings): Rest
partial,
indexSettings,
ignoreIndexSettings,
ignoreUnavailable,
} = restoreSettings;
let parsedIndexSettings: RestoreSettingsEs['index_settings'] | undefined;
@ -43,6 +44,33 @@ export function serializeRestoreSettings(restoreSettings: RestoreSettings): Rest
partial,
index_settings: parsedIndexSettings,
ignore_index_settings: ignoreIndexSettings,
ignore_unavailable: ignoreUnavailable,
};
return removeUndefinedSettings(settings);
}
export function deserializeRestoreSettings(restoreSettingsEs: RestoreSettingsEs): RestoreSettings {
const {
indices,
rename_pattern: renamePattern,
rename_replacement: renameReplacement,
include_global_state: includeGlobalState,
partial,
index_settings: indexSettings,
ignore_index_settings: ignoreIndexSettings,
ignore_unavailable: ignoreUnavailable,
} = restoreSettingsEs;
const settings: RestoreSettings = {
indices,
renamePattern,
renameReplacement,
includeGlobalState,
partial,
indexSettings: indexSettings ? JSON.stringify(indexSettings) : undefined,
ignoreIndexSettings,
ignoreUnavailable,
};
return removeUndefinedSettings(settings);

View file

@ -8,3 +8,4 @@ export * from './app';
export * from './repository';
export * from './snapshot';
export * from './restore';
export * from './policy';

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SnapshotConfig, SnapshotConfigEs } from './snapshot';
export interface SlmPolicy {
name: string;
version: number;
modifiedDate: string;
modifiedDateMillis: number;
snapshotName: string;
schedule: string;
repository: string;
config: SnapshotConfig;
nextExecution: string;
nextExecutionMillis: number;
lastSuccess?: {
snapshotName: string;
timeString: string;
time: number;
};
lastFailure?: {
snapshotName: string;
timeString: string;
time: number;
details: object | string;
};
}
export interface SlmPolicyEs {
version: number;
modified_date: string;
modified_date_millis: number;
policy: {
name: string;
schedule: string;
repository: string;
config: SnapshotConfigEs;
};
next_execution: string;
next_execution_millis: number;
last_success?: {
snapshot_name: string;
time_string: string;
time: number;
};
last_failure?: {
snapshot_name: string;
time_string: string;
time: number;
details: string;
};
}

View file

@ -12,6 +12,7 @@ export interface RestoreSettings {
partial?: boolean;
indexSettings?: string;
ignoreIndexSettings?: string[];
ignoreUnavailable?: boolean;
}
export interface RestoreSettingsEs {
@ -22,6 +23,7 @@ export interface RestoreSettingsEs {
partial?: boolean;
index_settings?: { [key: string]: any };
ignore_index_settings?: string[];
ignore_unavailable?: boolean;
}
export interface SnapshotRestore {

View file

@ -3,6 +3,25 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface SnapshotConfig {
indices?: string[];
ignoreUnavailable?: boolean;
includeGlobalState?: boolean;
partial?: boolean;
metadata?: {
[key: string]: string;
};
}
export interface SnapshotConfigEs {
indices?: string[];
ignore_unavailable?: boolean;
include_global_state?: boolean;
partial?: boolean;
metadata?: {
[key: string]: string;
};
}
export interface SnapshotDetails {
repository: string;
@ -23,6 +42,30 @@ export interface SnapshotDetails {
indexFailures: any[];
shards: SnapshotDetailsShardsStatus;
isManagedRepository?: boolean;
policyName?: string;
}
export interface SnapshotDetailsEs {
snapshot: string;
uuid: string;
version_id: number;
version: string;
indices: string[];
include_global_state: boolean;
state: string;
/** e.g. '2019-04-05T21:56:40.438Z' */
start_time: string;
start_time_in_millis: number;
/** e.g. '2019-04-05T21:56:45.210Z' */
end_time: string;
end_time_in_millis: number;
duration_in_millis: number;
failures: any[];
shards: SnapshotDetailsShardsStatusEs;
metadata?: {
policy: string;
[key: string]: any;
};
}
interface SnapshotDetailsShardsStatus {
@ -30,3 +73,9 @@ interface SnapshotDetailsShardsStatus {
failed: number;
successful: number;
}
interface SnapshotDetailsShardsStatusEs {
total: number;
failed: number;
successful: number;
}

View file

@ -106,7 +106,7 @@ export const App: React.FunctionComponent = () => {
);
}
const sections: Section[] = ['repositories', 'snapshots', 'restore_status'];
const sections: Section[] = ['repositories', 'snapshots', 'restore_status', 'policies'];
const sectionsRegex = sections.join('|');
return (

View file

@ -9,9 +9,10 @@ import { useAppDependencies } from '../index';
interface Props {
epochMs: number;
type?: 'date' | 'time';
}
export const FormattedDateTime: React.FunctionComponent<Props> = ({ epochMs }) => {
export const FormattedDateTime: React.FunctionComponent<Props> = ({ epochMs, type }) => {
const {
core: {
i18n: { FormattedDate, FormattedTime },
@ -19,11 +20,16 @@ export const FormattedDateTime: React.FunctionComponent<Props> = ({ epochMs }) =
} = useAppDependencies();
const date = new Date(epochMs);
const formattedDate = <FormattedDate value={date} year="numeric" month="short" day="2-digit" />;
const formattedTime = <FormattedTime value={date} timeZoneName="short" />;
if (type) {
return type === 'date' ? formattedDate : formattedTime;
}
return (
<Fragment>
<FormattedDate value={date} year="numeric" month="short" day="2-digit" />{' '}
<FormattedTime value={date} timeZoneName="short" />
{formattedDate} {formattedTime}
</Fragment>
);
};

View file

@ -6,7 +6,7 @@
export const BASE_PATH = '/management/elasticsearch/snapshot_restore';
export const DEFAULT_SECTION: Section = 'snapshots';
export type Section = 'repositories' | 'snapshots' | 'restore_status';
export type Section = 'repositories' | 'snapshots' | 'restore_status' | 'policies';
// Set a minimum request duration to avoid strange UI flickers
export const MINIMUM_TIMEOUT_MS = 300;
@ -105,3 +105,7 @@ export const UIM_SNAPSHOT_DELETE_MANY = 'snapshot_delete_many';
export const UIM_RESTORE_CREATE = 'restore_create';
export const UIM_RESTORE_LIST_LOAD = 'restore_list_load';
export const UIM_RESTORE_LIST_EXPAND_INDEX = 'restore_list_expand_index';
export const UIM_POLICY_LIST_LOAD = 'policy_list_load';
export const UIM_POLICY_SHOW_DETAILS_CLICK = 'policy_show_details_click';
export const UIM_POLICY_DETAIL_PANEL_SUMMARY_TAB = 'policy_detail_panel_summary_tab';
export const UIM_POLICY_DETAIL_PANEL_HISTORY_TAB = 'policy_detail_panel_last_success_tab';

View file

@ -27,6 +27,7 @@ import { breadcrumbService } from '../../services/navigation';
import { RepositoryList } from './repository_list';
import { SnapshotList } from './snapshot_list';
import { RestoreList } from './restore_list';
import { PolicyList } from './policy_list';
import { documentationLinksService } from '../../services/documentation';
interface MatchParams {
@ -67,6 +68,15 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
/>
),
},
{
id: 'policies',
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.policiesTabTitle"
defaultMessage="Policies"
/>
),
},
{
id: 'restore_status',
name: (
@ -158,6 +168,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
component={SnapshotList}
/>
<Route exact path={`${BASE_PATH}/restore_status`} component={RestoreList} />
<Route exact path={`${BASE_PATH}/policies/:policyName*`} component={PolicyList} />
</Switch>
</EuiPageContent>
</EuiPageBody>

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './snapshot';
export { PolicyList } from './policy_list';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { PolicyDetails } from './policy_details';

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
EuiTabs,
EuiTab,
} from '@elastic/eui';
import { SlmPolicy } from '../../../../../../common/types';
import { useAppDependencies } from '../../../../index';
import {
UIM_POLICY_DETAIL_PANEL_SUMMARY_TAB,
UIM_POLICY_DETAIL_PANEL_HISTORY_TAB,
} from '../../../../constants';
import { useLoadPolicy } from '../../../../services/http';
import { uiMetricService } from '../../../../services/ui_metric';
import { SectionError, SectionLoading } from '../../../../components';
import { TabSummary, TabHistory } from './tabs';
interface Props {
policyName: SlmPolicy['name'];
onClose: () => void;
}
const TAB_SUMMARY = 'summary';
const TAB_HISTORY = 'success';
const tabToUiMetricMap: { [key: string]: string } = {
[TAB_SUMMARY]: UIM_POLICY_DETAIL_PANEL_SUMMARY_TAB,
[TAB_HISTORY]: UIM_POLICY_DETAIL_PANEL_HISTORY_TAB,
};
export const PolicyDetails: React.FunctionComponent<Props> = ({ policyName, onClose }) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { trackUiMetric } = uiMetricService;
const { error, data: policyDetails } = useLoadPolicy(policyName);
const [activeTab, setActiveTab] = useState<string>(TAB_SUMMARY);
// Reset tab when we look at a different policy
useEffect(() => {
setActiveTab(TAB_SUMMARY);
}, [policyName]);
const tabOptions = [
{
id: TAB_SUMMARY,
name: (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.summaryTabTitle"
defaultMessage="Summary"
/>
),
},
{
id: TAB_HISTORY,
name: (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.historyTabTitle"
defaultMessage="History"
/>
),
},
];
const renderTabs = () => (
<EuiTabs>
{tabOptions.map(tab => (
<EuiTab
onClick={() => {
trackUiMetric(tabToUiMetricMap[tab.id]);
setActiveTab(tab.id);
}}
isSelected={tab.id === activeTab}
key={tab.id}
data-test-subj="tab"
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
);
const renderBody = () => {
if (policyDetails) {
const { policy } = policyDetails;
switch (activeTab) {
case TAB_HISTORY:
return <TabHistory policy={policy} />;
default:
return <TabSummary policy={policy} />;
}
}
if (error) {
return renderError();
}
return renderLoading();
};
const renderLoading = () => {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.loadingPolicyDescription"
defaultMessage="Loading policy…"
/>
</SectionLoading>
);
};
const renderError = () => {
const notFound = error.status === 404;
const errorObject = notFound
? {
data: {
error: i18n.translate(
'xpack.snapshotRestore.policyDetails.policyNotFoundErrorMessage',
{
defaultMessage: `The policy '{name}' does not exist.`,
values: {
name: policyName,
},
}
),
},
}
: error;
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.loadingPolicyErrorTitle"
defaultMessage="Error loading policy"
/>
}
error={errorObject}
/>
);
};
const renderFooter = () => {
return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="srPolicyDetailsFlyoutCloseButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<EuiFlyout
onClose={onClose}
data-test-subj="policyDetail"
aria-labelledby="srPolicyDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="srPolicyDetailsFlyoutTitle" data-test-subj="title">
{policyName}
</h2>
</EuiTitle>
{renderTabs()}
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="content">{renderBody()}</EuiFlyoutBody>
<EuiFlyoutFooter>{renderFooter()}</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TabSummary } from './tab_summary';
export { TabHistory } from './tab_history';

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiCodeEditor,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiTitle,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiText,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { SlmPolicy } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
import { FormattedDateTime } from '../../../../../components';
import { linkToSnapshot } from '../../../../../services/navigation';
interface Props {
policy: SlmPolicy;
}
export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { lastSuccess, lastFailure, nextExecutionMillis, name, repository } = policy;
const renderLastSuccess = () => {
if (!lastSuccess) {
return null;
}
const { time, snapshotName } = lastSuccess;
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastSuccessTitle"
defaultMessage="Last successful snapshot"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="successTime">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastSuccess.timeLabel"
defaultMessage="Succeeded on"
description="Title for date time. Example: Succeeded on Jul 16, 2019 6:30 PM PDT"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<FormattedDateTime epochMs={time} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="successSnapshot">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastSuccess.snapshotNameLabel"
defaultMessage="Snapshot name"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiLink href={linkToSnapshot(repository, snapshotName)}>{snapshotName}</EuiLink>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
</Fragment>
);
};
const renderLastFailure = () => {
if (!lastFailure) {
return null;
}
const { time, snapshotName, details } = lastFailure;
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailureTitle"
defaultMessage="Last snapshot failure"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="failureTime">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.timeLabel"
defaultMessage="Failed on"
description="Title for date time. Example: Failed on Jul 16, 2019 6:30 PM PDT"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<FormattedDateTime epochMs={time} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="failureSnapshot">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.snapshotNameLabel"
defaultMessage="Snapshot name"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{snapshotName}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="failureDetails">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.detailsLabel"
defaultMessage="Failure details"
/>
</EuiDescriptionListTitle>
<EuiSpacer size="s" />
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
isReadOnly
value={JSON.stringify(details, null, 2)}
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
minLines={6}
maxLines={6}
showGutter={false}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.detailsAriaLabel"
defaultMessage="Last failure details for policy '{name}'"
values={{
name,
}}
/>
}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
</Fragment>
);
};
return lastSuccess || lastFailure ? (
<Fragment>
{renderLastSuccess()}
{lastSuccess && lastFailure ? <EuiHorizontalRule /> : null}
{renderLastFailure()}
</Fragment>
) : (
<EuiText>
<p>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.noHistoryMessage"
defaultMessage="This policy has not been executed yet. It will automatically run on {date} at {time}."
values={{
date: <FormattedDateTime epochMs={nextExecutionMillis} type="date" />,
time: <FormattedDateTime epochMs={nextExecutionMillis} type="time" />,
}}
/>
</p>
</EuiText>
);
};

View file

@ -0,0 +1,280 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiTitle,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiIcon,
EuiText,
} from '@elastic/eui';
import { SlmPolicy } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
import { FormattedDateTime } from '../../../../../components';
import { linkToSnapshots, linkToRepository } from '../../../../../services/navigation';
interface Props {
policy: SlmPolicy;
}
export const TabSummary: React.FunctionComponent<Props> = ({ policy }) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const {
version,
name,
modifiedDateMillis,
snapshotName,
repository,
schedule,
nextExecutionMillis,
config,
} = policy;
const { includeGlobalState, ignoreUnavailable, indices, partial } = config;
// Only show 10 indices initially
const [isShowingFullIndicesList, setIsShowingFullIndicesList] = useState<boolean>(false);
const hiddenIndicesCount = indices && indices.length > 10 ? indices.length - 10 : 0;
const shortIndicesList =
indices && indices.length ? (
<ul>
{[...indices].splice(0, 10).map((index: string) => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
))}
{hiddenIndicesCount ? (
<li key="hiddenIndicesCount">
<EuiTitle size="xs">
<EuiLink onClick={() => setIsShowingFullIndicesList(true)}>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.indicesShowAllLink"
defaultMessage="Show {count} more {count, plural, one {index} other {indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowDown" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.allIndicesLabel"
defaultMessage="All indices"
/>
);
const fullIndicesList =
indices && indices.length && indices.length > 10 ? (
<ul>
{indices.map((index: string) => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
))}
{hiddenIndicesCount ? (
<li key="hiddenIndicesCount">
<EuiTitle size="xs">
<EuiLink onClick={() => setIsShowingFullIndicesList(false)}>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.indicesCollapseAllLink"
defaultMessage="Hide {count, plural, one {# index} other {# indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowUp" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
) : null;
return (
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="version">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.versionLabel"
defaultMessage="Version"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{version}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="modified">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.modifiedDateLabel"
defaultMessage="Last modified"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<FormattedDateTime epochMs={modifiedDateMillis} />
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="name">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.snapshotNameLabel"
defaultMessage="Snapshot name"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiLink href={linkToSnapshots(undefined, name)}>{snapshotName}</EuiLink>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="repository">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.repositoryLabel"
defaultMessage="Repository"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiLink href={linkToRepository(repository)}>{repository}</EuiLink>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="schedule">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.scheduleLabel"
defaultMessage="Schedule"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{schedule}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="execution">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.nextExecutionLabel"
defaultMessage="Next execution"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<FormattedDateTime epochMs={nextExecutionMillis} />
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="indices">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.indicesLabel"
defaultMessage="Indices"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiText>{isShowingFullIndicesList ? fullIndicesList : shortIndicesList}</EuiText>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="includeGlobalState">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.ignoreUnavailableLabel"
defaultMessage="Ignore unavailable indices"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{ignoreUnavailable ? (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.ignoreUnavailableTrueLabel"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.ignoreUnavailableFalseLabel"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="partial">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.partialLabel"
defaultMessage="Allow partial shards"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{partial ? (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.partialTrueLabel"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.partialFalseLabel"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="includeGlobalState">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.includeGlobalStateLabel"
defaultMessage="Include global state"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{includeGlobalState === false ? (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.includeGlobalStateFalseLabel"
defaultMessage="No"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.includeGlobalStateTrueLabel"
defaultMessage="Yes"
/>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
);
};

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiEmptyPrompt } from '@elastic/eui';
import { SlmPolicy } from '../../../../../common/types';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadPolicies } from '../../../services/http';
import { uiMetricService } from '../../../services/ui_metric';
import { PolicyDetails } from './policy_details';
import { PolicyTable } from './policy_table';
interface MatchParams {
policyName?: SlmPolicy['name'];
}
export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { policyName },
},
history,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
error,
loading,
data: { policies } = {
policies: undefined,
},
request: reload,
} = useLoadPolicies();
const openPolicyDetailsUrl = (newPolicyName: SlmPolicy['name']): string => {
return history.createHref({
pathname: `${BASE_PATH}/policies/${newPolicyName}`,
});
};
const closePolicyDetails = () => {
history.push(`${BASE_PATH}/policies`);
};
// Track component loaded
const { trackUiMetric } = uiMetricService;
useEffect(() => {
trackUiMetric(UIM_POLICY_LIST_LOAD);
}, []);
let content;
if (loading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.policyList.loadingPoliciesDescription"
defaultMessage="Loading policies…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.policyList.LoadingPoliciesErrorMessage"
defaultMessage="Error loading policies"
/>
}
error={error}
/>
);
} else if (policies && policies.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.policyList.emptyPromptTitle"
defaultMessage="You don't have any snapshot policies yet"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.policyList.emptyPromptDescription"
defaultMessage="Use policies to schedule automatic backups of your cluster."
/>
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
} else {
content = (
<PolicyTable
policies={policies || []}
reload={reload}
openPolicyDetailsUrl={openPolicyDetailsUrl}
/>
);
}
return (
<section data-test-subj="policyList">
{policyName ? <PolicyDetails policyName={policyName} onClose={closePolicyDetails} /> : null}
{content}
</section>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { PolicyTable } from './policy_table';

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiLink } from '@elastic/eui';
import { SlmPolicy } from '../../../../../../common/types';
import { UIM_POLICY_SHOW_DETAILS_CLICK } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { FormattedDateTime } from '../../../../components';
import { uiMetricService } from '../../../../services/ui_metric';
interface Props {
policies: SlmPolicy[];
reload: () => Promise<void>;
openPolicyDetailsUrl: (name: SlmPolicy['name']) => string;
}
export const PolicyTable: React.FunctionComponent<Props> = ({
policies,
reload,
openPolicyDetailsUrl,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { trackUiMetric } = uiMetricService;
const columns = [
{
field: 'name',
name: i18n.translate('xpack.snapshotRestore.policyList.table.policyNameColumnTitle', {
defaultMessage: 'Policy',
}),
truncateText: true,
sortable: true,
render: (name: SlmPolicy['name']) => {
return (
<EuiLink
onClick={() => trackUiMetric(UIM_POLICY_SHOW_DETAILS_CLICK)}
href={openPolicyDetailsUrl(name)}
data-test-subj="policyLink"
>
{name}
</EuiLink>
);
},
},
{
field: 'snapshotName',
name: i18n.translate('xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle', {
defaultMessage: 'Snapshot name',
}),
truncateText: true,
sortable: true,
},
{
field: 'repository',
name: i18n.translate('xpack.snapshotRestore.policyList.table.repositoryColumnTitle', {
defaultMessage: 'Repository',
}),
truncateText: true,
sortable: true,
},
{
field: 'schedule',
name: i18n.translate('xpack.snapshotRestore.policyList.table.scheduleColumnTitle', {
defaultMessage: 'Schedule',
}),
truncateText: true,
sortable: true,
},
{
field: 'nextExecutionMillis',
name: i18n.translate('xpack.snapshotRestore.policyList.table.nextExecutionColumnTitle', {
defaultMessage: 'Next execution',
}),
truncateText: true,
sortable: true,
render: (nextExecutionMillis: SlmPolicy['nextExecutionMillis']) => (
<FormattedDateTime epochMs={nextExecutionMillis} />
),
},
];
const sorting = {
sort: {
field: 'name',
direction: 'asc',
},
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50],
};
const search = {
toolsRight: (
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
<EuiFlexItem>
<EuiButton
color="secondary"
iconType="refresh"
onClick={reload}
data-test-subj="reloadButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyList.table.reloadPoliciesButton"
defaultMessage="Reload"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
box: {
incremental: true,
schema: true,
},
filters: [
{
type: 'field_value_selection',
field: 'repository',
name: i18n.translate('xpack.snapshotRestore.policyList.table.repositoryFilterLabel', {
defaultMessage: 'Repository',
}),
multiSelect: false,
options: Object.keys(
policies.reduce((repositoriesMap: any, policy) => {
repositoriesMap[policy.repository] = true;
return repositoriesMap;
}, {})
).map(repository => {
return {
value: repository,
view: repository,
};
}),
},
],
};
return (
<EuiInMemoryTable
items={policies}
itemId="name"
columns={columns}
search={search}
sorting={sorting}
pagination={pagination}
isSelectable={true}
rowProps={() => ({
'data-test-subj': 'row',
})}
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="policyTable"
/>
);
};

View file

@ -30,7 +30,7 @@ import {
verifyRepository as verifyRepositoryRequest,
} from '../../../../services/http';
import { textService } from '../../../../services/text';
import { linkToSnapshots } from '../../../../services/navigation';
import { linkToSnapshots, linkToEditRepository } from '../../../../services/navigation';
import { REPOSITORY_TYPES } from '../../../../../../common/constants';
import { Repository, RepositoryVerification } from '../../../../../../common/types';
@ -40,7 +40,6 @@ import {
SectionLoading,
RepositoryVerificationBadge,
} from '../../../../components';
import { BASE_PATH } from '../../../../constants';
import { TypeDetails } from './type_details';
interface Props {
@ -371,11 +370,7 @@ export const RepositoryDetails: React.FunctionComponent<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
href={`#${BASE_PATH}/edit_repository/${repositoryName}`}
fill
color="primary"
>
<EuiButton href={linkToEditRepository(repositoryName)} fill color="primary">
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.editButtonLabel"
defaultMessage="Edit"

View file

@ -19,10 +19,11 @@ import {
import { REPOSITORY_TYPES } from '../../../../../../common/constants';
import { Repository, RepositoryType } from '../../../../../../common/types';
import { RepositoryDeleteProvider } from '../../../../components';
import { BASE_PATH, UIM_REPOSITORY_SHOW_DETAILS_CLICK } from '../../../../constants';
import { UIM_REPOSITORY_SHOW_DETAILS_CLICK } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { textService } from '../../../../services/text';
import { uiMetricService } from '../../../../services/ui_metric';
import { linkToEditRepository, linkToAddRepository } from '../../../../services/navigation';
interface Props {
repositories: Repository[];
@ -115,7 +116,7 @@ export const RepositoryTable: React.FunctionComponent<Props> = ({
)}
iconType="pencil"
color="primary"
href={`#${BASE_PATH}/edit_repository/${name}`}
href={linkToEditRepository(name)}
data-test-subj="editRepositoryButton"
/>
</EuiToolTip>
@ -249,7 +250,7 @@ export const RepositoryTable: React.FunctionComponent<Props> = ({
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
href={`#${BASE_PATH}/add_repository`}
href={linkToAddRepository()}
fill
iconType="plusInCircle"
data-test-subj="registerRepositoryButton"
@ -302,8 +303,8 @@ export const RepositoryTable: React.FunctionComponent<Props> = ({
rowProps={() => ({
'data-test-subj': 'row',
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `cell`,
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="repositoryTable"
/>

View file

@ -18,11 +18,12 @@ import {
EuiLink,
} from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components';
import { UIM_RESTORE_LIST_LOAD, BASE_PATH } from '../../../constants';
import { UIM_RESTORE_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadRestores } from '../../../services/http';
import { useAppState } from '../../../services/state';
import { uiMetricService } from '../../../services/ui_metric';
import { linkToSnapshots } from '../../../services/navigation';
import { RestoreTable } from './restore_table';
const ONE_SECOND_MS = 1000;
@ -136,7 +137,7 @@ export const RestoreList: React.FunctionComponent = () => {
defaultMessage="Go to {snapshotsLink} to start a restore."
values={{
snapshotsLink: (
<EuiLink href={`#${BASE_PATH}/snapshots`}>
<EuiLink href={linkToSnapshots()}>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescriptionLink"
defaultMessage="Snapshots"

View file

@ -227,8 +227,8 @@ export const RestoreTable: React.FunctionComponent<Props> = ({ restores }) => {
'data-test-subj': 'row',
onClick: () => toggleIndexRestoreDetails(restore),
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `cell`,
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="restoresTable"
/>

View file

@ -22,16 +22,16 @@ import {
} from '@elastic/eui';
import React, { Fragment, useState, useEffect } from 'react';
import { SnapshotDetails as ISnapshotDetails } from '../../../../../../common/types';
import { SectionError, SectionLoading, SnapshotDeleteProvider } from '../../../../components';
import { useAppDependencies } from '../../../../index';
import {
BASE_PATH,
UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB,
UIM_SNAPSHOT_DETAIL_PANEL_FAILED_INDICES_TAB,
SNAPSHOT_STATE,
} from '../../../../constants';
import { useLoadSnapshot } from '../../../../services/http';
import { linkToRepository } from '../../../../services/navigation';
import { linkToRepository, linkToRestoreSnapshot } from '../../../../services/navigation';
import { uiMetricService } from '../../../../services/ui_metric';
import { TabSummary, TabFailures } from './tabs';
@ -75,7 +75,7 @@ export const SnapshotDetails: React.FunctionComponent<Props> = ({
let content;
if (snapshotDetails) {
const { indexFailures, state: snapshotState } = snapshotDetails;
const { indexFailures, state: snapshotState } = snapshotDetails as ISnapshotDetails;
const tabOptions = [
{
id: TAB_SUMMARY,
@ -221,7 +221,7 @@ export const SnapshotDetails: React.FunctionComponent<Props> = ({
<EuiFlexItem grow={false}>
<EuiButton
href={`#${BASE_PATH}/restore/${repositoryName}/${snapshotId}`}
href={linkToRestoreSnapshot(repositoryName, snapshotId)}
fill
color="primary"
isDisabled={

View file

@ -19,13 +19,15 @@ import {
EuiIcon,
} from '@elastic/eui';
import { SnapshotDetails } from '../../../../../../../common/types';
import { SNAPSHOT_STATE } from '../../../../../constants';
import { useAppDependencies } from '../../../../../index';
import { DataPlaceholder, FormattedDateTime } from '../../../../../components';
import { linkToPolicy } from '../../../../../services/navigation';
import { SnapshotState } from './snapshot_state';
interface Props {
snapshotDetails: any;
snapshotDetails: SnapshotDetails;
}
export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
@ -47,6 +49,7 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
endTimeInMillis,
durationInMillis,
uuid,
policyName,
} = snapshotDetails;
// Only show 10 indices initially
@ -253,6 +256,21 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
{policyName ? (
<EuiFlexItem data-test-subj="policy">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.createdByLabel"
defaultMessage="Created by"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiLink href={linkToPolicy(policyName)}>{policyName}</EuiLink>
</EuiDescriptionListDescription>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiDescriptionList>
);

View file

@ -78,13 +78,19 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
}
};
// Allow deeplinking to list pre-filtered by repository name
// Allow deeplinking to list pre-filtered by repository name or by policy name
const [filteredRepository, setFilteredRepository] = useState<string | undefined>(undefined);
const [filteredPolicy, setFilteredPolicy] = useState<string | undefined>(undefined);
useEffect(() => {
if (search) {
const parsedParams = parse(search.replace(/^\?/, ''));
if (parsedParams.repository && parsedParams.repository !== filteredRepository) {
setFilteredRepository(String(parsedParams.repository));
const { repository, policy } = parsedParams;
if (policy && policy !== filteredPolicy) {
setFilteredPolicy(String(policy));
history.replace(`${BASE_PATH}/snapshots`);
} else if (repository && repository !== filteredRepository) {
setFilteredRepository(String(repository));
history.replace(`${BASE_PATH}/snapshots`);
}
}
@ -287,6 +293,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
openSnapshotDetailsUrl={openSnapshotDetailsUrl}
onSnapshotDeleted={onSnapshotDeleted}
repositoryFilter={filteredRepository}
policyFilter={filteredPolicy}
/>
</Fragment>
);

View file

@ -16,9 +16,9 @@ import {
} from '@elastic/eui';
import { SnapshotDetails } from '../../../../../../common/types';
import { BASE_PATH, SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants';
import { SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { linkToRepository } from '../../../../services/navigation';
import { linkToRepository, linkToRestoreSnapshot } from '../../../../services/navigation';
import { uiMetricService } from '../../../../services/ui_metric';
import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components';
@ -28,6 +28,7 @@ interface Props {
reload: () => Promise<void>;
openSnapshotDetailsUrl: (repositoryName: string, snapshotId: string) => string;
repositoryFilter?: string;
policyFilter?: string;
onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void;
}
@ -38,6 +39,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
openSnapshotDetailsUrl,
onSnapshotDeleted,
repositoryFilter,
policyFilter,
}) => {
const {
core: { i18n },
@ -181,7 +183,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
iconType="importAction"
color="primary"
data-test-subj="srsnapshotListRestoreActionButton"
href={`#${BASE_PATH}/restore/${repository}/${snapshot}`}
href={linkToRestoreSnapshot(repository, snapshot)}
isDisabled={!canRestore}
/>
</EuiToolTip>
@ -253,6 +255,9 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
repository: {
type: 'string',
},
policyName: {
type: 'string',
},
},
};
@ -336,8 +341,15 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
})),
},
],
defaultQuery: repositoryFilter
? Query.parse(`repository:'${repositoryFilter}'`, {
defaultQuery: policyFilter
? Query.parse(`policyName="${policyFilter}"`, {
schema: {
...searchSchema,
strict: true,
},
})
: repositoryFilter
? Query.parse(`repository="${repositoryFilter}"`, {
schema: {
...searchSchema,
strict: true,
@ -359,7 +371,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
rowProps={() => ({
'data-test-subj': 'row',
})}
cellProps={(item: any, column: any) => ({
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="snapshotTable"

View file

@ -8,3 +8,4 @@ export * from './app_requests';
export * from './repository_requests';
export * from './snapshot_requests';
export * from './restore_requests';
export * from './policy_requests';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from '../../../../common/constants';
import { SlmPolicy } from '../../../../common/types';
import { httpService } from './http';
import { useRequest } from './use_request';
export const useLoadPolicies = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}policies`),
method: 'get',
});
};
export const useLoadPolicy = (name: SlmPolicy['name']) => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}policy/${encodeURIComponent(name)}`),
method: 'get',
});
};

View file

@ -5,4 +5,4 @@
*/
export { breadcrumbService } from './breadcrumb';
export { linkToRepository, linkToRepositories, linkToSnapshots } from './links';
export * from './links';

View file

@ -14,9 +14,36 @@ export function linkToRepository(repositoryName: string) {
return `#${BASE_PATH}/repositories/${encodeURIComponent(repositoryName)}`;
}
export function linkToSnapshots(repositoryName?: string) {
export function linkToEditRepository(repositoryName: string) {
return `#${BASE_PATH}/edit_repository/${encodeURIComponent(repositoryName)}`;
}
export function linkToAddRepository() {
return `#${BASE_PATH}/add_repository`;
}
export function linkToSnapshots(repositoryName?: string, policyName?: string) {
if (repositoryName) {
return `#${BASE_PATH}/snapshots?repository=${repositoryName}`;
return `#${BASE_PATH}/snapshots?repository=${encodeURIComponent(repositoryName)}`;
}
if (policyName) {
return `#${BASE_PATH}/snapshots?policy=${encodeURIComponent(policyName)}`;
}
return `#${BASE_PATH}/snapshots`;
}
export function linkToSnapshot(repositoryName: string, snapshotName: string) {
return `#${BASE_PATH}/snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(
snapshotName
)}`;
}
export function linkToRestoreSnapshot(repositoryName: string, snapshotName: string) {
return `#${BASE_PATH}/restore/${encodeURIComponent(repositoryName)}/${encodeURIComponent(
snapshotName
)}`;
}
export function linkToPolicy(policyName: string) {
return `#${BASE_PATH}/policies/${encodeURIComponent(policyName)}`;
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => {
const ca = components.clientAction.factory;
Client.prototype.slm = components.clientAction.namespaceFactory();
const slm = Client.prototype.slm.prototype;
slm.policies = ca({
urls: [
{
fmt: '/_slm/policy',
},
],
method: 'GET',
});
slm.policy = ca({
urls: [
{
fmt: '/_slm/policy/<%=name%>',
req: {
name: {
type: 'string',
},
},
},
],
method: 'GET',
});
};

View file

@ -9,6 +9,7 @@ export {
serializeRepositorySettings,
} from './repository_serialization';
export { cleanSettings } from './clean_settings';
export { deserializeSnapshotDetails } from './snapshot_serialization';
export { deserializeSnapshotDetails, deserializeSnapshotConfig } from './snapshot_serialization';
export { deserializeRestoreShard } from './restore_serialization';
export { getManagedRepositoryName } from './get_managed_repository_name';
export { deserializePolicy } from './policy_serialization';

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { deserializePolicy } from './policy_serialization';
describe('repository_serialization', () => {
describe('deserializePolicy()', () => {
it('should deserialize a new slm policy', () => {
expect(
deserializePolicy('my-backups-snapshots', {
version: 1,
modified_date: '2019-07-09T22:11:55.761Z',
modified_date_millis: 1562710315761,
policy: {
name: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: {
indices: ['kibana-*'],
ignore_unavailable: false,
include_global_state: false,
metadata: {
foo: 'bar',
},
},
},
next_execution: '2019-07-11T01:30:00.000Z',
next_execution_millis: 1562722200000,
})
).toEqual({
name: 'my-backups-snapshots',
version: 1,
modifiedDate: '2019-07-09T22:11:55.761Z',
modifiedDateMillis: 1562710315761,
snapshotName: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: {
indices: ['kibana-*'],
includeGlobalState: false,
ignoreUnavailable: false,
metadata: {
foo: 'bar',
},
},
nextExecution: '2019-07-11T01:30:00.000Z',
nextExecutionMillis: 1562722200000,
});
});
it('should deserialize a slm policy with success and failure info', () => {
expect(
deserializePolicy('my-backups-snapshots', {
version: 1,
modified_date: '2019-07-09T22:11:55.761Z',
modified_date_millis: 1562710315761,
policy: {
name: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: {
indices: ['kibana-*'],
ignore_unavailable: false,
include_global_state: false,
},
},
next_execution: '2019-07-11T01:30:00.000Z',
next_execution_millis: 1562722200000,
last_success: {
snapshot_name: 'daily-snap-2019.07.10-ya_cajvksbcidtlbnnxt9q',
time_string: '2019-07-10T01:30:02.548Z',
time: 1562722202548,
},
last_failure: {
snapshot_name: 'daily-snap-2019.07.10-cvi4m0uts5knejcrgq4qxq',
time_string: '2019-07-10T01:30:02.443Z',
time: 1562722202443,
details: `{"type":"concurrent_snapshot_execution_exception",
"reason":"[my-backups:daily-snap-2019.07.10-cvi4m0uts5knejcrgq4qxq] a snapshot is already running",
"stack_trace":"Some stack trace"}`,
},
})
).toEqual({
name: 'my-backups-snapshots',
version: 1,
modifiedDate: '2019-07-09T22:11:55.761Z',
modifiedDateMillis: 1562710315761,
snapshotName: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: { indices: ['kibana-*'], includeGlobalState: false, ignoreUnavailable: false },
nextExecution: '2019-07-11T01:30:00.000Z',
nextExecutionMillis: 1562722200000,
lastFailure: {
details: {
reason:
'[my-backups:daily-snap-2019.07.10-cvi4m0uts5knejcrgq4qxq] a snapshot is already running',
stack_trace: 'Some stack trace',
type: 'concurrent_snapshot_execution_exception',
},
snapshotName: 'daily-snap-2019.07.10-cvi4m0uts5knejcrgq4qxq',
time: 1562722202443,
timeString: '2019-07-10T01:30:02.443Z',
},
lastSuccess: {
snapshotName: 'daily-snap-2019.07.10-ya_cajvksbcidtlbnnxt9q',
time: 1562722202548,
timeString: '2019-07-10T01:30:02.548Z',
},
});
});
});
});

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SlmPolicy, SlmPolicyEs } from '../../common/types';
import { deserializeSnapshotConfig } from './';
export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolicy => {
const {
version,
modified_date: modifiedDate,
modified_date_millis: modifiedDateMillis,
policy: { name: snapshotName, schedule, repository, config },
next_execution: nextExecution,
next_execution_millis: nextExecutionMillis,
last_failure: lastFailure,
last_success: lastSuccess,
} = esPolicy;
const policy: SlmPolicy = {
name,
version,
modifiedDate,
modifiedDateMillis,
snapshotName,
schedule,
repository,
config: deserializeSnapshotConfig(config),
nextExecution,
nextExecutionMillis,
};
if (lastFailure) {
const {
snapshot_name: failureSnapshotName,
time: failureTime,
time_string: failureTimeString,
details: failureDetails,
} = lastFailure;
let jsonFailureDetails;
try {
jsonFailureDetails = JSON.parse(failureDetails);
} catch (e) {
// silently swallow json parsing error
// we don't expect ES to return unparsable json
}
policy.lastFailure = {
snapshotName: failureSnapshotName,
time: failureTime,
timeString: failureTimeString,
details: jsonFailureDetails || failureDetails,
};
}
if (lastSuccess) {
const {
snapshot_name: successSnapshotName,
time: successTime,
time_string: successTimeString,
} = lastSuccess;
policy.lastSuccess = {
snapshotName: successSnapshotName,
time: successTime,
timeString: successTimeString,
};
}
return policy;
};

View file

@ -6,8 +6,12 @@
import { sortBy } from 'lodash';
import { SnapshotDetails } from '../../common/types';
import { SnapshotDetailsEs } from '../types';
import {
SnapshotDetails,
SnapshotDetailsEs,
SnapshotConfig,
SnapshotConfigEs,
} from '../../common/types';
export function deserializeSnapshotDetails(
repository: string,
@ -33,6 +37,7 @@ export function deserializeSnapshotDetails(
duration_in_millis: durationInMillis,
failures = [],
shards,
metadata: { policy: policyName } = { policy: undefined },
} = snapshotDetailsEs;
// If an index has multiple failures, we'll want to see them grouped together.
@ -60,7 +65,7 @@ export function deserializeSnapshotDetails(
// Sort by index name.
const indexFailures = sortBy(Object.values(indexToFailuresMap), ({ index }) => index);
return {
const snapshotDetails: SnapshotDetails = {
repository,
snapshot,
uuid,
@ -78,4 +83,34 @@ export function deserializeSnapshotDetails(
shards,
isManagedRepository: repository === managedRepository,
};
if (policyName) {
snapshotDetails.policyName = policyName;
}
return snapshotDetails;
}
export function deserializeSnapshotConfig(snapshotConfigEs: SnapshotConfigEs): SnapshotConfig {
const {
indices,
ignore_unavailable: ignoreUnavailable,
include_global_state: includeGlobalState,
partial,
metadata,
} = snapshotConfigEs;
const snapshotConfig: SnapshotConfig = {
indices,
ignoreUnavailable,
includeGlobalState,
partial,
metadata,
};
return Object.entries(snapshotConfig).reduce((config: any, [key, value]) => {
if (value !== undefined) {
config[key] = value;
}
return config;
}, {});
}

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Request, ResponseToolkit } from 'hapi';
import { getAllHandler, getOneHandler } from './policy';
describe('[Snapshot and Restore API Routes] Restore', () => {
const mockRequest = {} as Request;
const mockResponseToolkit = {} as ResponseToolkit;
const mockEsPolicy = {
version: 1,
modified_date_millis: 1562710315761,
policy: {
name: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: {},
},
next_execution_millis: 1562722200000,
};
const mockPolicy = {
version: 1,
modifiedDateMillis: 1562710315761,
snapshotName: '<daily-snap-{now/d}>',
schedule: '0 30 1 * * ?',
repository: 'my-backups',
config: {},
nextExecutionMillis: 1562722200000,
};
describe('getAllHandler()', () => {
it('should arrify policies returned from ES', async () => {
const mockEsResponse = {
fooPolicy: mockEsPolicy,
barPolicy: mockEsPolicy,
};
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = {
policies: [
{
name: 'fooPolicy',
...mockPolicy,
},
{
name: 'barPolicy',
...mockPolicy,
},
],
};
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return empty array if no repositories returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = { policies: [] };
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('getOneHandler()', () => {
const name = 'fooPolicy';
const mockOneRequest = ({
params: {
name,
},
} as unknown) as Request;
it('should return policy if returned from ES', async () => {
const mockEsResponse = {
[name]: mockEsPolicy,
};
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockResolvedValueOnce({});
const expectedResponse = {
policy: {
name,
...mockPolicy,
},
};
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return 404 error if not returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockResolvedValueOnce({});
await expect(
getOneHandler(mockRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers';
import { SlmPolicyEs, SlmPolicy } from '../../../common/types';
import { deserializePolicy } from '../../lib';
export function registerPolicyRoutes(router: Router) {
router.get('policies', getAllHandler);
router.get('policy/{name}', getOneHandler);
}
export const getAllHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
policies: SlmPolicy[];
}> => {
// Get policies
const policiesByName: {
[key: string]: SlmPolicyEs;
} = await callWithRequest('slm.policies', {
human: true,
});
// Deserialize policies
return {
policies: Object.entries(policiesByName).map(([name, policy]) =>
deserializePolicy(name, policy)
),
};
};
export const getOneHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
policy: SlmPolicy;
}> => {
// Get policy
const { name } = req.params;
const policiesByName: {
[key: string]: SlmPolicyEs;
} = await callWithRequest('slm.policy', {
name,
human: true,
});
if (!policiesByName[name]) {
// If policy doesn't exist, ES will return 200 with an empty object, so manually throw 404 here
throw wrapCustomError(new Error('Policy not found'), 404);
}
// Deserialize policy
return {
policy: deserializePolicy(name, policiesByName[name]),
};
};

View file

@ -9,10 +9,12 @@ import { registerAppRoutes } from './app';
import { registerRepositoriesRoutes } from './repositories';
import { registerSnapshotsRoutes } from './snapshots';
import { registerRestoreRoutes } from './restore';
import { registerPolicyRoutes } from './policy';
export const registerRoutes = (router: Router, plugins: Plugins): void => {
registerAppRoutes(router, plugins);
registerRepositoriesRoutes(router, plugins);
registerSnapshotsRoutes(router, plugins);
registerRestoreRoutes(router);
registerPolicyRoutes(router);
};

View file

@ -5,10 +5,9 @@
*/
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapEsError } from '../../../../../server/lib/create_router/error_wrappers';
import { SnapshotDetails } from '../../../common/types';
import { SnapshotDetails, SnapshotDetailsEs } from '../../../common/types';
import { Plugins } from '../../../shim';
import { deserializeSnapshotDetails, getManagedRepositoryName } from '../../lib';
import { SnapshotDetailsEs } from '../../types';
let callWithInternalUser: any;

View file

@ -1,30 +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;
* you may not use this file except in compliance with the Elastic License.
*/
export interface SnapshotDetailsEs {
snapshot: string;
uuid: string;
version_id: number;
version: string;
indices: string[];
include_global_state: boolean;
state: string;
/** e.g. '2019-04-05T21:56:40.438Z' */
start_time: string;
start_time_in_millis: number;
/** e.g. '2019-04-05T21:56:45.210Z' */
end_time: string;
end_time_in_millis: number;
duration_in_millis: number;
failures: any[];
shards: SnapshotDetailsShardsStatusEs;
}
interface SnapshotDetailsShardsStatusEs {
total: number;
failed: number;
successful: number;
}

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import { Legacy } from 'kibana';
import { createRouter, Router } from '../../server/lib/create_router';
import { registerLicenseChecker } from '../../server/lib/register_license_checker';
import { elasticsearchJsPlugin } from './server/client/elasticsearch_slm';
export interface Core {
http: {
@ -39,7 +40,10 @@ export function createShim(
return {
core: {
http: {
createRouter: (basePath: string) => createRouter(server, pluginId, basePath),
createRouter: (basePath: string) =>
createRouter(server, pluginId, basePath, {
plugins: [elasticsearchJsPlugin],
}),
},
i18n,
},

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const callWithRequestFactory = (server, request) => {
const { callWithRequest } = server.plugins.elasticsearch.getCluster('data');
return (...args) => {
return callWithRequest(request, ...args);
};
export const callWithRequestFactory = (server, pluginId, config) => {
const { callWithRequest } = config
? server.plugins.elasticsearch.createCluster(pluginId, config)
: server.plugins.elasticsearch.getCluster('data');
return callWithRequest;
};

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Request } from 'hapi';
import { Legacy } from 'kibana';
import { CallCluster } from 'src/legacy/core_plugins/elasticsearch';
@ -12,5 +11,8 @@ export type CallWithRequest = (...args: any[]) => CallCluster;
export declare function callWithRequestFactory(
server: Legacy.Server,
request: Request
pluginId: string,
config?: {
plugins: any[];
}
): CallWithRequest;

View file

@ -29,7 +29,10 @@ export interface Router {
export declare function createRouter(
server: Legacy.Server,
pluginId: string,
apiBasePath: string
apiBasePath: string,
config?: {
plugins: any[];
}
): Router;
export declare function isEsErrorFactory(server: Legacy.Server): any;

View file

@ -16,16 +16,20 @@ export const isEsErrorFactory = server => {
return createIsEsError(server);
};
export const createRouter = (server, pluginId, apiBasePath = '') => {
export const createRouter = (server, pluginId, apiBasePath = '', config) => {
const isEsError = isEsErrorFactory(server);
// NOTE: The license-checking logic depends on the xpack_main plugin, so if your plugin
// consumes this helper, make sure it declares 'xpack_main' as a dependency.
const licensePreRouting = licensePreRoutingFactory(server, pluginId);
const callWithRequestInstance = callWithRequestFactory(server, pluginId, config);
const requestHandler = (handler) => async (request, h) => {
const callWithRequest = callWithRequestFactory(server, request);
try {
const callWithRequest = (...args) => {
return callWithRequestInstance(request, ...args);
};
return await handler(request, callWithRequest, h);
} catch (err) {
if (err instanceof Boom) {