Merge branch 'eureka' into fit-eureka

pull/1/head
tanghc 5 years ago
commit b94b48eb60
  1. 4
      changelog.md
  2. 23
      sop-2.2.0.sql
  3. 51
      sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/ServiceApi.java
  4. 70
      sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/service/ConfigPushService.java
  5. 69
      sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/service/ServerService.java
  6. 6
      sop-common/sop-gateway-common/src/main/java/com/gitee/sop/gatewaycommon/manager/AbstractConfiguration.java
  7. 22
      sop-common/sop-gateway-common/src/main/java/com/gitee/sop/gatewaycommon/manager/NacosEventProcessor.java
  8. 21
      sop.sql

@ -1,5 +1,9 @@
# changelog # changelog
## 2.2.0
- 支持eureka注册中心,需要执行`sop-2.2.0.sql`升级文件
## 2.1.3 ## 2.1.3
- 优化文件上传校验 - 优化文件上传校验

@ -0,0 +1,23 @@
use sop;
DROP TABLE IF EXISTS `config_service_route`;
CREATE TABLE `config_service_route` (
`id` varchar(128) NOT NULL DEFAULT '' COMMENT '路由id',
`service_id` varchar(128) NOT NULL DEFAULT '',
`name` varchar(128) NOT NULL DEFAULT '' COMMENT '接口名',
`version` varchar(64) NOT NULL DEFAULT '' COMMENT '版本号',
`predicates` varchar(256) DEFAULT NULL COMMENT '路由断言(SpringCloudGateway专用)',
`filters` varchar(256) DEFAULT NULL COMMENT '路由过滤器(SpringCloudGateway专用)',
`uri` varchar(128) NOT NULL DEFAULT '' COMMENT '路由规则转发的目标uri',
`path` varchar(128) NOT NULL DEFAULT '' COMMENT 'uri后面跟的path',
`order` int(11) NOT NULL DEFAULT '0' COMMENT '路由执行的顺序',
`ignore_validate` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否忽略验证,业务参数验证除外',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态,0:待审核,1:启用,2:禁用',
`merge_result` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否合并结果',
`permission` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否需要授权才能访问',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP,
`gmt_modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_serviceid` (`service_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='路由配置';

@ -9,14 +9,12 @@ import com.gitee.sop.adminserver.api.service.param.ServiceGrayConfigParam;
import com.gitee.sop.adminserver.api.service.param.ServiceIdParam; import com.gitee.sop.adminserver.api.service.param.ServiceIdParam;
import com.gitee.sop.adminserver.api.service.param.ServiceInstanceParam; import com.gitee.sop.adminserver.api.service.param.ServiceInstanceParam;
import com.gitee.sop.adminserver.api.service.param.ServiceSearchParam; import com.gitee.sop.adminserver.api.service.param.ServiceSearchParam;
import com.gitee.sop.adminserver.api.service.result.RouteServiceInfo;
import com.gitee.sop.adminserver.api.service.result.ServiceInfoVo; import com.gitee.sop.adminserver.api.service.result.ServiceInfoVo;
import com.gitee.sop.adminserver.api.service.result.ServiceInstanceVO; import com.gitee.sop.adminserver.api.service.result.ServiceInstanceVO;
import com.gitee.sop.adminserver.bean.ChannelMsg; import com.gitee.sop.adminserver.bean.ChannelMsg;
import com.gitee.sop.adminserver.bean.MetadataEnum; import com.gitee.sop.adminserver.bean.MetadataEnum;
import com.gitee.sop.adminserver.bean.NacosConfigs; import com.gitee.sop.adminserver.bean.NacosConfigs;
import com.gitee.sop.adminserver.bean.ServiceGrayDefinition; import com.gitee.sop.adminserver.bean.ServiceGrayDefinition;
import com.gitee.sop.adminserver.bean.ServiceInfo;
import com.gitee.sop.adminserver.bean.ServiceInstance; import com.gitee.sop.adminserver.bean.ServiceInstance;
import com.gitee.sop.adminserver.common.BizException; import com.gitee.sop.adminserver.common.BizException;
import com.gitee.sop.adminserver.common.ChannelOperation; import com.gitee.sop.adminserver.common.ChannelOperation;
@ -28,17 +26,13 @@ import com.gitee.sop.adminserver.mapper.ConfigGrayMapper;
import com.gitee.sop.adminserver.mapper.ConfigServiceRouteMapper; import com.gitee.sop.adminserver.mapper.ConfigServiceRouteMapper;
import com.gitee.sop.adminserver.service.ConfigPushService; import com.gitee.sop.adminserver.service.ConfigPushService;
import com.gitee.sop.adminserver.service.RegistryService; import com.gitee.sop.adminserver.service.RegistryService;
import com.gitee.sop.adminserver.service.ServerService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -53,6 +47,9 @@ public class ServiceApi {
@Autowired @Autowired
private RegistryService registryService; private RegistryService registryService;
@Autowired
private ServerService serverService;
@Autowired @Autowired
private ConfigGrayMapper configGrayMapper; private ConfigGrayMapper configGrayMapper;
@ -96,45 +93,7 @@ public class ServiceApi {
@Api(name = "service.instance.list") @Api(name = "service.instance.list")
@ApiDocMethod(description = "获取注册中心的服务列表", elementClass = ServiceInfoVo.class) @ApiDocMethod(description = "获取注册中心的服务列表", elementClass = ServiceInfoVo.class)
List<ServiceInstanceVO> listService(ServiceSearchParam param) { List<ServiceInstanceVO> listService(ServiceSearchParam param) {
List<ServiceInfo> serviceInfos; return serverService.listService(param);
try {
serviceInfos = registryService.listAllService(1, Integer.MAX_VALUE);
} catch (Exception e) {
log.error("获取服务实例失败", e);
return Collections.emptyList();
}
List<ServiceInstanceVO> serviceInfoVoList = new ArrayList<>();
AtomicInteger idGen = new AtomicInteger(1);
serviceInfos.stream()
.filter(serviceInfo -> {
if (StringUtils.isBlank(param.getServiceId())) {
return true;
}
return StringUtils.containsIgnoreCase(serviceInfo.getServiceId(), param.getServiceId());
})
.forEach(serviceInfo -> {
int pid = idGen.getAndIncrement();
String serviceId = serviceInfo.getServiceId();
ServiceInstanceVO parent = new ServiceInstanceVO();
parent.setId(pid);
parent.setServiceId(serviceId);
parent.setParentId(0);
serviceInfoVoList.add(parent);
List<ServiceInstance> instanceList = serviceInfo.getInstances();
for (ServiceInstance instance : instanceList) {
ServiceInstanceVO instanceVO = new ServiceInstanceVO();
BeanUtils.copyProperties(instance, instanceVO);
int id = idGen.getAndIncrement();
instanceVO.setId(id);
instanceVO.setParentId(pid);
if (instanceVO.getMetadata() == null) {
instanceVO.setMetadata(Collections.emptyMap());
}
serviceInfoVoList.add(instanceVO);
}
});
return serviceInfoVoList;
} }
@Api(name = "service.instance.offline") @Api(name = "service.instance.offline")

@ -1,9 +1,8 @@
package com.gitee.sop.adminserver.service; package com.gitee.sop.adminserver.service;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
import com.alibaba.nacos.api.annotation.NacosInjected; import com.gitee.sop.adminserver.api.service.param.ServiceSearchParam;
import com.alibaba.nacos.api.config.ConfigService; import com.gitee.sop.adminserver.api.service.result.ServiceInstanceVO;
import com.alibaba.nacos.api.exception.NacosException;
import com.gitee.sop.adminserver.bean.ChannelMsg; import com.gitee.sop.adminserver.bean.ChannelMsg;
import com.gitee.sop.adminserver.bean.GatewayPushDTO; import com.gitee.sop.adminserver.bean.GatewayPushDTO;
import com.gitee.sop.adminserver.bean.HttpTool; import com.gitee.sop.adminserver.bean.HttpTool;
@ -11,13 +10,17 @@ import com.gitee.sop.adminserver.common.BizException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/** /**
* @author tanghc * @author tanghc
@ -27,11 +30,12 @@ import java.util.Map;
public class ConfigPushService { public class ConfigPushService {
private static final String GATEWAY_PUSH_URL = "http://%s/configChannelMsg"; private static final String GATEWAY_PUSH_URL = "http://%s/configChannelMsg";
private static final String API_GATEWAY_SERVICE_ID = "api-gateway";
private static HttpTool httpTool = new HttpTool(); private static HttpTool httpTool = new HttpTool();
@NacosInjected @Autowired
private ConfigService configService; private ServerService serverService;
@Value("${gateway.host:}") @Value("${gateway.host:}")
private String gatewayHost; private String gatewayHost;
@ -40,34 +44,38 @@ public class ConfigPushService {
private String secret; private String secret;
public void publishConfig(String dataId, String groupId, ChannelMsg channelMsg) { public void publishConfig(String dataId, String groupId, ChannelMsg channelMsg) {
if (StringUtils.isNotBlank(gatewayHost)) { GatewayPushDTO gatewayPushDTO = new GatewayPushDTO(dataId, groupId, channelMsg);
String[] hosts = gatewayHost.split(","); ServiceSearchParam serviceSearchParam = new ServiceSearchParam();
for (String host : hosts) { serviceSearchParam.setServiceId(API_GATEWAY_SERVICE_ID);
GatewayPushDTO gatewayPushDTO = new GatewayPushDTO(dataId, groupId, channelMsg); List<ServiceInstanceVO> serviceInstanceList = serverService.listService(serviceSearchParam);
String url = String.format(GATEWAY_PUSH_URL, host); Collection<String> hostList = serviceInstanceList
try { .stream()
String requestBody = JSON.toJSONString(gatewayPushDTO); .map(ServiceInstanceVO::getIpPort)
Map<String, String> header = new HashMap<>(8); .collect(Collectors.toList());
header.put("sign", buildRequestBodySign(requestBody, secret)); this.pushByHost(hostList, gatewayPushDTO);
String resp = httpTool.requestJson(url, requestBody, header); }
if (!"ok".equals(resp)) {
throw new IOException(resp); private void pushByHost(Collection<String> hosts, GatewayPushDTO gatewayPushDTO) {
} for (String host : hosts) {
} catch (IOException e) { String url = String.format(GATEWAY_PUSH_URL, host);
log.error("nacos配置失败, dataId={}, groupId={}, operation={}, url={}", dataId, groupId, channelMsg.getOperation(), url, e);
throw new BizException("推送配置失败");
}
}
} else {
try { try {
log.info("nacos配置, dataId={}, groupId={}, operation={}", dataId, groupId, channelMsg.getOperation()); String requestBody = JSON.toJSONString(gatewayPushDTO);
configService.publishConfig(dataId, groupId, JSON.toJSONString(channelMsg)); Map<String, String> header = new HashMap<>(8);
} catch (NacosException e) { header.put("sign", buildRequestBodySign(requestBody, secret));
log.error("nacos配置失败, dataId={}, groupId={}, operation={}", dataId, groupId, channelMsg.getOperation(), e); String resp = httpTool.requestJson(url, requestBody, header);
throw new BizException("nacos配置失败"); if (!"ok".equals(resp)) {
throw new IOException(resp);
}
} catch (IOException e) {
log.error("nacos配置失败, dataId={}, groupId={}, operation={}, url={}",
gatewayPushDTO.getDataId()
, gatewayPushDTO.getGroupId()
, gatewayPushDTO.getChannelMsg().getOperation()
, url
, e);
throw new BizException("推送配置失败");
} }
} }
} }
public static String buildRequestBodySign(String requestBody, String secret) { public static String buildRequestBodySign(String requestBody, String secret) {

@ -0,0 +1,69 @@
package com.gitee.sop.adminserver.service;
import com.gitee.sop.adminserver.api.service.param.ServiceSearchParam;
import com.gitee.sop.adminserver.api.service.result.ServiceInstanceVO;
import com.gitee.sop.adminserver.bean.ServiceInfo;
import com.gitee.sop.adminserver.bean.ServiceInstance;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author tanghc
*/
@Slf4j
@Service
public class ServerService {
@Autowired
private RegistryService registryService;
public List<ServiceInstanceVO> listService(ServiceSearchParam param) {
List<ServiceInfo> serviceInfos;
try {
serviceInfos = registryService.listAllService(1, Integer.MAX_VALUE);
} catch (Exception e) {
log.error("获取服务实例失败", e);
return Collections.emptyList();
}
List<ServiceInstanceVO> serviceInfoVoList = new ArrayList<>();
AtomicInteger idGen = new AtomicInteger(1);
serviceInfos.stream()
.filter(serviceInfo -> {
if (StringUtils.isBlank(param.getServiceId())) {
return true;
}
return StringUtils.containsIgnoreCase(serviceInfo.getServiceId(), param.getServiceId());
})
.forEach(serviceInfo -> {
int pid = idGen.getAndIncrement();
String serviceId = serviceInfo.getServiceId();
ServiceInstanceVO parent = new ServiceInstanceVO();
parent.setId(pid);
parent.setServiceId(serviceId);
parent.setParentId(0);
serviceInfoVoList.add(parent);
List<ServiceInstance> instanceList = serviceInfo.getInstances();
for (ServiceInstance instance : instanceList) {
ServiceInstanceVO instanceVO = new ServiceInstanceVO();
BeanUtils.copyProperties(instance, instanceVO);
int id = idGen.getAndIncrement();
instanceVO.setId(id);
instanceVO.setParentId(pid);
if (instanceVO.getMetadata() == null) {
instanceVO.setMetadata(Collections.emptyMap());
}
serviceInfoVoList.add(instanceVO);
}
});
return serviceInfoVoList;
}
}

@ -66,12 +66,6 @@ public class AbstractConfiguration implements ApplicationContextAware {
return new SopPropertiesFactory(); return new SopPropertiesFactory();
} }
@Bean
@ConditionalOnClass(name = "com.alibaba.nacos.api.config.ConfigService")
public NacosEventProcessor nacosEventProcessor() {
return new NacosEventProcessor();
}
/** /**
* 微服务路由加载 * 微服务路由加载
*/ */

@ -5,7 +5,6 @@ import com.alibaba.nacos.api.annotation.NacosInjected;
import com.alibaba.nacos.api.config.ConfigService; import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.AbstractListener; import com.alibaba.nacos.api.config.listener.AbstractListener;
import com.alibaba.nacos.api.exception.NacosException; import com.alibaba.nacos.api.exception.NacosException;
import com.gitee.sop.gatewaycommon.bean.ApiConfig;
import com.gitee.sop.gatewaycommon.bean.ChannelMsg; import com.gitee.sop.gatewaycommon.bean.ChannelMsg;
import com.gitee.sop.gatewaycommon.bean.NacosConfigs; import com.gitee.sop.gatewaycommon.bean.NacosConfigs;
import com.gitee.sop.gatewaycommon.secret.IsvManager; import com.gitee.sop.gatewaycommon.secret.IsvManager;
@ -15,9 +14,12 @@ import org.springframework.beans.factory.annotation.Autowired;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
/** /**
* 用不到了
*
* @author tanghc * @author tanghc
*/ */
@Slf4j @Slf4j
@Deprecated
public class NacosEventProcessor { public class NacosEventProcessor {
@NacosInjected @NacosInjected
@ -56,8 +58,8 @@ public class NacosEventProcessor {
configService.addListener(NacosConfigs.DATA_ID_GRAY, NacosConfigs.GROUP_CHANNEL, new AbstractListener() { configService.addListener(NacosConfigs.DATA_ID_GRAY, NacosConfigs.GROUP_CHANNEL, new AbstractListener() {
@Override @Override
public void receiveConfigInfo(String configInfo) { public void receiveConfigInfo(String configInfo) {
ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class); ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class);
envGrayManager.process(channelMsg); envGrayManager.process(channelMsg);
} }
}); });
} }
@ -66,8 +68,8 @@ public class NacosEventProcessor {
configService.addListener(NacosConfigs.DATA_ID_IP_BLACKLIST, NacosConfigs.GROUP_CHANNEL, new AbstractListener() { configService.addListener(NacosConfigs.DATA_ID_IP_BLACKLIST, NacosConfigs.GROUP_CHANNEL, new AbstractListener() {
@Override @Override
public void receiveConfigInfo(String configInfo) { public void receiveConfigInfo(String configInfo) {
ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class); ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class);
ipBlacklistManager.process(channelMsg); ipBlacklistManager.process(channelMsg);
} }
}); });
} }
@ -76,8 +78,8 @@ public class NacosEventProcessor {
configService.addListener(NacosConfigs.DATA_ID_ISV, NacosConfigs.GROUP_CHANNEL, new AbstractListener() { configService.addListener(NacosConfigs.DATA_ID_ISV, NacosConfigs.GROUP_CHANNEL, new AbstractListener() {
@Override @Override
public void receiveConfigInfo(String configInfo) { public void receiveConfigInfo(String configInfo) {
ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class); ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class);
isvManager.process(channelMsg); isvManager.process(channelMsg);
} }
}); });
} }
@ -86,8 +88,8 @@ public class NacosEventProcessor {
configService.addListener(NacosConfigs.DATA_ID_ROUTE_PERMISSION, NacosConfigs.GROUP_CHANNEL, new AbstractListener() { configService.addListener(NacosConfigs.DATA_ID_ROUTE_PERMISSION, NacosConfigs.GROUP_CHANNEL, new AbstractListener() {
@Override @Override
public void receiveConfigInfo(String configInfo) { public void receiveConfigInfo(String configInfo) {
ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class); ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class);
isvRoutePermissionManager.process(channelMsg); isvRoutePermissionManager.process(channelMsg);
} }
}); });
} }
@ -96,7 +98,7 @@ public class NacosEventProcessor {
configService.addListener(NacosConfigs.DATA_ID_LIMIT_CONFIG, NacosConfigs.GROUP_CHANNEL, new AbstractListener() { configService.addListener(NacosConfigs.DATA_ID_LIMIT_CONFIG, NacosConfigs.GROUP_CHANNEL, new AbstractListener() {
@Override @Override
public void receiveConfigInfo(String configInfo) { public void receiveConfigInfo(String configInfo) {
ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class); ChannelMsg channelMsg = JSON.parseObject(configInfo, ChannelMsg.class);
limitConfigManager.process(channelMsg); limitConfigManager.process(channelMsg);
} }
}); });

@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `config_gray_instance`;
DROP TABLE IF EXISTS `config_gray`; DROP TABLE IF EXISTS `config_gray`;
DROP TABLE IF EXISTS `config_common`; DROP TABLE IF EXISTS `config_common`;
DROP TABLE IF EXISTS `admin_user_info`; DROP TABLE IF EXISTS `admin_user_info`;
DROP TABLE IF EXISTS `config_service_route`;
CREATE TABLE `admin_user_info` ( CREATE TABLE `admin_user_info` (
@ -211,7 +212,25 @@ CREATE TABLE `user_info` (
KEY `idx_unamepwd` (`username`,`password`) USING BTREE KEY `idx_unamepwd` (`username`,`password`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='用户信息表'; ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='用户信息表';
CREATE TABLE `config_service_route` (
`id` varchar(128) NOT NULL DEFAULT '' COMMENT '路由id',
`service_id` varchar(128) NOT NULL DEFAULT '',
`name` varchar(128) NOT NULL DEFAULT '' COMMENT '接口名',
`version` varchar(64) NOT NULL DEFAULT '' COMMENT '版本号',
`predicates` varchar(256) DEFAULT NULL COMMENT '路由断言(SpringCloudGateway专用)',
`filters` varchar(256) DEFAULT NULL COMMENT '路由过滤器(SpringCloudGateway专用)',
`uri` varchar(128) NOT NULL DEFAULT '' COMMENT '路由规则转发的目标uri',
`path` varchar(128) NOT NULL DEFAULT '' COMMENT 'uri后面跟的path',
`order` int(11) NOT NULL DEFAULT '0' COMMENT '路由执行的顺序',
`ignore_validate` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否忽略验证,业务参数验证除外',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态,0:待审核,1:启用,2:禁用',
`merge_result` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否合并结果',
`permission` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否需要授权才能访问',
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP,
`gmt_modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_serviceid` (`service_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='路由配置';
SET FOREIGN_KEY_CHECKS = @PREVIOUS_FOREIGN_KEY_CHECKS; SET FOREIGN_KEY_CHECKS = @PREVIOUS_FOREIGN_KEY_CHECKS;

Loading…
Cancel
Save