Browse Source

Merge branch 'develop/3.6' of github.com:thingsboard/thingsboard into develop/3.6

pull/9240/head
Igor Kulikov 3 years ago
parent
commit
c223995a80
  1. 7
      application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
  2. 44
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
  3. 2
      application/src/main/resources/logback.xml
  4. 2
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleNodeDao.java
  5. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java
  6. 13
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleNodeRepository.java
  7. 158
      dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java
  8. 10
      ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts
  9. 14
      ui-ngx/src/app/shared/models/edge.models.ts
  10. 5
      ui-ngx/src/assets/locale/locale.constant-en_US.json

7
application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java

@ -756,6 +756,13 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_rule_node_type_configuration_version ON rule_node(type, configuration_version);");
} catch (Exception e) {
}
try {
conn.createStatement().execute("UPDATE rule_node SET " +
"configuration = (configuration::jsonb || '{\"updateAttributesOnlyOnValueChange\": \"false\"}'::jsonb)::varchar, " +
"configuration_version = 1 " +
"WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode' AND configuration_version < 1;");
} catch (Exception e) {
}
try {
conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_unread ON notification(recipient_id) WHERE status <> 'READ';");
} catch (Exception e) {

44
application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java

@ -78,6 +78,7 @@ import org.thingsboard.server.dao.model.sql.DeviceProfileEntity;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import org.thingsboard.server.dao.sql.device.DeviceProfileRepository;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.tenant.TenantService;
@ -101,6 +102,8 @@ import static org.thingsboard.server.common.data.StringUtils.isBlank;
@Slf4j
public class DefaultDataUpdateService implements DataUpdateService {
private static final int MAX_PENDING_SAVE_RULE_NODE_FUTURES = 100;
@Autowired
private TenantService tenantService;
@ -153,6 +156,9 @@ public class DefaultDataUpdateService implements DataUpdateService {
@Autowired
private EdgeEventDao edgeEventDao;
@Autowired
JpaExecutorService jpaExecutorService;
@Override
public void updateData(String fromVersion) throws Exception {
switch (fromVersion) {
@ -225,13 +231,15 @@ public class DefaultDataUpdateService implements DataUpdateService {
@Override
public void upgradeRuleNodes() {
try {
var futures = new ArrayList<ListenableFuture<?>>(100);
int totalRuleNodesUpgraded = 0;
log.info("Starting rule nodes upgrade ...");
var nodeClassToVersionMap = componentDiscoveryService.getVersionedNodes();
log.debug("Found {} versioned nodes to check for upgrade!", nodeClassToVersionMap.size());
nodeClassToVersionMap.forEach(clazz -> {
var ruleNodeType = clazz.getClassName();
var ruleNodeTypeForLogs = clazz.getSimpleName();
var toVersion = clazz.getCurrentVersion();
for (var ruleNodeClassInfo : nodeClassToVersionMap) {
var ruleNodeType = ruleNodeClassInfo.getClassName();
var ruleNodeTypeForLogs = ruleNodeClassInfo.getSimpleName();
var toVersion = ruleNodeClassInfo.getCurrentVersion();
log.debug("Going to check for nodes with type: {} to upgrade to version: {}.", ruleNodeTypeForLogs, toVersion);
var ruleNodesToUpdate = new PageDataIterable<>(
pageLink -> ruleChainService.findAllRuleNodesByTypeAndVersionLessThan(ruleNodeType, toVersion, pageLink), 1024
@ -246,28 +254,44 @@ public class DefaultDataUpdateService implements DataUpdateService {
log.debug("Going to upgrade rule node with id: {} type: {} fromVersion: {} toVersion: {}",
ruleNodeId, ruleNodeTypeForLogs, fromVersion, toVersion);
try {
var tbVersionedNode = (TbNode) clazz.getClazz().getDeclaredConstructor().newInstance();
var tbVersionedNode = (TbNode) ruleNodeClassInfo.getClazz().getDeclaredConstructor().newInstance();
TbPair<Boolean, JsonNode> upgradeRuleNodeConfigurationResult = tbVersionedNode.upgrade(fromVersion, oldConfiguration);
if (upgradeRuleNodeConfigurationResult.getFirst()) {
ruleNode.setConfiguration(upgradeRuleNodeConfigurationResult.getSecond());
}
ruleNode.setConfigurationVersion(toVersion);
ruleChainService.saveRuleNode(TenantId.SYS_TENANT_ID, ruleNode);
log.debug("Successfully upgrade rule node with id: {} type: {} fromVersion: {} toVersion: {}",
ruleNodeId, ruleNodeTypeForLogs, fromVersion, toVersion);
futures.add(jpaExecutorService.submit(() -> {
ruleChainService.saveRuleNode(TenantId.SYS_TENANT_ID, ruleNode);
log.debug("Successfully upgrade rule node with id: {} type: {} fromVersion: {} toVersion: {}",
ruleNodeId, ruleNodeTypeForLogs, fromVersion, toVersion);
}));
if (futures.size() >= MAX_PENDING_SAVE_RULE_NODE_FUTURES) {
log.info("{} upgraded rule nodes so far ...",
totalRuleNodesUpgraded += awaitFuturesToCompleteAndGetCount(futures));
futures.clear();
}
} catch (Exception e) {
log.warn("Failed to upgrade rule node with id: {} type: {} fromVersion: {} toVersion: {} due to: ",
ruleNodeId, ruleNodeTypeForLogs, fromVersion, toVersion, e);
}
}
}
});
log.info("Finished rule nodes upgrade!");
}
log.info("Finished rule nodes upgrade. Upgraded rule nodes count: {}",
totalRuleNodesUpgraded + awaitFuturesToCompleteAndGetCount(futures));
} catch (Exception e) {
log.error("Unexpected error during rule nodes upgrade: ", e);
}
}
private int awaitFuturesToCompleteAndGetCount(List<ListenableFuture<?>> futures) {
try {
return Futures.allAsList(futures).get().size();
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException("Failed to process save rule nodes requests due to: ", e);
}
}
private final PaginatedUpdater<String, DeviceProfileEntity> deviceProfileEntityDynamicConditionsUpdater =
new PaginatedUpdater<>() {

2
application/src/main/resources/logback.xml

@ -30,6 +30,8 @@
<logger name="org.apache.kafka.clients" level="WARN"/>
<!-- To enable the logging of scanned rule engine components-->
<!-- <logger name="org.thingsboard.server.service.component.AnnotationComponentDiscoveryService" level="DEBUG" />-->
<!-- To enable the debug logging of rule node upgrade -->
<!-- <logger name="org.thingsboard.server.service.install.update.DefaultDataUpdateService" level="DEBUG"/>-->
<!-- Other useful logs -->
<!-- <logger name="org.springframework.jdbc.core" level="TRACE" />-->
<!-- <logger name="org.hibernate.SQL" level="DEBUG" />-->

2
dao/src/main/java/org/thingsboard/server/dao/rule/RuleNodeDao.java

@ -30,7 +30,7 @@ import java.util.List;
*/
public interface RuleNodeDao extends Dao<RuleNode> {
List<RuleNode> findRuleNodesByTenantIdAndType(TenantId tenantId, String type, String search);
List<RuleNode> findRuleNodesByTenantIdAndType(TenantId tenantId, String type, String configurationSearch);
PageData<RuleNode> findAllRuleNodesByType(String type, PageLink pageLink);

4
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java

@ -56,8 +56,8 @@ public class JpaRuleNodeDao extends JpaAbstractDao<RuleNodeEntity, RuleNode> imp
}
@Override
public List<RuleNode> findRuleNodesByTenantIdAndType(TenantId tenantId, String type, String search) {
return DaoUtil.convertDataList(ruleNodeRepository.findRuleNodesByTenantIdAndType(tenantId.getId(), type, search));
public List<RuleNode> findRuleNodesByTenantIdAndType(TenantId tenantId, String type, String configurationSearch) {
return DaoUtil.convertDataList(ruleNodeRepository.findRuleNodesByTenantIdAndType(tenantId.getId(), type, configurationSearch));
}
@Override

13
dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleNodeRepository.java

@ -29,19 +29,22 @@ import java.util.UUID;
public interface RuleNodeRepository extends JpaRepository<RuleNodeEntity, UUID> {
@Query("SELECT r FROM RuleNodeEntity r WHERE r.ruleChainId in " +
"(select id from RuleChainEntity rc WHERE rc.tenantId = :tenantId) " +
"AND r.type = :ruleType AND LOWER(r.configuration) LIKE LOWER(CONCAT('%', :searchText, '%')) ")
@Query(nativeQuery = true, value = "SELECT * FROM rule_node r WHERE r.rule_chain_id in " +
"(select id from rule_chain rc WHERE rc.tenant_id = :tenantId) AND r.type = :ruleType " +
" AND (:searchText IS NULL OR r.configuration ILIKE CONCAT('%', :searchText, '%'))")
List<RuleNodeEntity> findRuleNodesByTenantIdAndType(@Param("tenantId") UUID tenantId,
@Param("ruleType") String ruleType,
@Param("searchText") String searchText);
@Query("SELECT r FROM RuleNodeEntity r WHERE r.type = :ruleType AND LOWER(r.configuration) LIKE LOWER(CONCAT('%', :searchText, '%')) ")
@Query(nativeQuery = true, value = "SELECT * FROM rule_node r WHERE r.type = :ruleType " +
" AND (:searchText IS NULL OR r.configuration ILIKE CONCAT('%', :searchText, '%'))")
Page<RuleNodeEntity> findAllRuleNodesByType(@Param("ruleType") String ruleType,
@Param("searchText") String searchText,
Pageable pageable);
@Query("SELECT r FROM RuleNodeEntity r WHERE r.type = :ruleType AND r.configurationVersion < :version AND LOWER(r.configuration) LIKE LOWER(CONCAT('%', :searchText, '%')) ")
@Query(nativeQuery = true, value = "SELECT * FROM rule_node r WHERE r.type = :ruleType " +
" AND configuration_version < :version " +
" AND (:searchText IS NULL OR r.configuration ILIKE CONCAT('%', :searchText, '%'))")
Page<RuleNodeEntity> findAllRuleNodesByTypeAndVersionLessThan(@Param("ruleType") String ruleType,
@Param("version") int version,
@Param("searchText") String searchText,

158
dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java

@ -0,0 +1,158 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.sql.rule;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.common.util.concurrent.ListeningExecutorService;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.dao.AbstractJpaDaoTest;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.rule.RuleNodeDao;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class JpaRuleNodeDaoTest extends AbstractJpaDaoTest {
public static final int COUNT = 40;
public static final String PREFIX_FOR_RULE_NODE_NAME = "SEARCH_TEXT_";
List<UUID> ruleNodeIds;
TenantId tenantId1;
TenantId tenantId2;
RuleChainId ruleChainId1;
RuleChainId ruleChainId2;
@Autowired
private RuleChainDao ruleChainDao;
@Autowired
private RuleNodeDao ruleNodeDao;
ListeningExecutorService executor;
@Before
public void setUp() {
tenantId1 = TenantId.fromUUID(Uuids.timeBased());
ruleChainId1 = new RuleChainId(UUID.randomUUID());
tenantId2 = TenantId.fromUUID(Uuids.timeBased());
ruleChainId2 = new RuleChainId(UUID.randomUUID());
ruleNodeIds = createRuleNodes(tenantId1, tenantId2, ruleChainId1, ruleChainId2, COUNT);
}
@After
public void tearDown() throws Exception {
ruleNodeDao.removeAllByIds(ruleNodeIds);
if (executor != null) {
executor.shutdownNow();
}
}
@Test
public void testSaveRuleName0x00_thenSomeDatabaseException() {
RuleNode ruleNode = getRuleNode(ruleChainId1, "T", "\u0000");
assertThatThrownBy(() -> ruleNodeIds.add(ruleNodeDao.save(tenantId1, ruleNode).getUuidId()));
}
@Test
public void testFindRuleNodesByTenantIdAndType() {
List<RuleNode> ruleNodes1 = ruleNodeDao.findRuleNodesByTenantIdAndType(tenantId1, "A", PREFIX_FOR_RULE_NODE_NAME);
assertEquals(20, ruleNodes1.size());
List<RuleNode> ruleNodes2 = ruleNodeDao.findRuleNodesByTenantIdAndType(tenantId2, "B", PREFIX_FOR_RULE_NODE_NAME);
assertEquals(20, ruleNodes2.size());
ruleNodes1 = ruleNodeDao.findRuleNodesByTenantIdAndType(tenantId1, "A", null);
assertEquals(20, ruleNodes1.size());
ruleNodes2 = ruleNodeDao.findRuleNodesByTenantIdAndType(tenantId2, "B", null);
assertEquals(20, ruleNodes2.size());
}
@Test
public void testFindRuleNodesByType() {
PageData<RuleNode> ruleNodes = ruleNodeDao.findAllRuleNodesByType( "A", new PageLink(10, 0, PREFIX_FOR_RULE_NODE_NAME));
assertEquals(20, ruleNodes.getTotalElements());
assertEquals(2, ruleNodes.getTotalPages());
assertEquals(10, ruleNodes.getData().size());
ruleNodes = ruleNodeDao.findAllRuleNodesByType( "A", new PageLink(10, 0));
assertEquals(20, ruleNodes.getTotalElements());
assertEquals(2, ruleNodes.getTotalPages());
assertEquals(10, ruleNodes.getData().size());
}
@Test
public void testFindRuleNodesByTypeAndVersionLessThan() {
PageData<RuleNode> ruleNodes = ruleNodeDao.findAllRuleNodesByTypeAndVersionLessThan( "A", 1, new PageLink(10, 0, PREFIX_FOR_RULE_NODE_NAME));
assertEquals(20, ruleNodes.getTotalElements());
assertEquals(2, ruleNodes.getTotalPages());
assertEquals(10, ruleNodes.getData().size());
ruleNodes = ruleNodeDao.findAllRuleNodesByTypeAndVersionLessThan( "A", 1, new PageLink(10, 0));
assertEquals(20, ruleNodes.getTotalElements());
assertEquals(2, ruleNodes.getTotalPages());
assertEquals(10, ruleNodes.getData().size());
}
private List<UUID> createRuleNodes(TenantId tenantId1, TenantId tenantId2, RuleChainId ruleChainId1, RuleChainId ruleChainId2, int count) {
var chain1 = new RuleChain(ruleChainId1);
chain1.setTenantId(tenantId1);
chain1.setName(ruleChainId1.toString());
ruleChainDao.save(tenantId1, chain1);
var chain2 = new RuleChain(ruleChainId2);
chain2.setTenantId(tenantId2);
chain2.setName(ruleChainId2.toString());
ruleChainDao.save(tenantId2, chain2);
List<UUID> savedRuleNodeIds = new ArrayList<>();
for (int i = 0; i < count / 2; i++) {
savedRuleNodeIds.add(ruleNodeDao.save(tenantId1, getRuleNode(ruleChainId1, "A", Integer.toString(i))).getUuidId());
savedRuleNodeIds.add(ruleNodeDao.save(tenantId2, getRuleNode(ruleChainId2, "B", Integer.toString(i + count / 2))).getUuidId());
}
return savedRuleNodeIds;
}
private RuleNode getRuleNode(RuleChainId ruleChainId, String type, String nameSuffix) {
return getRuleNode(ruleChainId, Uuids.timeBased(), type, nameSuffix);
}
private RuleNode getRuleNode(RuleChainId ruleChainId, UUID ruleNodeId, String type, String nameSuffix) {
RuleNode ruleNode = new RuleNode();
ruleNode.setId(new RuleNodeId(ruleNodeId));
ruleNode.setRuleChainId(ruleChainId);
ruleNode.setName(nameSuffix);
ruleNode.setType(type);
ruleNode.setConfiguration(JacksonUtil.newObjectNode().put("searchHint", PREFIX_FOR_RULE_NODE_NAME + nameSuffix));
ruleNode.setConfigurationVersion(0);
return ruleNode;
}
}

10
ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts

@ -107,9 +107,15 @@ export class EdgeDownlinkTableConfig extends EntityTableConfig<EdgeEvent, TimePa
this.columns.push(
new DateEntityTableColumn<EdgeEvent>('createdTime', 'event.event-time', this.datePipe, '120px'),
new EntityTableColumn<EdgeEvent>('type', 'event.type', '25%',
entity => this.translate.instant(edgeEventTypeTranslations.get(entity.type)), entity => ({}), false),
entity => {
let key = edgeEventTypeTranslations.get(entity.type);
return key ? this.translate.instant(key) : entity.type;
}, entity => ({}), false),
new EntityTableColumn<EdgeEvent>('action', 'edge.event-action', '25%',
entity => this.translate.instant(edgeEventActionTypeTranslations.get(entity.action)), entity => ({}), false),
entity => {
let key = edgeEventActionTypeTranslations.get(entity.action);
return key ? this.translate.instant(key) : entity.action;
}, entity => ({}), false),
new EntityTableColumn<EdgeEvent>('entityId', 'edge.entity-id', '40%',
(entity) => entity.entityId ? entity.entityId : '', () => ({}), false),
new EntityTableColumn<EdgeEvent>('status', 'event.status', '10%',

14
ui-ngx/src/app/shared/models/edge.models.ts

@ -59,9 +59,12 @@ export enum EdgeEventType {
CUSTOMER = 'CUSTOMER',
RELATION = 'RELATION',
TENANT = 'TENANT',
TENANT_PROFILE = 'TENANT_PROFILE',
WIDGETS_BUNDLE = 'WIDGETS_BUNDLE',
WIDGET_TYPE = 'WIDGET_TYPE',
ADMIN_SETTINGS = 'ADMIN_SETTINGS'
ADMIN_SETTINGS = 'ADMIN_SETTINGS',
OTA_PACKAGE = 'OTA_PACKAGE',
QUEUE = 'QUEUE'
}
export enum EdgeEventActionType {
@ -80,6 +83,8 @@ export enum EdgeEventActionType {
RPC_CALL = 'RPC_CALL',
ALARM_ACK = 'ALARM_ACK',
ALARM_CLEAR = 'ALARM_CLEAR',
ALARM_ASSIGNED = 'ALARM_ASSIGNED',
ALARM_UNASSIGNED = 'ALARM_UNASSIGNED',
ASSIGNED_TO_EDGE = 'ASSIGNED_TO_EDGE',
UNASSIGNED_FROM_EDGE = 'UNASSIGNED_FROM_EDGE',
CREDENTIALS_REQUEST = 'CREDENTIALS_REQUEST',
@ -107,9 +112,12 @@ export const edgeEventTypeTranslations = new Map<EdgeEventType, string>(
[EdgeEventType.CUSTOMER, 'edge-event.type-customer'],
[EdgeEventType.RELATION, 'edge-event.type-relation'],
[EdgeEventType.TENANT, 'edge-event.type-tenant'],
[EdgeEventType.TENANT_PROFILE, 'edge-event.type-tenant-profile'],
[EdgeEventType.WIDGETS_BUNDLE, 'edge-event.type-widgets-bundle'],
[EdgeEventType.WIDGET_TYPE, 'edge-event.type-widgets-type'],
[EdgeEventType.ADMIN_SETTINGS, 'edge-event.type-admin-settings']
[EdgeEventType.ADMIN_SETTINGS, 'edge-event.type-admin-settings'],
[EdgeEventType.OTA_PACKAGE, 'edge-event.type-ota-package'],
[EdgeEventType.QUEUE, 'edge-event.type-queue']
]
);
@ -130,6 +138,8 @@ export const edgeEventActionTypeTranslations = new Map<EdgeEventActionType, stri
[EdgeEventActionType.RPC_CALL, 'edge-event.action-type-rpc-call'],
[EdgeEventActionType.ALARM_ACK, 'edge-event.action-type-alarm-ack'],
[EdgeEventActionType.ALARM_CLEAR, 'edge-event.action-type-alarm-clear'],
[EdgeEventActionType.ALARM_ASSIGNED, 'edge-event.action-type-alarm-assigned'],
[EdgeEventActionType.ALARM_UNASSIGNED, 'edge-event.action-type-alarm-unassigned'],
[EdgeEventActionType.ASSIGNED_TO_EDGE, 'edge-event.action-type-assigned-to-edge'],
[EdgeEventActionType.UNASSIGNED_FROM_EDGE, 'edge-event.action-type-unassigned-from-edge'],
[EdgeEventActionType.CREDENTIALS_REQUEST, 'edge-event.action-type-credentials-request'],

5
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2014,11 +2014,14 @@
"type-edge": "Edge",
"type-user": "User",
"type-tenant": "Tenant",
"type-tenant-profile": "Tenant Profile",
"type-customer": "Customer",
"type-relation": "Relation",
"type-widgets-bundle": "Widgets Bundle",
"type-widgets-type": "Widgets Type",
"type-admin-settings": "Admin Settings",
"type-ota-package": "Ota Package",
"type-queue": "Queue",
"action-type-added": "Added",
"action-type-deleted": "Deleted",
"action-type-updated": "Updated",
@ -2034,6 +2037,8 @@
"action-type-rpc-call": "RPC Call",
"action-type-alarm-ack": "Alarm Ack",
"action-type-alarm-clear": "Alarm Clear",
"action-type-alarm-assigned": "Alarm Assigned",
"action-type-alarm-unassigned": "Alarm Unassigned",
"action-type-assigned-to-edge": "Assigned to Edge",
"action-type-unassigned-from-edge": "Unassigned from Edge",
"action-type-credentials-request": "Credentials Request",

Loading…
Cancel
Save