[Synthetics] Added ability to hide public locations (#164863)

This commit is contained in:
Shahzad 2023-09-14 17:49:11 +02:00 committed by GitHub
parent 39cf3718b3
commit 519c4d6249
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 470 additions and 196 deletions

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export * from './locations';
export * from './state';

View file

@ -1,31 +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.
*/
import * as t from 'io-ts';
import { CheckGeoType, SummaryType } from '../common';
// IO type for validation
export const MonitorLocationType = t.type({
up_history: t.number,
down_history: t.number,
timestamp: t.string,
summary: SummaryType,
geo: CheckGeoType,
});
// Typescript type for type checking
export type MonitorLocation = t.TypeOf<typeof MonitorLocationType>;
export const MonitorLocationsType = t.intersection([
t.type({
monitorId: t.string,
up_history: t.number,
down_history: t.number,
}),
t.partial({ locations: t.array(MonitorLocationType) }),
]);
export type MonitorLocations = t.TypeOf<typeof MonitorLocationsType>;

View file

@ -46,6 +46,10 @@ export const TLSSensitiveFieldsCodec = t.partial({
export const TLSCodec = t.intersection([TLSFieldsCodec, TLSSensitiveFieldsCodec]);
const MonitorLocationsCodec = t.array(t.union([MonitorServiceLocationCodec, PrivateLocationCodec]));
export type MonitorLocations = t.TypeOf<typeof MonitorLocationsCodec>;
// CommonFields
export const CommonFieldsCodec = t.intersection([
t.interface({
@ -56,7 +60,7 @@ export const CommonFieldsCodec = t.intersection([
[ConfigKey.SCHEDULE]: ScheduleCodec,
[ConfigKey.APM_SERVICE_NAME]: t.string,
[ConfigKey.TAGS]: t.array(t.string),
[ConfigKey.LOCATIONS]: t.array(t.union([MonitorServiceLocationCodec, PrivateLocationCodec])),
[ConfigKey.LOCATIONS]: MonitorLocationsCodec,
[ConfigKey.MONITOR_QUERY_ID]: t.string,
[ConfigKey.CONFIG_ID]: t.string,
}),

View file

@ -30,12 +30,14 @@ export const FleetPermissionsCallout = () => {
*/
export const NoPermissionsTooltip = ({
canEditSynthetics = true,
canUsePublicLocations = true,
children,
}: {
canEditSynthetics?: boolean;
canUsePublicLocations?: boolean;
children: ReactNode;
}) => {
const disabledMessage = getRestrictionReasonLabel(canEditSynthetics);
const disabledMessage = getRestrictionReasonLabel(canEditSynthetics, canUsePublicLocations);
if (disabledMessage) {
return (
<EuiToolTip content={disabledMessage}>
@ -47,8 +49,16 @@ export const NoPermissionsTooltip = ({
return <>{children}</>;
};
function getRestrictionReasonLabel(canEditSynthetics = true): string | undefined {
return !canEditSynthetics ? CANNOT_PERFORM_ACTION_SYNTHETICS : undefined;
function getRestrictionReasonLabel(
canEditSynthetics = true,
canUsePublicLocations = true
): string | undefined {
const message = !canEditSynthetics ? CANNOT_PERFORM_ACTION_SYNTHETICS : undefined;
if (message) {
return message;
}
return !canUsePublicLocations ? CANNOT_PERFORM_ACTION_PUBLIC_LOCATIONS : undefined;
}
export const NEED_PERMISSIONS_PRIVATE_LOCATIONS = i18n.translate(
@ -83,3 +93,10 @@ export const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate(
defaultMessage: 'You do not have sufficient permissions to perform this action.',
}
);
export const CANNOT_PERFORM_ACTION_PUBLIC_LOCATIONS = i18n.translate(
'xpack.synthetics.monitorManagement.canUsePublicLocations',
{
defaultMessage: 'You do not have sufficient permissions to use Elastic managed locations.',
}
);

View file

@ -78,6 +78,7 @@ import {
ResponseCheckJSON,
ThrottlingConfig,
RequestBodyCheck,
SourceType,
} from '../types';
import { AlertConfigKey, ALLOWED_SCHEDULES_IN_MINUTES } from '../constants';
import { getDefaultFormFields } from './defaults';
@ -404,8 +405,8 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
props: ({ field, setValue, locations, trigger }) => {
return {
options: Object.values(locations).map((location) => ({
label: locations?.find((loc) => location.id === loc.id)?.label || '',
id: location.id || '',
label: location.label,
id: location.id,
isServiceManaged: location.isServiceManaged || false,
isInvalid: location.isInvalid,
disabled: location.isInvalid,
@ -417,7 +418,9 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
: location.isServiceManaged
? 'default'
: 'primary',
label: locations?.find((loc) => location.id === loc.id)?.label ?? location.id,
label:
(location.label || locations?.find((loc) => location.id === loc.id)?.label) ??
location.id,
id: location.id || '',
isServiceManaged: location.isServiceManaged || false,
})),
@ -483,66 +486,78 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({
helpText: i18n.translate('xpack.synthetics.monitorConfig.edit.enabled.label', {
defaultMessage: `When disabled, the monitor doesn't run any tests. You can enable it at any time.`,
}),
props: ({ setValue, field, trigger }): EuiSwitchProps => ({
id: 'syntheticsMontiorConfigIsEnabled',
label: i18n.translate('xpack.synthetics.monitorConfig.enabled.label', {
defaultMessage: 'Enable Monitor',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(ConfigKey.ENABLED, !!event.target.checked);
await trigger(ConfigKey.ENABLED);
},
'data-test-subj': 'syntheticsEnableSwitch',
// enabled is an allowed field for read only
// isDisabled: readOnly,
}),
props: ({ setValue, field, trigger, formState }): EuiSwitchProps => {
const isProjectMonitor =
formState.defaultValues?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
return {
id: 'syntheticsMontiorConfigIsEnabled',
label: i18n.translate('xpack.synthetics.monitorConfig.enabled.label', {
defaultMessage: 'Enable Monitor',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(ConfigKey.ENABLED, !!event.target.checked);
await trigger(ConfigKey.ENABLED);
},
'data-test-subj': 'syntheticsEnableSwitch',
// enabled is an allowed field for read only
disabled: !isProjectMonitor && readOnly,
};
},
},
[AlertConfigKey.STATUS_ENABLED]: {
fieldKey: AlertConfigKey.STATUS_ENABLED,
component: Switch,
controlled: true,
props: ({ setValue, field, trigger }): EuiSwitchProps => ({
id: 'syntheticsMonitorConfigIsAlertEnabled',
label: field?.value
? i18n.translate('xpack.synthetics.monitorConfig.enabledAlerting.label', {
defaultMessage: 'Disable status alerts on this monitor',
})
: i18n.translate('xpack.synthetics.monitorConfig.disabledAlerting.label', {
defaultMessage: 'Enable status alerts on this monitor',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(AlertConfigKey.STATUS_ENABLED, !!event.target.checked);
await trigger(AlertConfigKey.STATUS_ENABLED);
},
'data-test-subj': 'syntheticsAlertStatusSwitch',
// alert config is an allowed field for read only
// isDisabled: readOnly,
}),
props: ({ setValue, field, trigger, formState }): EuiSwitchProps => {
const isProjectMonitor =
formState.defaultValues?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
return {
id: 'syntheticsMonitorConfigIsAlertEnabled',
label: field?.value
? i18n.translate('xpack.synthetics.monitorConfig.enabledAlerting.label', {
defaultMessage: 'Disable status alerts on this monitor',
})
: i18n.translate('xpack.synthetics.monitorConfig.disabledAlerting.label', {
defaultMessage: 'Enable status alerts on this monitor',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(AlertConfigKey.STATUS_ENABLED, !!event.target.checked);
await trigger(AlertConfigKey.STATUS_ENABLED);
},
'data-test-subj': 'syntheticsAlertStatusSwitch',
// alert config is an allowed field for read only
disabled: !isProjectMonitor && readOnly,
};
},
},
[AlertConfigKey.TLS_ENABLED]: {
fieldKey: AlertConfigKey.TLS_ENABLED,
component: Switch,
controlled: true,
props: ({ setValue, field, trigger }): EuiSwitchProps => ({
id: 'syntheticsMonitorConfigIsTlsAlertEnabled',
label: field?.value
? i18n.translate('xpack.synthetics.monitorConfig.edit.alertTlsEnabled.label', {
defaultMessage: 'Disable TLS alerts on this monitor.',
})
: i18n.translate('xpack.synthetics.monitorConfig.create.alertTlsEnabled.label', {
defaultMessage: 'Enable TLS alerts on this monitor.',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(AlertConfigKey.TLS_ENABLED, !!event.target.checked);
await trigger(AlertConfigKey.TLS_ENABLED);
},
'data-test-subj': 'syntheticsAlertStatusSwitch',
// alert config is an allowed field for read only
// isDisabled: readOnly,
}),
props: ({ setValue, field, trigger, formState }): EuiSwitchProps => {
const isProjectMonitor =
formState.defaultValues?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
return {
id: 'syntheticsMonitorConfigIsTlsAlertEnabled',
label: field?.value
? i18n.translate('xpack.synthetics.monitorConfig.edit.alertTlsEnabled.label', {
defaultMessage: 'Disable TLS alerts on this monitor.',
})
: i18n.translate('xpack.synthetics.monitorConfig.create.alertTlsEnabled.label', {
defaultMessage: 'Enable TLS alerts on this monitor.',
}),
checked: field?.value || false,
onChange: async (event) => {
setValue(AlertConfigKey.TLS_ENABLED, !!event.target.checked);
await trigger(AlertConfigKey.TLS_ENABLED);
},
'data-test-subj': 'syntheticsAlertStatusSwitch',
// alert config is an allowed field for read only
disabled: !isProjectMonitor && readOnly,
};
},
},
[ConfigKey.TAGS]: {
fieldKey: ConfigKey.TAGS,

View file

@ -18,7 +18,8 @@ export const MonitorForm: React.FC<{
defaultValues?: SyntheticsMonitor;
space?: string;
readOnly?: boolean;
}> = ({ children, defaultValues, space, readOnly = false }) => {
canUsePublicLocations: boolean;
}> = ({ children, defaultValues, space, readOnly = false, canUsePublicLocations }) => {
const methods = useFormWrapped({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
@ -43,7 +44,7 @@ export const MonitorForm: React.FC<{
>
{children}
<EuiSpacer />
<ActionBar readOnly={readOnly} />
<ActionBar readOnly={readOnly} canUsePublicLocations={canUsePublicLocations} />
</EuiForm>
<Disclaimer />
</FormProvider>

View file

@ -16,7 +16,11 @@ import { format } from './formatter';
import { MonitorFields as MonitorFieldsType } from '../../../../../../common/runtime_types';
import { runOnceMonitor } from '../../../state/manual_test_runs/api';
export const RunTestButton = () => {
export const RunTestButton = ({
canUsePublicLocations = true,
}: {
canUsePublicLocations?: boolean;
}) => {
const { formState, getValues, handleSubmit } = useFormContext();
const [inProgress, setInProgress] = useState(false);
@ -56,7 +60,7 @@ export const RunTestButton = () => {
<EuiButton
data-test-subj="syntheticsRunTestBtn"
color="success"
disabled={isDisabled}
disabled={isDisabled || !canUsePublicLocations}
aria-label={TEST_NOW_ARIA_LABEL}
iconType="play"
onClick={handleSubmit(handleTestNow)}

View file

@ -21,7 +21,13 @@ import { format } from './formatter';
import { MONITORS_ROUTE } from '../../../../../../common/constants';
export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
export const ActionBar = ({
readOnly = false,
canUsePublicLocations = true,
}: {
readOnly: boolean;
canUsePublicLocations: boolean;
}) => {
const { monitorId } = useParams<{ monitorId: string }>();
const history = useHistory();
const {
@ -59,6 +65,7 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
onClick={() => {
setMonitorPendingDeletion(defaultValues as SyntheticsMonitor);
}}
isDisabled={!canEditSynthetics || !canUsePublicLocations}
>
{DELETE_MONITOR_LABEL}
</EuiButton>
@ -75,16 +82,19 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RunTestButton />
<RunTestButton canUsePublicLocations={canUsePublicLocations} />
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ marginLeft: 'auto' }}>
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={canUsePublicLocations}
>
<EuiButton
fill
isLoading={loading}
onClick={handleSubmit(formSubmitter)}
data-test-subj="syntheticsMonitorConfigSubmitButton"
disabled={!canEditSynthetics}
disabled={!canEditSynthetics || !canUsePublicLocations}
>
{isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL}
</EuiButton>

View file

@ -12,6 +12,7 @@ import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useTrackPageview, useFetcher } from '@kbn/observability-shared-plugin/public';
import { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import { useCanUsePublicLocations } from '../../../../hooks/use_capabilities';
import { EditMonitorNotFound } from './edit_monitor_not_found';
import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_flyout';
import { ConfigKey, SourceType } from '../../../../../common/runtime_types';
@ -50,11 +51,15 @@ export const MonitorEditPage: React.FC = () => {
data?.id
);
const canUsePublicLocations = useCanUsePublicLocations(data?.[ConfigKey.LOCATIONS]);
if (monitorNotFoundError) {
return <EditMonitorNotFound />;
}
const isReadOnly = data?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
const isReadOnly =
data?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT || !canUsePublicLocations;
const projectId = data?.[ConfigKey.PROJECT_ID];
if (locationsError) {
@ -87,8 +92,13 @@ export const MonitorEditPage: React.FC = () => {
return data && locationsLoaded && !loading && !error ? (
<>
<AlertingCallout isAlertingEnabled={data[ConfigKey.ALERT_CONFIG]?.status?.enabled} />
<MonitorForm defaultValues={data} readOnly={isReadOnly}>
<MonitorForm
defaultValues={data}
readOnly={isReadOnly}
canUsePublicLocations={canUsePublicLocations}
>
<MonitorSteps
canUsePublicLocations={canUsePublicLocations}
stepMap={EDIT_MONITOR_STEPS(isReadOnly)}
isEditFlow={true}
readOnly={isReadOnly}

View file

@ -16,6 +16,7 @@ import { MonitorTypePortal } from './monitor_type_portal';
import { ReadOnlyCallout } from './read_only_callout';
export const MonitorSteps = ({
canUsePublicLocations,
stepMap,
projectId,
isEditFlow = false,
@ -23,6 +24,7 @@ export const MonitorSteps = ({
}: {
stepMap: StepMap;
readOnly?: boolean;
canUsePublicLocations?: boolean;
isEditFlow?: boolean;
projectId?: string;
}) => {
@ -32,12 +34,9 @@ export const MonitorSteps = ({
return (
<>
{readOnly ? (
<>
<ReadOnlyCallout projectId={projectId} />
<EuiSpacer size="m" />
</>
) : null}
{isEditFlow && (
<ReadOnlyCallout projectId={projectId} canUsePublicLocations={canUsePublicLocations} />
)}
{isEditFlow ? (
steps.map((step) => (
<div key={step.title}>

View file

@ -5,27 +5,65 @@
* 2.0.
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export const ReadOnlyCallout = ({ projectId }: { projectId?: string }) => {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.synthetics.project.readOnly.callout.title"
defaultMessage="This configuration is read-only"
/>
}
iconType="document"
>
<p>
<FormattedMessage
id="xpack.synthetics.project.readOnly.callout.content"
defaultMessage="This monitor was added from an external project: {projectId}. From this page, you can only enable and disable the monitor and its alerts, or remove it. To make configuration changes, you have to edit its source file and push it again from that project."
values={{ projectId: <strong>{projectId}</strong> }}
/>
</p>
</EuiCallOut>
);
export const ReadOnlyCallout = ({
projectId,
canUsePublicLocations,
}: {
projectId?: string;
canUsePublicLocations?: boolean;
}) => {
if (projectId) {
return (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.synthetics.project.readOnly.callout.title"
defaultMessage="This configuration is read-only"
/>
}
iconType="document"
>
<p>
<FormattedMessage
id="xpack.synthetics.project.readOnly.callout.content"
defaultMessage="This monitor was added from an external project: {projectId}. From this page, you can only enable and disable the monitor and its alerts, or remove it. To make configuration changes, you have to edit its source file and push it again from that project."
values={{ projectId: <strong>{projectId}</strong> }}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
}
if (!canUsePublicLocations) {
return (
<>
<EuiCallOut
color="warning"
title={
<FormattedMessage
id="xpack.synthetics.publicLocations.readOnly.callout.title"
defaultMessage="You do not have permission to use Elastic managed locations"
/>
}
iconType="alert"
>
<p>
<FormattedMessage
id="xpack.synthetics.publicLocations.readOnly.callout.content"
defaultMessage="This monitor contains a Elastic managed location. To edit this monitor, you need to have permission to use Elastic managed locations."
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
}
return null;
};

View file

@ -7,6 +7,7 @@
import { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ServiceLocation } from '../../../../../../common/runtime_types';
import { useSelectedMonitor } from './use_selected_monitor';
import { selectSelectedLocationId, setMonitorDetailsLocationAction } from '../../../state';
import { useUrlParams, useLocations } from '../../../hooks';
@ -41,8 +42,12 @@ export const useSelectedLocation = (updateUrl = true) => {
monitor?.locations,
]);
return useMemo(
() => locations.find((loc) => loc.id === urlLocationId) ?? null,
[urlLocationId, locations]
);
return useMemo(() => {
let selLoc = locations.find((loc) => loc.id === urlLocationId) ?? null;
if (!selLoc) {
selLoc =
(monitor?.locations?.find((loc) => loc.id === urlLocationId) as ServiceLocation) ?? null;
}
return selLoc;
}, [locations, urlLocationId, monitor?.locations]);
};

View file

@ -9,6 +9,9 @@ import { EuiButton, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { CANNOT_PERFORM_ACTION_PUBLIC_LOCATIONS } from '../common/components/permissions';
import { useCanUsePublicLocations } from '../../../../hooks/use_capabilities';
import { ConfigKey } from '../../../../../common/constants/monitor_management';
import { TEST_NOW_ARIA_LABEL, TEST_SCHEDULED_LABEL } from '../monitor_add_edit/form/run_test_btn';
import { useSelectedMonitor } from './hooks/use_selected_monitor';
import {
@ -22,7 +25,13 @@ export const RunTestManually = () => {
const { monitor } = useSelectedMonitor();
const testInProgress = useSelector(manualTestRunInProgressSelector(monitor?.config_id));
const content = testInProgress ? TEST_SCHEDULED_LABEL : TEST_NOW_ARIA_LABEL;
const canUsePublicLocations = useCanUsePublicLocations(monitor?.[ConfigKey.LOCATIONS]);
const content = !canUsePublicLocations
? CANNOT_PERFORM_ACTION_PUBLIC_LOCATIONS
: testInProgress
? TEST_SCHEDULED_LABEL
: TEST_NOW_ARIA_LABEL;
return (
<EuiToolTip content={content} key={content}>
@ -31,6 +40,7 @@ export const RunTestManually = () => {
color="success"
iconType="beaker"
isLoading={!Boolean(monitor) || testInProgress}
isDisabled={!canUsePublicLocations}
onClick={() => {
if (monitor) {
dispatch(

View file

@ -0,0 +1,25 @@
/*
* 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 { useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { selectOverviewState } from '../../../state';
export const useCanUsePublicLocById = (configId: string) => {
const {
data: { monitors },
} = useSelector(selectOverviewState);
const hasManagedLocation = monitors?.filter(
(mon) => mon.configId === configId && mon.location.isServiceManaged
);
const canUsePublicLocations =
useKibana().services?.application?.capabilities.uptime.elasticManagedLocationsEnabled ?? true;
return hasManagedLocation ? !!canUsePublicLocations : true;
};

View file

@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities';
import {
isStatusEnabled,
@ -55,6 +56,15 @@ export function useMonitorListColumns({
return alertStatus(fields[ConfigKey.CONFIG_ID]) === FETCH_STATUS.LOADING;
};
const canUsePublicLocations =
useKibana().services?.application?.capabilities.uptime.elasticManagedLocationsEnabled ?? true;
const isPublicLocationsAllowed = (fields: EncryptedSyntheticsSavedMonitor) => {
const publicLocations = fields.locations.some((loc) => loc.isServiceManaged);
return publicLocations ? Boolean(canUsePublicLocations) : true;
};
const columns: Array<EuiBasicTableColumn<EncryptedSyntheticsSavedMonitor>> = [
{
align: 'left' as const,
@ -166,14 +176,18 @@ export function useMonitorListColumns({
'data-test-subj': 'syntheticsMonitorEditAction',
isPrimary: true,
name: (fields) => (
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={isPublicLocationsAllowed(fields)}
>
{labels.EDIT_LABEL}
</NoPermissionsTooltip>
),
description: labels.EDIT_LABEL,
icon: 'pencil' as const,
type: 'icon' as const,
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
enabled: (fields) =>
canEditSynthetics && !isActionLoading(fields) && isPublicLocationsAllowed(fields),
onClick: (fields) => {
history.push({
pathname: `/edit-monitor/${fields[ConfigKey.CONFIG_ID]}`,
@ -184,7 +198,10 @@ export function useMonitorListColumns({
'data-test-subj': 'syntheticsMonitorDeleteAction',
isPrimary: true,
name: (fields) => (
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={isPublicLocationsAllowed(fields)}
>
{labels.DELETE_LABEL}
</NoPermissionsTooltip>
),
@ -192,7 +209,8 @@ export function useMonitorListColumns({
icon: 'trash' as const,
type: 'icon' as const,
color: 'danger' as const,
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
enabled: (fields) =>
canEditSynthetics && !isActionLoading(fields) && isPublicLocationsAllowed(fields),
onClick: (fields) => {
setMonitorPendingDeletion(fields);
},
@ -207,7 +225,8 @@ export function useMonitorListColumns({
isStatusEnabled(fields[ConfigKey.ALERT_CONFIG]) ? 'bellSlash' : 'bell',
type: 'icon' as const,
color: 'danger' as const,
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
enabled: (fields) =>
canEditSynthetics && !isActionLoading(fields) && isPublicLocationsAllowed(fields),
onClick: (fields) => {
updateAlertEnabledState({
monitor: {

View file

@ -10,7 +10,10 @@ import { EuiSwitch, EuiSwitchEvent, EuiLoadingSpinner } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import { ConfigKey, EncryptedSyntheticsMonitor } from '../../../../../../../common/runtime_types';
import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities';
import {
useCanEditSynthetics,
useCanUsePublicLocations,
} from '../../../../../../hooks/use_capabilities';
import { useMonitorEnableHandler } from '../../../../hooks';
import { NoPermissionsTooltip } from '../../../common/components/permissions';
import * as labels from './labels';
@ -32,6 +35,8 @@ export const MonitorEnabled = ({
}: Props) => {
const canEditSynthetics = useCanEditSynthetics();
const canUsePublicLocations = useCanUsePublicLocations(monitor?.[ConfigKey.LOCATIONS]);
const monitorName = monitor[ConfigKey.NAME];
const statusLabels = useMemo(() => {
return {
@ -63,11 +68,14 @@ export const MonitorEnabled = ({
{isLoading || initialLoading ? (
<EuiLoadingSpinner size="m" />
) : (
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={canUsePublicLocations}
>
<SwitchWithCursor
compressed={true}
checked={enabled}
disabled={isLoading || !canEditSynthetics}
disabled={isLoading || !canEditSynthetics || !canUsePublicLocations}
showLabel={false}
label={enabledDisableLabel}
title={enabledDisableLabel}

View file

@ -23,12 +23,19 @@ export const MonitorLocations = ({ locations, monitorId, status }: Props) => {
const locationsToDisplay = locations
.map((loc) => {
if (loc.label) {
return {
id: loc.id,
label: loc.label,
...getLocationStatusColor(theme, loc.id, monitorId, status),
};
}
const fullLoc = allLocations.find((l) => l.id === loc.id);
if (fullLoc) {
return {
id: fullLoc.id,
label: fullLoc.label,
...getLocationStatusColor(theme, fullLoc.label, monitorId, status),
...getLocationStatusColor(theme, fullLoc.id, monitorId, status),
};
}
})
@ -41,7 +48,7 @@ export const MonitorLocations = ({ locations, monitorId, status }: Props) => {
function getLocationStatusColor(
euiTheme: ReturnType<typeof useTheme>,
locationLabel: string | undefined,
locationId: string,
monitorId: string,
overviewStatus: OverviewStatusState | null
) {
@ -49,7 +56,7 @@ function getLocationStatusColor(
eui: { euiColorVis9, euiColorVis0, euiColorDisabled },
} = euiTheme;
const locById = `${monitorId}-${locationLabel}`;
const locById = `${monitorId}-${locationId}`;
if (overviewStatus?.downConfigs[locById]) {
return { status: 'down', color: euiColorVis9 };

View file

@ -14,10 +14,13 @@ import {
EuiPanel,
EuiLoadingSpinner,
EuiContextMenuPanelItemDescriptor,
EuiToolTip,
} from '@elastic/eui';
import { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { TEST_SCHEDULED_LABEL } from '../../../monitor_add_edit/form/run_test_btn';
import { useCanUsePublicLocById } from '../../hooks/use_can_use_public_loc_id';
import { toggleStatusAlert } from '../../../../../../../common/runtime_types/monitor_management/alert_config';
import {
manualTestMonitorAction,
@ -101,8 +104,7 @@ export function ActionsPopover({
}: Props) {
const euiShadow = useEuiShadow('l');
const dispatch = useDispatch();
const location = useLocationName({ locationId });
const locationName = location?.label || monitor.location.id;
const locationName = useLocationName(monitor);
const detailUrl = useMonitorDetailLocator({
configId: monitor.configId,
@ -112,6 +114,8 @@ export function ActionsPopover({
const canEditSynthetics = useCanEditSynthetics();
const canUsePublicLocations = useCanUsePublicLocById(monitor.configId);
const labels = useMemo(
() => ({
enabledSuccessLabel: enabledSuccessLabel(monitor.name),
@ -163,7 +167,6 @@ export function ActionsPopover({
};
const alertLoading = alertStatus(monitor.configId) === FETCH_STATUS.LOADING;
let popoverItems: EuiContextMenuPanelItemDescriptor[] = [
{
name: actionsMenuGoToMonitorName,
@ -172,9 +175,17 @@ export function ActionsPopover({
},
quickInspectPopoverItem,
{
name: runTestManually,
name: testInProgress ? (
<EuiToolTip content={TEST_SCHEDULED_LABEL}>
<span>{runTestManually}</span>
</EuiToolTip>
) : (
<NoPermissionsTooltip canUsePublicLocations={canUsePublicLocations}>
{runTestManually}
</NoPermissionsTooltip>
),
icon: 'beaker',
disabled: testInProgress,
disabled: testInProgress || !canUsePublicLocations,
onClick: () => {
dispatch(manualTestMonitorAction.get({ configId: monitor.configId, name: monitor.name }));
dispatch(setFlyoutConfig(null));
@ -193,12 +204,15 @@ export function ActionsPopover({
},
{
name: (
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={canUsePublicLocations}
>
{enableLabel}
</NoPermissionsTooltip>
),
icon: 'invert',
disabled: !canEditSynthetics,
disabled: !canEditSynthetics || !canUsePublicLocations,
onClick: () => {
if (status !== FETCH_STATUS.LOADING) {
updateMonitorEnabledState(!monitor.isEnabled);
@ -207,11 +221,14 @@ export function ActionsPopover({
},
{
name: (
<NoPermissionsTooltip canEditSynthetics={canEditSynthetics}>
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUsePublicLocations={canUsePublicLocations}
>
{monitor.isStatusAlertEnabled ? disableAlertLabel : enableMonitorAlertLabel}
</NoPermissionsTooltip>
),
disabled: !canEditSynthetics,
disabled: !canEditSynthetics || !canUsePublicLocations,
icon: alertLoading ? (
<EuiLoadingSpinner size="s" />
) : monitor.isStatusAlertEnabled ? (

View file

@ -64,8 +64,7 @@ export const MetricItem = ({
const [isMouseOver, setIsMouseOver] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const isErrorPopoverOpen = useSelector(selectErrorPopoverState);
const locationName =
useLocationName({ locationId: monitor.location.id })?.label || monitor.location?.id;
const locationName = useLocationName(monitor);
const { status, timestamp, ping, configIdByLocation } = useStatusByLocationOverview(
monitor.configId,
monitor.location.id

View file

@ -27,8 +27,7 @@ export const OverviewGridItem = ({
monitor: MonitorOverviewItem;
onClick: (params: FlyoutParamProps) => void;
}) => {
const locationName =
useLocationName({ locationId: monitor.location?.id })?.label || monitor.location?.id;
const locationName = useLocationName(monitor);
const { timestamp } = useStatusByLocationOverview(monitor.configId, locationName);

View file

@ -8,6 +8,7 @@ import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useLocationName } from './use_location_name';
import { WrappedHelper } from '../utils/testing';
import { MonitorOverviewItem } from '../../../../common/runtime_types';
describe('useLocationName', () => {
beforeEach(() => {
@ -47,16 +48,11 @@ describe('useLocationName', () => {
const { result } = renderHook(
() =>
useLocationName({
locationId: 'us_central',
}),
location: { id: 'us_central' },
} as MonitorOverviewItem),
{ wrapper: WrapperWithState }
);
expect(result.current).toEqual({
id: 'us_central',
isServiceManaged: true,
label: 'US Central',
url: 'mockUrl',
});
expect(result.current).toEqual('US Central');
});
it('returns the location id if matching location cannot be found', () => {
@ -92,10 +88,10 @@ describe('useLocationName', () => {
const { result } = renderHook(
() =>
useLocationName({
locationId: 'us_west',
}),
location: { id: 'us_west' },
} as MonitorOverviewItem),
{ wrapper: WrapperWithState }
);
expect(result.current).toEqual(undefined);
expect(result.current).toEqual('us_west');
});
});

View file

@ -7,9 +7,10 @@
import { useMemo, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { MonitorOverviewItem } from '../../../../common/runtime_types';
import { selectServiceLocationsState, getServiceLocations } from '../state';
export function useLocationName({ locationId }: { locationId: string }) {
export function useLocationName(monitor: MonitorOverviewItem) {
const dispatch = useDispatch();
const { locationsLoaded, locations } = useSelector(selectServiceLocationsState);
useEffect(() => {
@ -17,12 +18,14 @@ export function useLocationName({ locationId }: { locationId: string }) {
dispatch(getServiceLocations());
}
});
const locationId = monitor?.location.id;
return useMemo(() => {
if (!locationsLoaded) {
return undefined;
if (!locationsLoaded || monitor.location.label) {
return monitor.location.label ?? monitor.location.id;
} else {
return locations.find((location) => location.id === locationId);
const location = locations.find((loc) => loc.id === locationId);
return location?.label ?? (monitor.location.label || monitor.location.id);
}
}, [locationsLoaded, locations, locationId]);
}, [locationsLoaded, locations, locationId, monitor]);
}

View file

@ -6,7 +6,20 @@
*/
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { MonitorLocations } from '../../common/runtime_types';
export const useCanEditSynthetics = () => {
return !!useKibana().services?.application?.capabilities.uptime.save;
};
export const useCanUsePublicLocations = (monLocations?: MonitorLocations) => {
const canUsePublicLocations =
useKibana().services?.application?.capabilities.uptime.elasticManagedLocationsEnabled ?? true;
const publicLocations = monLocations?.some((loc) => loc.isServiceManaged);
if (!publicLocations) {
return true;
}
return !!canUsePublicLocations;
};

View file

@ -7,6 +7,11 @@
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { i18n } from '@kbn/i18n';
import {
SubFeaturePrivilegeGroupConfig,
SubFeaturePrivilegeGroupType,
} from '@kbn/features-plugin/common';
import { syntheticsMonitorType, syntheticsParamType } from '../common/types/saved_objects';
import { SYNTHETICS_RULE_TYPES } from '../common/constants/synthetics_alerts';
import { privateLocationsSavedObjectName } from '../common/saved_objects/private_locations';
@ -27,6 +32,24 @@ const ruleTypes = [
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
];
const elasticManagedLocationsEnabledPrivilege: SubFeaturePrivilegeGroupConfig = {
groupType: 'independent' as SubFeaturePrivilegeGroupType,
privileges: [
{
id: 'elastic_managed_locations_enabled',
name: i18n.translate('xpack.synthetics.features.elasticManagedLocations', {
defaultMessage: 'Elastic managed locations enabled',
}),
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: ['elasticManagedLocationsEnabled'],
},
],
};
export const uptimeFeature = {
id: PLUGIN.ID,
name: PLUGIN.NAME,
@ -94,4 +117,12 @@ export const uptimeFeature = {
ui: ['show', 'alerting:save'],
},
},
subFeatures: [
{
name: i18n.translate('xpack.synthetics.features.app', {
defaultMessage: 'Synthetics',
}),
privilegeGroups: [elasticManagedLocationsEnabledPrivilege],
},
],
};

View file

@ -6,6 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { validatePermissions } from './edit_monitor';
import { SyntheticsServerSetup } from '../../types';
import { RouteContext, SyntheticsRestApiRouteFactory } from '../types';
import { syntheticsMonitorType } from '../../../common/types/saved_objects';
@ -39,10 +40,13 @@ export const deleteSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () =>
const { monitorId } = request.params;
try {
const errors = await deleteMonitor({
const { errors, res } = await deleteMonitor({
routeContext,
monitorId,
});
if (res) {
return res;
}
if (errors && errors.length > 0) {
return response.ok({
@ -68,7 +72,7 @@ export const deleteMonitor = async ({
routeContext: RouteContext;
monitorId: string;
}) => {
const { spaceId, savedObjectsClient, server, syntheticsMonitorClient } = routeContext;
const { response, spaceId, savedObjectsClient, server, syntheticsMonitorClient } = routeContext;
const { logger, telemetry, stackVersion } = server;
const { monitor, monitorWithSecret } = await getMonitorToDelete(
@ -78,6 +82,17 @@ export const deleteMonitor = async ({
spaceId
);
const err = await validatePermissions(routeContext, monitor.attributes.locations);
if (err) {
return {
res: response.forbidden({
body: {
message: err,
},
}),
};
}
let deletePromise;
try {
@ -113,7 +128,7 @@ export const deleteMonitor = async ({
)
);
return errors;
return { errors };
} catch (e) {
if (deletePromise) {
await deletePromise;

View file

@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema';
import { SavedObjectsUpdateResponse, SavedObject } from '@kbn/core/server';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { getDecryptedMonitor } from '../../saved_objects/synthetics_monitor';
import { getPrivateLocations } from '../../synthetics_service/get_private_locations';
import { mergeSourceMonitor } from './helper';
import { RouteContext, SyntheticsRestApiRouteFactory } from '../types';
@ -18,6 +19,7 @@ import {
SyntheticsMonitorWithSecretsAttributes,
SyntheticsMonitor,
ConfigKey,
MonitorLocations,
} from '../../../common/runtime_types';
import { SYNTHETICS_API_URLS } from '../../../common/constants';
import { validateMonitor } from './monitor_validation';
@ -39,10 +41,10 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => (
}),
body: schema.any(),
},
writeAccess: true,
handler: async (routeContext): Promise<any> => {
const { request, response, savedObjectsClient, server } = routeContext;
const { encryptedSavedObjects, logger } = server;
const encryptedSavedObjectsClient = encryptedSavedObjects.getClient();
const { logger } = server;
const monitor = request.body as SyntheticsMonitor;
const { monitorId } = request.params;
@ -55,14 +57,11 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => (
/* Decrypting the previous monitor before editing ensures that all existing fields remain
* on the object, even in flows where decryption does not take place, such as the enabled tab
* on the monitor list table. We do not decrypt monitors in bulk for the monitor list table */
const decryptedPreviousMonitor =
await encryptedSavedObjectsClient.getDecryptedAsInternalUser<SyntheticsMonitorWithSecretsAttributes>(
syntheticsMonitorType,
monitorId,
{
namespace: previousMonitor.namespaces?.[0],
}
);
const decryptedPreviousMonitor = await getDecryptedMonitor(
server,
monitorId,
previousMonitor.namespaces?.[0]!
);
const normalizedPreviousMonitor = normalizeSecrets(decryptedPreviousMonitor).attributes;
const editedMonitor = mergeSourceMonitor(normalizedPreviousMonitor, monitor);
@ -74,6 +73,15 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => (
return response.badRequest({ body: { message, attributes: { details, ...payload } } });
}
const err = await validatePermissions(routeContext, editedMonitor.locations);
if (err) {
return response.forbidden({
body: {
message: err,
},
});
}
const monitorWithRevision = {
...validationResult.decodedMonitor,
/* reset config hash to empty string. Ensures that the synthetics agent is able
@ -230,3 +238,22 @@ export const syncEditedMonitor = async ({
throw e;
}
};
export const validatePermissions = async (
{ server, response, request }: RouteContext,
monitorLocations: MonitorLocations
) => {
const hasPublicLocations = monitorLocations?.some((loc) => loc.isServiceManaged);
if (!hasPublicLocations) {
return;
}
const elasticManagedLocationsEnabled =
Boolean(
(await server.coreStart?.capabilities.resolveCapabilities(request)).uptime
.elasticManagedLocationsEnabled
) ?? true;
if (!elasticManagedLocationsEnabled) {
return "You don't have permission to use public locations";
}
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { toClientContract } from '../settings/private_locations/helpers';
import { getPrivateLocationsAndAgentPolicies } from '../settings/private_locations/get_private_locations';
import { SyntheticsRestApiRouteFactory } from '../types';
import { getAllLocations } from '../../synthetics_service/get_all_locations';
import { SYNTHETICS_API_URLS } from '../../../common/constants';
@ -13,16 +15,37 @@ export const getServiceLocationsRoute: SyntheticsRestApiRouteFactory = () => ({
method: 'GET',
path: SYNTHETICS_API_URLS.SERVICE_LOCATIONS,
validate: {},
handler: async ({ server, savedObjectsClient, syntheticsMonitorClient }): Promise<any> => {
const { throttling, allLocations } = await getAllLocations({
server,
syntheticsMonitorClient,
savedObjectsClient,
});
handler: async ({
request,
server,
savedObjectsClient,
syntheticsMonitorClient,
}): Promise<any> => {
const elasticManagedLocationsEnabled =
Boolean(
(await server.coreStart?.capabilities.resolveCapabilities(request)).uptime
.elasticManagedLocationsEnabled
) ?? true;
return {
locations: allLocations,
throttling,
};
if (elasticManagedLocationsEnabled) {
const { throttling, allLocations } = await getAllLocations({
server,
syntheticsMonitorClient,
savedObjectsClient,
});
return {
locations: allLocations,
throttling,
};
} else {
const { locations: privateLocations, agentPolicies } =
await getPrivateLocationsAndAgentPolicies(savedObjectsClient, syntheticsMonitorClient);
const result = toClientContract({ locations: privateLocations }, agentPolicies).locations;
return {
locations: result,
};
}
},
});

View file

@ -47,6 +47,11 @@ const getServicePublicLocations = async (
server: SyntheticsServerSetup,
syntheticsMonitorClient: SyntheticsMonitorClient
) => {
if (!syntheticsMonitorClient.syntheticsService.isAllowed) {
return {
locations: [],
};
}
if (syntheticsMonitorClient.syntheticsService.locations.length === 0) {
return await getServiceLocations(server);
}

View file

@ -55,7 +55,7 @@ export default function ({ getService }: FtrProviderContext) {
'file_operations_all',
'execute_operations_all',
],
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
uptime: ['all', 'read', 'minimal_all', 'minimal_read', 'elastic_managed_locations_enabled'],
securitySolutionAssistant: ['all', 'read', 'minimal_all', 'minimal_read'],
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],

View file

@ -130,7 +130,13 @@ export default function ({ getService }: FtrProviderContext) {
'file_operations_all',
'execute_operations_all',
],
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
uptime: [
'all',
'elastic_managed_locations_enabled',
'read',
'minimal_all',
'minimal_read',
],
securitySolutionAssistant: ['all', 'read', 'minimal_all', 'minimal_read'],
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],