Browse Source

Merge with main branch

pull/6591/head
Andrii Shvaika 4 years ago
parent
commit
db34f7766f
  1. 8
      application/src/main/java/org/thingsboard/server/controller/AdminController.java
  2. 30
      application/src/main/java/org/thingsboard/server/service/sync/vc/ClearRepositoryGitRequest.java
  3. 4
      application/src/main/java/org/thingsboard/server/service/sync/vc/CommitGitRequest.java
  4. 33
      application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java
  5. 76
      application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/sync/vc/LocalGitVersionControlService.java
  7. 4
      application/src/main/java/org/thingsboard/server/service/sync/vc/PendingGitRequest.java
  8. 1
      application/src/main/resources/thingsboard.yml
  9. 11
      common/cluster-api/src/main/proto/queue.proto
  10. 5
      common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbApplicationEventListener.java
  11. 5
      common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java
  12. 161
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java
  13. 24
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java
  14. 9
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java
  15. 2
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepositoryService.java
  16. 12
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/PendingCommit.java
  17. 2
      dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java
  18. 20
      ui-ngx/src/app/core/http/admin.service.ts
  19. 40
      ui-ngx/src/app/core/http/entities-version-control.service.ts
  20. 14
      ui-ngx/src/app/core/services/menu.service.ts
  21. 7
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  22. 6
      ui-ngx/src/app/modules/home/components/profile/device-profile.component.html
  23. 77
      ui-ngx/src/app/modules/home/components/vc/vc-entity-export-dialog.component.html
  24. 105
      ui-ngx/src/app/modules/home/components/vc/vc-entity-export-dialog.component.ts
  25. 17
      ui-ngx/src/app/modules/home/dialogs/home-dialogs.service.ts
  26. 14
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  27. 4
      ui-ngx/src/app/modules/home/pages/admin/admin.module.ts
  28. 110
      ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.html
  29. 33
      ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.scss
  30. 198
      ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.ts
  31. 6
      ui-ngx/src/app/modules/home/pages/asset/asset.component.html
  32. 10
      ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts
  33. 6
      ui-ngx/src/app/modules/home/pages/customer/customer.component.html
  34. 12
      ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts
  35. 6
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html
  36. 12
      ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts
  37. 12
      ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts
  38. 6
      ui-ngx/src/app/modules/home/pages/device/device.component.html
  39. 10
      ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts
  40. 6
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html
  41. 12
      ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts
  42. 43
      ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html
  43. 199
      ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts
  44. 3
      ui-ngx/src/app/shared/models/constants.ts
  45. 21
      ui-ngx/src/app/shared/models/settings.models.ts
  46. 55
      ui-ngx/src/app/shared/models/vc.models.ts
  47. 7
      ui-ngx/src/app/shared/shared.module.ts
  48. 36
      ui-ngx/src/assets/locale/locale.constant-en_US.json

8
application/src/main/java/org/thingsboard/server/controller/AdminController.java

@ -207,10 +207,14 @@ public class AdminController extends BaseController {
notes = "Creates or Updates the version control settings object. " + TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/vcSettings")
public void saveVersionControlSettings(@RequestBody EntitiesVersionControlSettings settings) throws ThingsboardException {
public EntitiesVersionControlSettings saveVersionControlSettings(@RequestBody EntitiesVersionControlSettings settings) throws ThingsboardException {
try {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE);
versionControlService.saveVersionControlSettings(getTenantId(), settings);
EntitiesVersionControlSettings versionControlSettings = checkNotNull(versionControlService.saveVersionControlSettings(getTenantId(), settings));
versionControlSettings.setPassword(null);
versionControlSettings.setPrivateKey(null);
versionControlSettings.setPrivateKeyPassword(null);
return versionControlSettings;
} catch (Exception e) {
throw handleException(e);
}

30
application/src/main/java/org/thingsboard/server/service/sync/vc/ClearRepositoryGitRequest.java

@ -0,0 +1,30 @@
/**
* Copyright © 2016-2022 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.service.sync.vc;
import org.thingsboard.server.common.data.id.TenantId;
public class ClearRepositoryGitRequest extends VoidGitRequest {
public ClearRepositoryGitRequest(TenantId tenantId) {
super(tenantId);
}
public boolean requiresSettings() {
return false;
}
}

4
application/src/main/java/org/thingsboard/server/service/sync/vc/CommitGitRequest.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.service.sync.vc;
import lombok.Getter;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.sync.vc.VersionCreationResult;
import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest;
@ -23,10 +24,13 @@ import java.util.UUID;
public class CommitGitRequest extends PendingGitRequest<VersionCreationResult> {
@Getter
private final UUID txId;
private final VersionCreateRequest request;
public CommitGitRequest(TenantId tenantId, VersionCreateRequest request) {
super(tenantId);
this.txId = UUID.randomUUID();
this.request = request;
}

33
application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java

@ -321,11 +321,21 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
@Override
public EntitiesVersionControlSettings saveVersionControlSettings(TenantId tenantId, EntitiesVersionControlSettings versionControlSettings) {
EntitiesVersionControlSettings storedSettings = getVersionControlSettings(tenantId);
AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(tenantId, SETTINGS_KEY);
EntitiesVersionControlSettings storedSettings = null;
if (adminSettings != null) {
try {
storedSettings = JacksonUtil.convertValue(adminSettings.getJsonValue(), EntitiesVersionControlSettings.class);
} catch (Exception e) {
throw new RuntimeException("Failed to load version control settings!", e);
}
}
versionControlSettings = this.restoreCredentials(versionControlSettings, storedSettings);
AdminSettings adminSettings = new AdminSettings();
adminSettings.setTenantId(tenantId);
adminSettings.setKey(SETTINGS_KEY);
if (adminSettings == null) {
adminSettings = new AdminSettings();
adminSettings.setKey(SETTINGS_KEY);
adminSettings.setTenantId(tenantId);
}
adminSettings.setJsonValue(JacksonUtil.valueToTree(versionControlSettings));
AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(tenantId, adminSettings);
EntitiesVersionControlSettings savedVersionControlSettings;
@ -359,11 +369,21 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
//TODO: ashvayka: replace future.get with deferred result.
gitServiceQueue.testRepository(tenantId, settings).get();
} catch (Exception e) {
throw new ThingsboardException(String.format("Unable to access repository: %s", e.getMessage()),
throw new ThingsboardException(String.format("Unable to access repository: %s", getCauseMessage(e)),
ThingsboardErrorCode.GENERAL);
}
}
private String getCauseMessage(Exception e) {
String message;
if(e.getCause() != null && StringUtils.isNotEmpty(e.getCause().getMessage())){
message = e.getCause().getMessage();
} else {
message = e.getMessage();
}
return message;
}
private EntitiesVersionControlSettings restoreCredentials(EntitiesVersionControlSettings settings, EntitiesVersionControlSettings storedSettings) {
VersionControlAuthMethod authMethod = settings.getAuthMethod();
if (VersionControlAuthMethod.USERNAME_PASSWORD.equals(authMethod) && settings.getPassword() == null) {
@ -373,8 +393,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
} else if (VersionControlAuthMethod.PRIVATE_KEY.equals(authMethod) && settings.getPrivateKey() == null) {
if (storedSettings != null) {
settings.setPrivateKey(storedSettings.getPrivateKey());
if (StringUtils.isEmpty(settings.getPrivateKeyPassword()) &&
StringUtils.isNotEmpty(storedSettings.getPrivateKeyPassword())) {
if (settings.getPrivateKeyPassword() == null) {
settings.setPrivateKeyPassword(storedSettings.getPrivateKeyPassword());
}
}

76
application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java

@ -20,10 +20,14 @@ import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.google.protobuf.ByteString;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ExportableEntity;
import org.thingsboard.server.common.data.StringUtils;
@ -48,6 +52,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.VersionControlRespon
import org.thingsboard.server.queue.TbQueueCallback;
import org.thingsboard.server.queue.TbQueueMsgMetadata;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.util.DataDecodingEncodingService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.io.IOException;
@ -59,14 +64,25 @@ import java.util.function.Function;
@TbCoreComponent
@Service
@RequiredArgsConstructor
@Slf4j
public class DefaultGitVersionControlQueueService implements GitVersionControlQueueService {
private final ObjectWriter jsonWriter = new ObjectMapper().writer(SerializationFeature.INDENT_OUTPUT);
private final TbServiceInfoProvider serviceInfoProvider;
private final TbClusterService clusterService;
private final DataDecodingEncodingService encodingService;
private final DefaultEntitiesVersionControlService entitiesVersionControlService;
private final Map<UUID, PendingGitRequest<?>> pendingRequestMap = new HashMap<>();
private final ObjectWriter jsonWriter = new ObjectMapper().writer(SerializationFeature.INDENT_OUTPUT);
public DefaultGitVersionControlQueueService(TbServiceInfoProvider serviceInfoProvider, TbClusterService clusterService,
DataDecodingEncodingService encodingService,
@Lazy DefaultEntitiesVersionControlService entitiesVersionControlService) {
this.serviceInfoProvider = serviceInfoProvider;
this.clusterService = clusterService;
this.encodingService = encodingService;
this.entitiesVersionControlService = entitiesVersionControlService;
}
@Override
public ListenableFuture<CommitGitRequest> prepareCommit(TenantId tenantId, VersionCreateRequest request) {
@ -74,9 +90,8 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
CommitGitRequest commit = new CommitGitRequest(tenantId, request);
registerAndSend(commit, builder -> builder.setCommitRequest(
CommitRequestMsg.newBuilder().setPrepareMsg(getCommitPrepareMsg(request)).build()
buildCommitRequest(commit).setPrepareMsg(getCommitPrepareMsg(request)).build()
).build(), wrap(future, commit));
return future;
}
@ -94,11 +109,11 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
}
registerAndSend(commit, builder -> builder.setCommitRequest(
CommitRequestMsg.newBuilder().setAddMsg(
buildCommitRequest(commit).setAddMsg(
TransportProtos.AddMsg.newBuilder()
.setRelativePath(path).setEntityDataJson(entityDataJson).build()
).build()
).build(), wrap(commit.getFuture(), null));
).build(), wrap(future, null));
return future;
}
@ -109,7 +124,7 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
String path = getRelativePath(entityType, null);
registerAndSend(commit, builder -> builder.setCommitRequest(
CommitRequestMsg.newBuilder().setDeleteMsg(
buildCommitRequest(commit).setDeleteMsg(
TransportProtos.DeleteMsg.newBuilder().setRelativePath(path).build()
).build()
).build(), wrap(commit.getFuture(), null));
@ -120,7 +135,7 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
@Override
public ListenableFuture<VersionCreationResult> push(CommitGitRequest commit) {
registerAndSend(commit, builder -> builder.setCommitRequest(
CommitRequestMsg.newBuilder().setPushMsg(
buildCommitRequest(commit).setPushMsg(
TransportProtos.PushMsg.newBuilder().build()
).build()
).build(), wrap(commit.getFuture()));
@ -202,7 +217,6 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
.setEntityIdMSB(entityId.getId().getMostSignificantBits())
.setEntityIdLSB(entityId.getId().getLeastSignificantBits())).build()
, wrap(request.getFuture()));
return request.getFuture();
// try {
// String entityDataJson = gitRepositoryService.getFileContentAtCommit(tenantId,
@ -214,10 +228,16 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
// }
}
private <T> void registerAndSend(PendingGitRequest<T> request, Function<ToVersionControlServiceMsg.Builder, ToVersionControlServiceMsg> enrichFunction, TbQueueCallback callback) {
private <T> void registerAndSend(PendingGitRequest<T> request,
Function<ToVersionControlServiceMsg.Builder, ToVersionControlServiceMsg> enrichFunction, TbQueueCallback callback) {
registerAndSend(request, enrichFunction, null, callback);
}
private <T> void registerAndSend(PendingGitRequest<T> request,
Function<ToVersionControlServiceMsg.Builder, ToVersionControlServiceMsg> enrichFunction, EntitiesVersionControlSettings settings, TbQueueCallback callback) {
if (!request.getFuture().isDone()) {
pendingRequestMap.putIfAbsent(request.getRequestId(), request);
clusterService.pushMsgToVersionControl(request.getTenantId(), enrichFunction.apply(newRequestProto(request)), callback);
clusterService.pushMsgToVersionControl(request.getTenantId(), enrichFunction.apply(newRequestProto(request, settings)), callback);
} else {
throw new RuntimeException("Future is already done!");
}
@ -243,7 +263,7 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
VoidGitRequest request = new VoidGitRequest(tenantId);
registerAndSend(request, builder -> builder.setInitRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build()
, wrap(request.getFuture()));
, settings, wrap(request.getFuture()));
return request.getFuture();
}
@ -252,15 +272,16 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
public ListenableFuture<Void> testRepository(TenantId tenantId, EntitiesVersionControlSettings settings) {
VoidGitRequest request = new VoidGitRequest(tenantId);
registerAndSend(request, builder -> builder.setTestRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build()
, wrap(request.getFuture()));
registerAndSend(request, builder -> builder
.setTestRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build()
, settings, wrap(request.getFuture()));
return request.getFuture();
}
@Override
public ListenableFuture<Void> clearRepository(TenantId tenantId) {
VoidGitRequest request = new VoidGitRequest(tenantId);
ClearRepositoryGitRequest request = new ClearRepositoryGitRequest(tenantId);
registerAndSend(request, builder -> builder.setClearRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build()
, wrap(request.getFuture()));
@ -284,6 +305,14 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
} else {
if (vcResponseMsg.hasGenericResponse()) {
future.set(null);
} else if (vcResponseMsg.hasCommitResponse()) {
var commitResponse = vcResponseMsg.getCommitResponse();
var commitResult = new VersionCreationResult();
commitResult.setVersion(new EntityVersion(commitResponse.getCommitId(), commitResponse.getName()));
commitResult.setAdded(commitResponse.getAdded());
commitResult.setRemoved(commitResponse.getRemoved());
commitResult.setModified(commitResponse.getModified());
((CommitGitRequest) request).getFuture().set(commitResult);
}
}
}
@ -327,16 +356,29 @@ public class DefaultGitVersionControlQueueService implements GitVersionControlQu
return PrepareMsg.newBuilder().setCommitMsg(request.getVersionName()).setBranchName(request.getBranch()).build();
}
private ToVersionControlServiceMsg.Builder newRequestProto(PendingGitRequest<?> request) {
private ToVersionControlServiceMsg.Builder newRequestProto(PendingGitRequest<?> request, EntitiesVersionControlSettings settings) {
var tenantId = request.getTenantId();
var requestId = request.getRequestId();
return ToVersionControlServiceMsg.newBuilder()
var builder = ToVersionControlServiceMsg.newBuilder()
.setNodeId(serviceInfoProvider.getServiceId())
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.setRequestIdMSB(requestId.getMostSignificantBits())
.setRequestIdLSB(requestId.getLeastSignificantBits());
EntitiesVersionControlSettings vcSettings = settings;
if (vcSettings == null && request.requiresSettings()) {
vcSettings = entitiesVersionControlService.getVersionControlSettings(tenantId);
}
if (vcSettings != null) {
builder.setVcSettings(ByteString.copyFrom(encodingService.encode(vcSettings)));
} else {
throw new RuntimeException("No entity version control settings provisioned!");
}
return builder;
}
private CommitRequestMsg.Builder buildCommitRequest(CommitGitRequest commit) {
return CommitRequestMsg.newBuilder().setTxId(commit.getTxId().toString());
}
}

2
application/src/main/java/org/thingsboard/server/service/sync/vc/LocalGitVersionControlService.java

@ -63,7 +63,7 @@ public class LocalGitVersionControlService {
private final TenantDao tenantDao;
private final AdminSettingsService adminSettingsService;
private final ConcurrentMap<TenantId, Lock> tenantRepoLocks = new ConcurrentHashMap<>();
// private final Map<TenantId, PendingCommit> pendingCommitMap = new HashMap<>();
private final Map<TenantId, PendingCommit> pendingCommitMap = new HashMap<>();
//
// @AfterStartUp
// public void init() {

4
application/src/main/java/org/thingsboard/server/service/sync/vc/PendingGitRequest.java

@ -33,4 +33,8 @@ public class PendingGitRequest<T> {
this.tenantId = tenantId;
this.future = SettableFuture.create();
}
public boolean requiresSettings(){
return true;
}
}

1
application/src/main/resources/thingsboard.yml

@ -1122,6 +1122,7 @@ metrics:
vc:
thread_pool_size: "${TB_VC_POOL_SIZE:4}"
git:
folder: "${TB_VC_GIT_FOLDER:}"
repos-poll-interval: "${TB_VC_GIT_REPOS_POLL_INTERVAL_SEC:60}"
management:

11
common/cluster-api/src/main/proto/queue.proto

@ -680,11 +680,12 @@ message EdgeNotificationMsgProto {
TB Core to Version Control Service
*/
message CommitRequestMsg {
PrepareMsg prepareMsg = 1;
AddMsg addMsg = 2;
DeleteMsg deleteMsg = 3;
PushMsg pushMsg = 4;
AbortMsg abortMsg = 5;
string txId = 1; // To correlate prepare, add, delete and push messages
PrepareMsg prepareMsg = 2;
AddMsg addMsg = 3;
DeleteMsg deleteMsg = 4;
PushMsg pushMsg = 5;
AbortMsg abortMsg = 6;
}
message CommitResponseMsg {

5
common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbApplicationEventListener.java

@ -40,7 +40,7 @@ public abstract class TbApplicationEventListener<T extends TbApplicationEvent> i
} finally {
seqNumberLock.unlock();
}
if (validUpdate) {
if (validUpdate && filterTbApplicationEvent(event)) {
onTbApplicationEvent(event);
} else {
log.info("Application event ignored due to invalid sequence number ({} > {}). Event: {}", lastProcessedSequenceNumber, event.getSequenceNumber(), event);
@ -49,5 +49,8 @@ public abstract class TbApplicationEventListener<T extends TbApplicationEvent> i
protected abstract void onTbApplicationEvent(T event);
protected boolean filterTbApplicationEvent(T event) {
return true;
}
}

5
common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java

@ -5,7 +5,7 @@
* 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
* 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,
@ -157,8 +157,7 @@ public class InMemoryMonolithQueueFactory implements TbCoreQueueFactory, TbRuleE
@Override
public TbQueueProducer<TbProtoQueueMsg<TransportProtos.ToVersionControlServiceMsg>> createVersionControlMsgProducer() {
//TODO: version-control
return null;
return new InMemoryTbQueueProducer<>(storage, vcSettings.getTopic());
}
@Scheduled(fixedRateString = "${queue.in_memory.stats.print-interval-ms:60000}")

161
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java

@ -17,28 +17,25 @@ package org.thingsboard.server.service.sync.vc;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.sync.vc.EntitiesVersionControlSettings;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.data.sync.vc.VersionCreationResult;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.*;
import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToVersionControlServiceMsg;
import org.thingsboard.server.queue.TbQueueCallback;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.TbQueueMsgMetadata;
import org.thingsboard.server.queue.TbQueueProducer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.discovery.NotificationsTopicService;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.TbApplicationEventListener;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
@ -48,11 +45,19 @@ import org.thingsboard.server.queue.util.TbVersionControlComponent;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
@Slf4j
@TbVersionControlComponent
@ -66,6 +71,9 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
private final GitRepositoryService vcService;
private final NotificationsTopicService notificationsTopicService;
private final ConcurrentMap<TenantId, Lock> tenantRepoLocks = new ConcurrentHashMap<>();
private final Map<TenantId, PendingCommit> pendingCommitMap = new HashMap<>();
private volatile ExecutorService consumerExecutor;
private volatile TbQueueConsumer<TbProtoQueueMsg<ToVersionControlServiceMsg>> consumer;
private volatile TbQueueProducer<TbProtoQueueMsg<ToCoreNotificationMsg>> producer;
@ -100,6 +108,11 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
consumer.subscribe(event.getPartitions());
}
@Override
protected boolean filterTbApplicationEvent(PartitionChangeEvent event) {
return ServiceType.TB_VC_EXECUTOR.equals(event.getServiceType());
}
@EventListener(ApplicationReadyEvent.class)
@Order(value = 2)
public void onApplicationEvent(ApplicationReadyEvent event) {
@ -115,36 +128,35 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
}
for (TbProtoQueueMsg<ToVersionControlServiceMsg> msgWrapper : msgs) {
ToVersionControlServiceMsg msg = msgWrapper.getValue();
if (msg.hasClearRepositoryRequest()) {
handleClearRepositoryCommand(new VersionControlRequestCtx(msg, null));
} else {
VersionControlRequestCtx ctx = new VersionControlRequestCtx(msg, getEntitiesVersionControlSettings(msg));
if (msg.hasTestRepositoryRequest()) {
handleTestRepositoryCommand(ctx);
} else if (msg.hasInitRepositoryRequest()) {
handleInitRepositoryCommand(ctx);
var ctx = new VersionControlRequestCtx(msg, msg.hasClearRepositoryRequest() ? null : getEntitiesVersionControlSettings(msg));
var lock = getRepoLock(ctx.getTenantId());
lock.lock();
try {
if (msg.hasClearRepositoryRequest()) {
handleClearRepositoryCommand(ctx);
} else {
if (msg.hasTestRepositoryRequest()) {
handleTestRepositoryCommand(ctx);
} else if (msg.hasInitRepositoryRequest()) {
handleInitRepositoryCommand(ctx);
} else {
var currentSettings = vcService.getRepositorySettings(ctx.getTenantId());
var newSettings = ctx.getSettings();
if (!newSettings.equals(currentSettings)) {
vcService.initRepository(ctx.getTenantId(), ctx.getSettings());
}
if (msg.hasCommitRequest()) {
handleCommitRequest(ctx, msg.getCommitRequest());
}
}
}
} catch (Exception e) {
reply(ctx, Optional.of(e));
} finally {
lock.unlock();
}
}
// ConcurrentMap<UUID, TbProtoQueueMsg<ToIntegrationExecutorDownlinkMsg>> pendingMap = msgs.stream().collect(
// Collectors.toConcurrentMap(s -> UUID.randomUUID(), Function.identity()));
// CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
// TbPackProcessingContext<TbProtoQueueMsg<ToIntegrationExecutorDownlinkMsg>> ctx = new TbPackProcessingContext<>(
// processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
// pendingMap.forEach((id, msg) -> {
// log.trace("[{}] Creating downlink callback for message: {}", id, msg.getValue());
// TbCallback callback = new TbPackCallback<>(id, ctx);
// try {
// handleDownlink(id, msg, callback);
// } catch (Throwable e) {
// log.warn("[{}] Failed to process notification: {}", id, msg, e);
// callback.onFailure(e);
// }
// });
// if (!processingTimeoutLatch.await(processingTimeout, TimeUnit.MILLISECONDS)) {
// ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process downlink: {}", id, msg.getValue()));
// ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process downlink: {}", id, msg.getValue()));
// }
//TODO: handle timeouts and async processing for multiple tenants;
consumer.commit();
} catch (Exception e) {
if (!stopped) {
@ -160,6 +172,66 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
log.info("TB Version Control request consumer stopped.");
}
private void handleCommitRequest(VersionControlRequestCtx ctx, CommitRequestMsg commitRequest) throws Exception {
var tenantId = ctx.getTenantId();
UUID txId = UUID.fromString(commitRequest.getTxId());
if (commitRequest.hasPrepareMsg()) {
prepareCommit(ctx, txId, commitRequest.getPrepareMsg());
} else if (commitRequest.hasAbortMsg()) {
PendingCommit current = pendingCommitMap.get(tenantId);
if (current != null && current.getTxId().equals(txId)) {
doAbortCurrentCommit(tenantId, current);
}
} else {
PendingCommit current = pendingCommitMap.get(tenantId);
if (current != null && current.getTxId().equals(txId)) {
try {
if (commitRequest.hasAddMsg()) {
addToCommit(ctx, current, commitRequest.getAddMsg());
} else if (commitRequest.hasDeleteMsg()) {
deleteFromCommit(ctx, current, commitRequest.getDeleteMsg());
} else if (commitRequest.hasPushMsg()) {
reply(ctx, vcService.push(current));
}
} catch (Exception e) {
doAbortCurrentCommit(tenantId, current, e);
throw e;
}
} else {
log.debug("[{}] Ignore request due to stale commit: {}", txId, commitRequest);
}
}
}
private void prepareCommit(VersionControlRequestCtx ctx, UUID txId, PrepareMsg prepareMsg) {
var tenantId = ctx.getTenantId();
var pendingCommit = new PendingCommit(tenantId, ctx.getNodeId(), txId, prepareMsg.getBranchName(), prepareMsg.getCommitMsg());
PendingCommit old = pendingCommitMap.get(tenantId);
if (old != null) {
doAbortCurrentCommit(tenantId, old);
}
pendingCommitMap.put(tenantId, pendingCommit);
vcService.prepareCommit(pendingCommit);
}
private void deleteFromCommit(VersionControlRequestCtx ctx, PendingCommit commit, DeleteMsg deleteMsg) throws IOException {
vcService.deleteFolderContent(commit, deleteMsg.getRelativePath());
}
private void addToCommit(VersionControlRequestCtx ctx, PendingCommit commit, AddMsg addMsg) throws IOException {
vcService.add(commit, addMsg.getRelativePath(), addMsg.getEntityDataJson());
}
private void doAbortCurrentCommit(TenantId tenantId, PendingCommit current) {
doAbortCurrentCommit(tenantId, current, null);
}
private void doAbortCurrentCommit(TenantId tenantId, PendingCommit current, Exception e) {
vcService.abort(current);
pendingCommitMap.remove(tenantId);
//TODO: push notification to core using old.getNodeId() to cancel old commit processing on the caller side.
}
private void handleClearRepositoryCommand(VersionControlRequestCtx ctx) {
try {
vcService.clearRepository(ctx.getTenantId());
@ -191,13 +263,29 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
}
}
private void reply(VersionControlRequestCtx ctx, VersionCreationResult result) {
reply(ctx, Optional.empty(), builder -> builder.setCommitResponse(CommitResponseMsg.newBuilder()
.setCommitId(result.getVersion().getId())
.setName(result.getVersion().getName())
.setAdded(result.getAdded())
.setModified(result.getModified())
.setRemoved(result.getRemoved())));
}
private void reply(VersionControlRequestCtx ctx, Optional<Exception> e) {
reply(ctx, e, null);
}
private void reply(VersionControlRequestCtx ctx, Optional<Exception> e, Function<VersionControlResponseMsg.Builder, VersionControlResponseMsg.Builder> enrichFunction) {
TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, ctx.getNodeId());
TransportProtos.VersionControlResponseMsg.Builder builder = TransportProtos.VersionControlResponseMsg.newBuilder()
VersionControlResponseMsg.Builder builder = VersionControlResponseMsg.newBuilder()
.setRequestIdMSB(ctx.getRequestId().getMostSignificantBits())
.setRequestIdLSB(ctx.getRequestId().getLeastSignificantBits());
if (e.isPresent()) {
builder.setError(e.get().getMessage());
}
if (enrichFunction != null) {
builder = enrichFunction.apply(builder);
} else {
builder.setGenericResponse(TransportProtos.GenericRepositoryResponseMsg.newBuilder().build());
}
@ -216,4 +304,9 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe
}
}
private Lock getRepoLock(TenantId tenantId) {
return tenantRepoLocks.computeIfAbsent(tenantId, t -> new ReentrantLock(true));
}
}

24
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.service.sync.vc;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
@ -55,7 +54,10 @@ import java.util.stream.Collectors;
@Service
public class DefaultGitRepositoryService implements GitRepositoryService {
@Value("${vc.git.repos-poll-interval:${java.io.tmpdir}/repositories}")
@Value("${java.io.tmpdir}/repositories")
private String defaultFolder;
@Value("${vc.git.folder:${java.io.tmpdir}/repositories}")
private String repositoriesFolder;
@Value("${vc.git.repos-poll-interval:60}")
@ -66,6 +68,9 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
@PostConstruct
public void init() {
if (StringUtils.isEmpty(repositoriesFolder)) {
repositoriesFolder = defaultFolder;
}
scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleWithFixedDelay(() -> {
repositories.forEach((tenantId, repository) -> {
@ -89,7 +94,7 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
@Override
public void prepareCommit(PendingCommit commit) {
GitRepository repository = checkRepository(commit.getTenantId());
String branch = commit.getRequest().getBranch();
String branch = commit.getBranch();
try {
repository.fetch();
if (repository.listBranches().contains(branch)) {
@ -139,7 +144,7 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
result.setModified(status.getModified().size());
result.setRemoved(status.getRemoved().size());
GitRepository.Commit gitCommit = repository.commit(commit.getRequest().getVersionName());
GitRepository.Commit gitCommit = repository.commit(commit.getVersionName());
repository.push();
result.setVersion(toVersion(gitCommit));
@ -157,8 +162,8 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
@Override
public String getFileContentAtCommit(TenantId tenantId, String relativePath, String versionId) throws IOException {
GitRepository repository = checkRepository(tenantId);
return repository.getFileContentAtCommit(relativePath, versionId);
GitRepository repository = checkRepository(tenantId);
return repository.getFileContentAtCommit(relativePath, versionId);
}
@Override
@ -213,6 +218,7 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
@Override
public void initRepository(TenantId tenantId, EntitiesVersionControlSettings settings) throws Exception {
clearRepository(tenantId);
Path repositoryDirectory = Path.of(repositoriesFolder, tenantId.getId().toString());
GitRepository repository;
if (Files.exists(repositoryDirectory)) {
@ -224,6 +230,12 @@ public class DefaultGitRepositoryService implements GitRepositoryService {
repositories.put(tenantId, repository);
}
@Override
public EntitiesVersionControlSettings getRepositorySettings(TenantId tenantId) throws Exception {
var gitRepository = repositories.get(tenantId);
return gitRepository != null ? gitRepository.getSettings() : null;
}
@Override
public void clearRepository(TenantId tenantId) throws IOException {
GitRepository repository = repositories.get(tenantId);

9
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java

@ -56,14 +56,17 @@ import java.util.stream.Collectors;
public class GitRepository {
private final Git git;
@Getter
private final EntitiesVersionControlSettings settings;
private final CredentialsProvider credentialsProvider;
private final SshdSessionFactory sshSessionFactory;
@Getter
private final String directory;
private GitRepository(Git git, CredentialsProvider credentialsProvider, SshdSessionFactory sshSessionFactory, String directory) {
private GitRepository(Git git, EntitiesVersionControlSettings settings, CredentialsProvider credentialsProvider, SshdSessionFactory sshSessionFactory, String directory) {
this.git = git;
this.settings = settings;
this.credentialsProvider = credentialsProvider;
this.sshSessionFactory = sshSessionFactory;
this.directory = directory;
@ -83,7 +86,7 @@ public class GitRepository {
.setNoCheckout(true);
configureTransportCommand(cloneCommand, credentialsProvider, sshSessionFactory);
Git git = cloneCommand.call();
return new GitRepository(git, credentialsProvider, sshSessionFactory, directory.getAbsolutePath());
return new GitRepository(git, settings, credentialsProvider, sshSessionFactory, directory.getAbsolutePath());
}
public static GitRepository open(File directory, EntitiesVersionControlSettings settings) throws IOException {
@ -95,7 +98,7 @@ public class GitRepository {
} else if (VersionControlAuthMethod.PRIVATE_KEY.equals(settings.getAuthMethod())) {
sshSessionFactory = newSshdSessionFactory(settings.getPrivateKey(), settings.getPrivateKeyPassword(), directory);
}
return new GitRepository(git, credentialsProvider, sshSessionFactory, directory.getAbsolutePath());
return new GitRepository(git, settings, credentialsProvider, sshSessionFactory, directory.getAbsolutePath());
}
public static void test(EntitiesVersionControlSettings settings, File directory) throws GitAPIException {

2
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepositoryService.java

@ -36,6 +36,8 @@ public interface GitRepositoryService {
void initRepository(TenantId tenantId, EntitiesVersionControlSettings settings) throws Exception;
EntitiesVersionControlSettings getRepositorySettings(TenantId tenantId) throws Exception;
void clearRepository(TenantId tenantId) throws IOException;
void add(PendingCommit commit, String relativePath, String entityDataJson) throws IOException;

12
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/PendingCommit.java

@ -25,12 +25,16 @@ import java.util.UUID;
public class PendingCommit {
private final UUID txId;
private final String nodeId;
private final TenantId tenantId;
private final VersionCreateRequest request;
private String branch;
private String versionName;
public PendingCommit(TenantId tenantId, VersionCreateRequest request) {
this.txId = UUID.randomUUID();
public PendingCommit(TenantId tenantId, String nodeId, UUID txId, String branch, String versionName) {
this.tenantId = tenantId;
this.request = request;
this.nodeId = nodeId;
this.txId = txId;
this.branch = branch;
this.versionName = versionName;
}
}

2
dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java

@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.DaoUtil;
@ -51,6 +52,7 @@ public class JpaAdminSettingsDao extends JpaAbstractDao<AdminSettingsEntity, Adm
}
@Override
@Transactional
public boolean removeByTenantIdAndKey(UUID tenantId, String key) {
if (adminSettingsRepository.existsByTenantIdAndKey(tenantId, key)) {
adminSettingsRepository.deleteByTenantIdAndKey(tenantId, key);

20
ui-ngx/src/app/core/http/admin.service.ts

@ -20,6 +20,7 @@ import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import {
AdminSettings,
EntitiesVersionControlSettings,
MailServerSettings,
SecuritySettings,
TestSmsRequest,
@ -64,6 +65,25 @@ export class AdminService {
defaultHttpOptionsFromConfig(config));
}
public getEntitiesVersionControlSettings(config?: RequestConfig): Observable<EntitiesVersionControlSettings> {
return this.http.get<EntitiesVersionControlSettings>(`/api/admin/vcSettings`, defaultHttpOptionsFromConfig(config));
}
public saveEntitiesVersionControlSettings(versionControlSettings: EntitiesVersionControlSettings,
config?: RequestConfig): Observable<EntitiesVersionControlSettings> {
return this.http.post<EntitiesVersionControlSettings>('/api/admin/vcSettings', versionControlSettings,
defaultHttpOptionsFromConfig(config));
}
public deleteEntitiesVersionControlSettings(config?: RequestConfig) {
return this.http.delete('/api/admin/vcSettings', defaultHttpOptionsFromConfig(config));
}
public checkVersionControlAccess(versionControlSettings: EntitiesVersionControlSettings,
config?: RequestConfig): Observable<void> {
return this.http.post<void>('/api/admin/vcSettings/checkAccess', versionControlSettings, defaultHttpOptionsFromConfig(config));
}
public checkUpdates(config?: RequestConfig): Observable<UpdateMessage> {
return this.http.get<UpdateMessage>(`/api/admin/updates`, defaultHttpOptionsFromConfig(config));
}

40
ui-ngx/src/app/core/http/entities-version-control.service.ts

@ -0,0 +1,40 @@
///
/// Copyright © 2016-2022 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.
///
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
import { Observable } from 'rxjs';
import { BranchInfo, VersionCreateRequest, VersionCreationResult } from '@shared/models/vc.models';
@Injectable({
providedIn: 'root'
})
export class EntitiesVersionControlService {
constructor(
private http: HttpClient
) {
}
public listBranches(config?: RequestConfig): Observable<Array<BranchInfo>> {
return this.http.get<Array<BranchInfo>>('/api/entities/vc/branches', defaultHttpOptionsFromConfig(config));
}
public saveEntitiesVersion(request: VersionCreateRequest, config?: RequestConfig): Observable<VersionCreationResult> {
return this.http.post<VersionCreationResult>('/api/entities/vc/version', request, defaultHttpOptionsFromConfig(config));
}
}

14
ui-ngx/src/app/core/services/menu.service.ts

@ -362,7 +362,7 @@ export class MenuService {
name: 'admin.system-settings',
type: 'toggle',
path: '/settings',
height: '80px',
height: '120px',
icon: 'settings',
pages: [
{
@ -378,6 +378,13 @@ export class MenuService {
type: 'link',
path: '/settings/resources-library',
icon: 'folder'
},
{
id: guid(),
name: 'admin.git-settings',
type: 'link',
path: '/settings/vc',
icon: 'manage_history'
}
]
}
@ -512,6 +519,11 @@ export class MenuService {
name: 'resource.resources-library',
icon: 'folder',
path: '/settings/resources-library'
},
{
name: 'admin.git-settings',
icon: 'manage_history',
path: '/settings/vc',
}
]
}

7
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -153,6 +153,7 @@ import { TenantProfileQueuesComponent } from '@home/components/profile/queue/ten
import { QueueFormComponent } from '@home/components/queue/queue-form.component';
import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module';
import { WidgetSettingsComponent } from '@home/components/widget/widget-settings.component';
import { VcEntityExportDialogComponent } from '@home/components/vc/vc-entity-export-dialog.component';
@NgModule({
declarations:
@ -276,7 +277,8 @@ import { WidgetSettingsComponent } from '@home/components/widget/widget-settings
EmbedDashboardDialogComponent,
DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent,
QueueFormComponent
QueueFormComponent,
VcEntityExportDialogComponent
],
imports: [
CommonModule,
@ -394,7 +396,8 @@ import { WidgetSettingsComponent } from '@home/components/widget/widget-settings
EmbedDashboardDialogComponent,
DisplayWidgetTypesPanelComponent,
TenantProfileQueuesComponent,
QueueFormComponent
QueueFormComponent,
VcEntityExportDialogComponent
],
providers: [
WidgetComponentService,

6
ui-ngx/src/app/modules/home/components/profile/device-profile.component.html

@ -34,6 +34,12 @@
[fxShow]="!isEdit && !entity?.default">
{{'device-profile.set-default' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

77
ui-ngx/src/app/modules/home/components/vc/vc-entity-export-dialog.component.html

@ -0,0 +1,77 @@
<!--
Copyright © 2016-2022 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.
-->
<section style="min-width: 400px;">
<mat-toolbar color="primary">
<h2>{{ (createResult ? 'version-control.entity-version-exported' : 'version-control.export-entity-version') | translate }}</h2>
<span fxFlex></span>
<button mat-button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div mat-dialog-content>
<form *ngIf="!createResult" [formGroup]="exportFormGroup">
<fieldset [disabled]="isLoading$ | async">
<div fxFlex fxLayout="column">
<tb-branch-autocomplete
required
formControlName="branch">
</tb-branch-autocomplete>
<mat-form-field class="mat-block" fxFlex>
<mat-label translate>version-control.version-name</mat-label>
<input required matInput formControlName="versionName">
<mat-error *ngIf="exportFormGroup.get('versionName').hasError('required')">
{{ 'version-control.version-name-required' | translate }}
</mat-error>
</mat-form-field>
<mat-checkbox formControlName="saveRelations" style="margin-bottom: 16px;">
{{ 'version-control.export-entity-relations' | translate }}
</mat-checkbox>
</div>
</fieldset>
</form>
<div *ngIf="createResult" fxFlex fxLayout="column" fxLayoutAlign="center">
<div [innerHTML]="createResultMessage"></div>
</div>
</div>
<div *ngIf="!createResult" mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="button"
(click)="export()"
[disabled]="(isLoading$ | async) || exportFormGroup.invalid || !exportFormGroup.dirty">
{{ 'action.export' | translate }}
</button>
</div>
<div *ngIf="createResult" mat-dialog-actions fxLayoutAlign="end center">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.close' | translate }}
</button>
</div>
</section>

105
ui-ngx/src/app/modules/home/components/vc/vc-entity-export-dialog.component.ts

@ -0,0 +1,105 @@
///
/// Copyright © 2016-2022 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.
///
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { EntityId } from '@shared/models/id/entity-id';
import {
SingleEntityVersionCreateRequest,
VersionCreateRequestType,
VersionCreationResult
} from '@shared/models/vc.models';
import { EntitiesVersionControlService } from '@core/http/entities-version-control.service';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
export interface VcEntityExportDialogData {
entityId: EntityId;
}
@Component({
selector: 'tb-vc-entity-export-dialog',
templateUrl: './vc-entity-export-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: VcEntityExportDialogComponent}],
styleUrls: []
})
export class VcEntityExportDialogComponent extends DialogComponent<VcEntityExportDialogComponent>
implements OnInit, ErrorStateMatcher {
exportFormGroup: FormGroup;
submitted = false;
createResult: VersionCreationResult;
createResultMessage: SafeHtml;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: VcEntityExportDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<VcEntityExportDialogComponent>,
private entitiesVersionControlService: EntitiesVersionControlService,
private translate: TranslateService,
private domSanitizer: DomSanitizer,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.exportFormGroup = this.fb.group({
branch: [null, [Validators.required]],
versionName: [null, [Validators.required]],
saveRelations: [false, []]
});
}
ngOnInit(): void {
}
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close();
}
export(): void {
this.submitted = true;
const request: SingleEntityVersionCreateRequest = {
entityId: this.data.entityId,
branch: this.exportFormGroup.get('branch').value,
versionName: this.exportFormGroup.get('versionName').value,
config: {
saveRelations: this.exportFormGroup.get('saveRelations').value
},
type: VersionCreateRequestType.SINGLE_ENTITY
};
this.entitiesVersionControlService.saveEntitiesVersion(request).subscribe((result) => {
this.createResult = result;
const message = this.translate.instant('version-control.export-entity-version-result-message',
{name: result.version.name, commitId: result.version.id});
this.createResultMessage = this.domSanitizer.bypassSecurityTrustHtml(message);
});
}
}

17
ui-ngx/src/app/modules/home/dialogs/home-dialogs.service.ts

@ -22,6 +22,11 @@ import {
ImportDialogCsvComponent,
ImportDialogCsvData
} from '@home/components/import-export/import-dialog-csv.component';
import { EntityId } from '@shared/models/id/entity-id';
import {
VcEntityExportDialogComponent,
VcEntityExportDialogData
} from '@home/components/vc/vc-entity-export-dialog.component';
@Injectable()
export class HomeDialogsService {
@ -41,6 +46,17 @@ export class HomeDialogsService {
}
}
public exportVcEntity(entityId: EntityId): Observable<void> {
return this.dialog.open<VcEntityExportDialogComponent, VcEntityExportDialogData>(VcEntityExportDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
entityId
}
}).afterClosed();
}
private openImportDialogCSV(entityType: EntityType, importTitle: string, importFileLabel: string): Observable<boolean> {
return this.dialog.open<ImportDialogCsvComponent, ImportDialogCsvData,
any>(ImportDialogCsvComponent, {
@ -53,4 +69,5 @@ export class HomeDialogsService {
}
}).afterClosed();
}
}

14
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -33,6 +33,7 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai
import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
import { BreadCrumbConfig } from '@shared/components/breadcrumb';
import { QueuesTableConfigResolver } from '@home/pages/admin/queue/queues-table-config.resolver';
import { VersionControlSettingsComponent } from '@home/pages/admin/version-control-settings.component';
@Injectable()
export class OAuth2LoginProcessingUrlResolver implements Resolve<string> {
@ -222,6 +223,19 @@ const routes: Routes = [
}
}
]
},
{
path: 'vc',
component: VersionControlSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
auth: [Authority.TENANT_ADMIN],
title: 'admin.git-settings',
breadcrumb: {
label: 'admin.git-settings',
icon: 'manage_history'
}
}
}
]
}

4
ui-ngx/src/app/modules/home/pages/admin/admin.module.ts

@ -29,6 +29,7 @@ import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dial
import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component';
import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component';
import { QueueComponent} from '@home/pages/admin/queue/queue.component';
import { VersionControlSettingsComponent } from '@home/pages/admin/version-control-settings.component';
@NgModule({
declarations:
@ -41,7 +42,8 @@ import { QueueComponent} from '@home/pages/admin/queue/queue.component';
OAuth2SettingsComponent,
HomeSettingsComponent,
ResourcesLibraryComponent,
QueueComponent
QueueComponent,
VersionControlSettingsComponent
],
imports: [
CommonModule,

110
ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.html

@ -0,0 +1,110 @@
<!--
Copyright © 2016-2022 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.
-->
<div>
<mat-card class="settings-card">
<mat-card-title>
<div fxLayout="row">
<span class="mat-headline" translate>admin.git-repository-settings</span>
<span fxFlex></span>
<div tb-help="versionControlSettings"></div>
</div>
</mat-card-title>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="versionControlSettingsForm" #formDirective="ngForm" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<mat-label translate>admin.repository-url</mat-label>
<input matInput required formControlName="repositoryUri">
<mat-error translate *ngIf="versionControlSettingsForm.get('repositoryUri').hasError('required')">
admin.repository-url-required
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.default-branch</mat-label>
<input matInput formControlName="defaultBranch">
</mat-form-field>
<fieldset [disabled]="isLoading$ | async" class="fields-group">
<legend class="group-title" translate>admin.authentication-settings</legend>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.auth-method</mat-label>
<mat-select required formControlName="authMethod">
<mat-option *ngFor="let method of versionControlAuthMethods" [value]="method">
{{versionControlAuthMethodTranslations.get(method) | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<section [fxShow]="versionControlSettingsForm.get('authMethod').value === versionControlAuthMethod.USERNAME_PASSWORD" fxLayout="column">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" placeholder="{{ 'common.enter-username' | translate }}"
autocomplete="new-username"/>
</mat-form-field>
<mat-checkbox *ngIf="showChangePassword" (change)="changePasswordChanged()"
[(ngModel)]="changePassword" [ngModelOptions]="{ standalone: true }" style="padding-bottom: 16px;">
{{ 'admin.change-password-access-token' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="changePassword || !showChangePassword">
<mat-label translate>admin.password-access-token</mat-label>
<input matInput formControlName="password" type="password"
placeholder="{{ 'common.enter-password' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</section>
<section [fxShow]="versionControlSettingsForm.get('authMethod').value === versionControlAuthMethod.PRIVATE_KEY" fxLayout="column">
<tb-file-input style="margin-bottom: 16px;"
[existingFileName]="versionControlSettingsForm.get('privateKeyFileName').value"
required
formControlName="privateKey"
dropLabel="{{ 'admin.drop-private-key-file-or' | translate }}"
[label]="'admin.private-key' | translate"
(fileNameChanged)="versionControlSettingsForm.get('privateKeyFileName').patchValue($event)">
</tb-file-input>
<mat-checkbox *ngIf="showChangePrivateKeyPassword" (change)="changePrivateKeyPasswordChanged()"
[(ngModel)]="changePrivateKeyPassword" [ngModelOptions]="{ standalone: true }" style="padding-bottom: 16px;">
{{ 'admin.change-passphrase' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="changePrivateKeyPassword || !showChangePrivateKeyPassword">
<mat-label translate>admin.passphrase</mat-label>
<input matInput formControlName="privateKeyPassword" type="password"
placeholder="{{ 'admin.enter-passphrase' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</section>
</fieldset>
<div fxLayout="row" fxLayoutAlign="end center" fxLayout.xs="column" fxLayoutAlign.xs="end" fxLayoutGap="16px">
<button mat-raised-button color="warn" type="button" [fxShow]="settings !== null"
[disabled]="(isLoading$ | async)" (click)="delete(formDirective)">
{{'action.delete' | translate}}
</button>
<span fxFlex></span>
<button mat-raised-button type="button"
[disabled]="(isLoading$ | async) || versionControlSettingsForm.invalid" (click)="checkAccess()">
{{'admin.check-access' | translate}}
</button>
<button mat-raised-button color="primary" [disabled]="(isLoading$ | async) || versionControlSettingsForm.invalid || !versionControlSettingsForm.dirty"
type="submit">{{'action.save' | translate}}
</button>
</div>
</fieldset>
</form>
</mat-card-content>
</mat-card>
</div>

33
ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.scss

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2022 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.
*/
:host {
.fields-group {
padding: 0 16px 8px;
margin-bottom: 10px;
border: 1px groove rgba(0, 0, 0, .25);
border-radius: 4px;
legend {
color: rgba(0, 0, 0, .7);
width: fit-content;
}
legend + * {
display: block;
margin-top: 16px;
}
}
}

198
ui-ngx/src/app/modules/home/pages/admin/version-control-settings.component.ts

@ -0,0 +1,198 @@
///
/// Copyright © 2016-2022 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.
///
import { Component, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { FormBuilder, FormGroup, FormGroupDirective, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { AdminService } from '@core/http/admin.service';
import {
EntitiesVersionControlSettings,
VersionControlAuthMethod,
versionControlAuthMethodTranslationMap
} from '@shared/models/settings.models';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { isNotEmptyStr } from '@core/utils';
import { DialogService } from '@core/services/dialog.service';
@Component({
selector: 'tb-version-control-settings',
templateUrl: './version-control-settings.component.html',
styleUrls: ['./version-control-settings.component.scss', './settings-card.scss']
})
export class VersionControlSettingsComponent extends PageComponent implements OnInit, HasConfirmForm {
versionControlSettingsForm: FormGroup;
settings: EntitiesVersionControlSettings = null;
versionControlAuthMethod = VersionControlAuthMethod;
versionControlAuthMethods = Object.values(VersionControlAuthMethod);
versionControlAuthMethodTranslations = versionControlAuthMethodTranslationMap;
showChangePassword = false;
changePassword = false;
showChangePrivateKeyPassword = false;
changePrivateKeyPassword = false;
constructor(protected store: Store<AppState>,
private adminService: AdminService,
private dialogService: DialogService,
private translate: TranslateService,
public fb: FormBuilder) {
super(store);
}
ngOnInit() {
this.versionControlSettingsForm = this.fb.group({
repositoryUri: [null, [Validators.required]],
defaultBranch: [null, []],
authMethod: [VersionControlAuthMethod.USERNAME_PASSWORD, [Validators.required]],
username: [null, []],
password: [null, []],
privateKeyFileName: [null, [Validators.required]],
privateKey: [null, []],
privateKeyPassword: [null, []]
});
this.updateValidators(false);
this.versionControlSettingsForm.get('authMethod').valueChanges.subscribe(() => {
this.updateValidators(true);
});
this.versionControlSettingsForm.get('privateKeyFileName').valueChanges.subscribe(() => {
this.updateValidators(false);
});
this.adminService.getEntitiesVersionControlSettings({ignoreErrors: true}).subscribe(
(settings) => {
this.settings = settings;
if (this.settings.authMethod === VersionControlAuthMethod.USERNAME_PASSWORD) {
this.showChangePassword = true;
} else {
this.showChangePrivateKeyPassword = true;
}
this.versionControlSettingsForm.reset(this.settings);
this.updateValidators(false);
});
}
checkAccess(): void {
const settings: EntitiesVersionControlSettings = this.versionControlSettingsForm.value;
this.adminService.checkVersionControlAccess(settings).subscribe(() => {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.check-vc-access-success'),
type: 'success' }));
});
}
save(): void {
const settings: EntitiesVersionControlSettings = this.versionControlSettingsForm.value;
this.adminService.saveEntitiesVersionControlSettings(settings).subscribe(
(savedSettings) => {
this.settings = savedSettings;
if (this.settings.authMethod === VersionControlAuthMethod.USERNAME_PASSWORD) {
this.showChangePassword = true;
this.changePassword = false;
} else {
this.showChangePrivateKeyPassword = true;
this.changePrivateKeyPassword = false;
}
this.versionControlSettingsForm.reset(this.settings);
this.updateValidators(false);
}
);
}
delete(formDirective: FormGroupDirective): void {
this.dialogService.confirm(
this.translate.instant('admin.delete-git-settings-title', ),
this.translate.instant('admin.delete-git-settings-text'), null,
this.translate.instant('action.delete')
).subscribe((data) => {
if (data) {
this.adminService.deleteEntitiesVersionControlSettings().subscribe(
() => {
this.settings = null;
this.showChangePassword = false;
this.changePassword = false;
this.showChangePrivateKeyPassword = false;
this.changePrivateKeyPassword = false;
formDirective.resetForm();
this.versionControlSettingsForm.reset({ authMethod: VersionControlAuthMethod.USERNAME_PASSWORD });
this.updateValidators(false);
}
);
}
});
}
confirmForm(): FormGroup {
return this.versionControlSettingsForm;
}
changePasswordChanged() {
if (this.changePassword) {
this.versionControlSettingsForm.get('password').patchValue('');
this.versionControlSettingsForm.get('password').markAsDirty();
}
this.updateValidators(false);
}
changePrivateKeyPasswordChanged() {
if (this.changePrivateKeyPassword) {
this.versionControlSettingsForm.get('privateKeyPassword').patchValue('');
this.versionControlSettingsForm.get('privateKeyPassword').markAsDirty();
}
this.updateValidators(false);
}
updateValidators(emitEvent?: boolean): void {
const authMethod: VersionControlAuthMethod = this.versionControlSettingsForm.get('authMethod').value;
const privateKeyFileName: string = this.versionControlSettingsForm.get('privateKeyFileName').value;
if (authMethod === VersionControlAuthMethod.USERNAME_PASSWORD) {
this.versionControlSettingsForm.get('username').enable({emitEvent});
if (this.changePassword || !this.showChangePassword) {
this.versionControlSettingsForm.get('password').enable({emitEvent});
} else {
this.versionControlSettingsForm.get('password').disable({emitEvent});
}
this.versionControlSettingsForm.get('privateKeyFileName').disable({emitEvent});
this.versionControlSettingsForm.get('privateKey').disable({emitEvent});
this.versionControlSettingsForm.get('privateKeyPassword').disable({emitEvent});
} else {
this.versionControlSettingsForm.get('username').disable({emitEvent});
this.versionControlSettingsForm.get('password').disable({emitEvent});
this.versionControlSettingsForm.get('privateKeyFileName').enable({emitEvent});
this.versionControlSettingsForm.get('privateKey').enable({emitEvent});
if (this.changePrivateKeyPassword || !this.showChangePrivateKeyPassword) {
this.versionControlSettingsForm.get('privateKeyPassword').enable({emitEvent});
} else {
this.versionControlSettingsForm.get('privateKeyPassword').disable({emitEvent});
}
if (isNotEmptyStr(privateKeyFileName)) {
this.versionControlSettingsForm.get('privateKey').clearValidators();
} else {
this.versionControlSettingsForm.get('privateKey').setValidators([Validators.required]);
}
}
this.versionControlSettingsForm.get('username').updateValueAndValidity({emitEvent: false});
this.versionControlSettingsForm.get('password').updateValueAndValidity({emitEvent: false});
this.versionControlSettingsForm.get('privateKeyFileName').updateValueAndValidity({emitEvent: false});
this.versionControlSettingsForm.get('privateKey').updateValueAndValidity({emitEvent: false});
this.versionControlSettingsForm.get('privateKeyPassword').updateValueAndValidity({emitEvent: false});
}
}

6
ui-ngx/src/app/modules/home/pages/asset/asset.component.html

@ -46,6 +46,12 @@
[fxShow]="!isEdit && assetScope === 'edge'">
{{ 'edge.unassign-from-edge' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit && (assetScope === 'customer' || assetScope === 'tenant')">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

10
ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts

@ -468,6 +468,13 @@ export class AssetsTableConfigResolver implements Resolve<EntityTableConfig<Asse
);
}
vcExport($event: Event, asset: Asset) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(asset.id);
}
onAssetAction(action: EntityAction<AssetInfo>, config: EntityTableConfig<AssetInfo>): boolean {
switch (action.action) {
case 'open':
@ -485,6 +492,9 @@ export class AssetsTableConfigResolver implements Resolve<EntityTableConfig<Asse
case 'unassignFromEdge':
this.unassignFromEdge(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

6
ui-ngx/src/app/modules/home/pages/customer/customer.component.html

@ -53,6 +53,12 @@
*ngIf="edgesSupportEnabled()">
{{'customer.manage-edges' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit && !isPublic">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

12
ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts

@ -34,6 +34,7 @@ import { CustomerTabsComponent } from '@home/pages/customer/customer-tabs.compon
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { HomeDialogsService } from '@home/dialogs/home-dialogs.service';
@Injectable()
export class CustomersTableConfigResolver implements Resolve<EntityTableConfig<Customer>> {
@ -41,6 +42,7 @@ export class CustomersTableConfigResolver implements Resolve<EntityTableConfig<C
private readonly config: EntityTableConfig<Customer> = new EntityTableConfig<Customer>();
constructor(private customerService: CustomerService,
private homeDialogs: HomeDialogsService,
private translate: TranslateService,
private datePipe: DatePipe,
private router: Router,
@ -180,6 +182,13 @@ export class CustomersTableConfigResolver implements Resolve<EntityTableConfig<C
this.router.navigateByUrl(`customers/${customer.id.id}/edgeInstances`);
}
vcExport($event: Event, customer: Customer) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(customer.id);
}
onCustomerAction(action: EntityAction<Customer>, config: EntityTableConfig<Customer>): boolean {
switch (action.action) {
case 'open':
@ -200,6 +209,9 @@ export class CustomersTableConfigResolver implements Resolve<EntityTableConfig<C
case 'manageEdges':
this.manageCustomerEdges(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

6
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html

@ -59,6 +59,12 @@
[fxShow]="!isEdit && dashboardScope === 'edge'">
{{ 'edge.unassign-from-edge' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit && dashboardScope === 'tenant'">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

12
ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts

@ -69,6 +69,7 @@ import {
AddEntitiesToEdgeDialogComponent,
AddEntitiesToEdgeDialogData
} from '@home/dialogs/add-entities-to-edge-dialog.component';
import { HomeDialogsService } from '@home/dialogs/home-dialogs.service';
@Injectable()
export class DashboardsTableConfigResolver implements Resolve<EntityTableConfig<DashboardInfo | Dashboard>> {
@ -80,6 +81,7 @@ export class DashboardsTableConfigResolver implements Resolve<EntityTableConfig<
private customerService: CustomerService,
private edgeService: EdgeService,
private dialogService: DialogService,
private homeDialogs: HomeDialogsService,
private importExport: ImportExportService,
private translate: TranslateService,
private datePipe: DatePipe,
@ -537,6 +539,13 @@ export class DashboardsTableConfigResolver implements Resolve<EntityTableConfig<
);
}
vcExport($event: Event, dashboard: DashboardInfo) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(dashboard.id);
}
onDashboardAction(action: EntityAction<DashboardInfo>): boolean {
switch (action.action) {
case 'open':
@ -560,6 +569,9 @@ export class DashboardsTableConfigResolver implements Resolve<EntityTableConfig<
case 'unassignFromEdge':
this.unassignFromEdge(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

12
ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts

@ -42,6 +42,7 @@ import {
AddDeviceProfileDialogData
} from '@home/components/profile/add-device-profile-dialog.component';
import { ImportExportService } from '@home/components/import-export/import-export.service';
import { HomeDialogsService } from '@home/dialogs/home-dialogs.service';
@Injectable()
export class DeviceProfilesTableConfigResolver implements Resolve<EntityTableConfig<DeviceProfile>> {
@ -50,6 +51,7 @@ export class DeviceProfilesTableConfigResolver implements Resolve<EntityTableCon
constructor(private deviceProfileService: DeviceProfileService,
private importExport: ImportExportService,
private homeDialogs: HomeDialogsService,
private translate: TranslateService,
private datePipe: DatePipe,
private dialogService: DialogService,
@ -204,6 +206,13 @@ export class DeviceProfilesTableConfigResolver implements Resolve<EntityTableCon
this.importExport.exportDeviceProfile(deviceProfile.id.id);
}
vcExport($event: Event, deviceProfile: DeviceProfile) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(deviceProfile.id);
}
onDeviceProfileAction(action: EntityAction<DeviceProfile>): boolean {
switch (action.action) {
case 'open':
@ -215,6 +224,9 @@ export class DeviceProfilesTableConfigResolver implements Resolve<EntityTableCon
case 'export':
this.exportDeviceProfile(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

6
ui-ngx/src/app/modules/home/pages/device/device.component.html

@ -52,6 +52,12 @@
[fxShow]="!isEdit && deviceScope === 'edge'">
{{ 'edge.unassign-from-edge' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit && (deviceScope === 'customer' || deviceScope === 'tenant')">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary" fxFlex.xs
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

10
ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts

@ -547,6 +547,13 @@ export class DevicesTableConfigResolver implements Resolve<EntityTableConfig<Dev
});
}
vcExport($event: Event, device: Device) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(device.id);
}
onDeviceAction(action: EntityAction<DeviceInfo>, config: EntityTableConfig<DeviceInfo>): boolean {
switch (action.action) {
case 'open':
@ -567,6 +574,9 @@ export class DevicesTableConfigResolver implements Resolve<EntityTableConfig<Dev
case 'manageCredentials':
this.manageCredentials(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

6
ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html

@ -58,6 +58,12 @@
[fxShow]="!isEdit && !isEdgeRootRuleChain() && ruleChainScope === 'edge'">
{{'edge.unassign-from-edge' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'vcExport')"
[fxShow]="!isEdit">
{{'version-control.export-to-git' | translate }}
</button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'delete')"

12
ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts

@ -50,6 +50,7 @@ import { PageLink } from '@shared/models/page/page-link';
import { Edge } from '@shared/models/edge.models';
import { mergeMap } from 'rxjs/operators';
import { PageData } from '@shared/models/page/page-data';
import { HomeDialogsService } from '@home/dialogs/home-dialogs.service';
@Injectable()
export class RuleChainsTableConfigResolver implements Resolve<EntityTableConfig<RuleChain>> {
@ -63,6 +64,7 @@ export class RuleChainsTableConfigResolver implements Resolve<EntityTableConfig<
private importExport: ImportExportService,
private itembuffer: ItemBufferService,
private edgeService: EdgeService,
private homeDialogs: HomeDialogsService,
private translate: TranslateService,
private datePipe: DatePipe,
private router: Router) {
@ -358,6 +360,13 @@ export class RuleChainsTableConfigResolver implements Resolve<EntityTableConfig<
);
}
vcExport($event: Event, ruleChain: RuleChain) {
if ($event) {
$event.stopPropagation();
}
this.homeDialogs.exportVcEntity(ruleChain.id);
}
onRuleChainAction(action: EntityAction<RuleChain>): boolean {
switch (action.action) {
case 'open':
@ -381,6 +390,9 @@ export class RuleChainsTableConfigResolver implements Resolve<EntityTableConfig<
case 'unsetAutoAssignToEdge':
this.unsetAutoAssignToEdgeRuleChain(action.event, action.entity);
return true;
case 'vcExport':
this.vcExport(action.event, action.entity);
return true;
}
return false;
}

43
ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html

@ -0,0 +1,43 @@
<!--
Copyright © 2016-2022 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.
-->
<mat-form-field [formGroup]="branchFormGroup" class="mat-block">
<mat-label>{{ 'version-control.branch' | translate }}</mat-label>
<input matInput type="text" placeholder="{{ 'version-control.select-branch' | translate }}"
#branchInput
formControlName="branch"
(focusin)="onFocus()"
[required]="required"
[matAutocomplete]="subTypeAutocomplete">
<button *ngIf="branchFormGroup.get('branch').value && !disabled"
type="button"
matSuffix mat-button mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete
class="tb-autocomplete"
#subTypeAutocomplete="matAutocomplete"
[displayWith]="displayBranchFn">
<mat-option *ngFor="let branch of filteredBranches | async" [value]="branch">
<span [innerHTML]="branch | highlight:searchText"></span>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="branchFormGroup.get('branch').hasError('required')">
{{ 'version-control.branch-required' | translate }}
</mat-error>
</mat-form-field>

199
ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts

@ -0,0 +1,199 @@
///
/// Copyright © 2016-2022 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.
///
import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Observable, of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
publishReplay,
refCount,
switchMap,
tap
} from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { BranchInfo } from '@shared/models/vc.models';
import { EntitiesVersionControlService } from '@core/http/entities-version-control.service';
@Component({
selector: 'tb-branch-autocomplete',
templateUrl: './branch-autocomplete.component.html',
styleUrls: [],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => BranchAutocompleteComponent),
multi: true
}]
})
export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit {
branchFormGroup: FormGroup;
modelValue: string | null;
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@Input()
disabled: boolean;
@Input()
selectDefaultBranch = true;
@ViewChild('branchInput', {static: true}) branchInput: ElementRef;
filteredBranches: Observable<Array<string>>;
branches: Observable<Array<BranchInfo>>;
searchText = '';
private dirty = false;
private propagateChange = (v: any) => { };
constructor(private store: Store<AppState>,
private entitiesVersionControlService: EntitiesVersionControlService,
private fb: FormBuilder) {
this.branchFormGroup = this.fb.group({
branch: [null, []]
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
this.branches = null;
this.filteredBranches = this.branchFormGroup.get('branch').valueChanges
.pipe(
debounceTime(150),
distinctUntilChanged(),
tap(value => {
this.updateView(value);
}),
map(value => value ? value : ''),
switchMap(branch => this.fetchBranches(branch))
);
}
ngAfterViewInit(): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.branchFormGroup.disable({emitEvent: false});
} else {
this.branchFormGroup.enable({emitEvent: false});
}
}
selectDefaultBranchIfNeeded(): void {
if (this.selectDefaultBranch && !this.modelValue) {
this.getBranches().subscribe(
(data) => {
if (data && data.length) {
const defaultBranch = data.find(branch => branch.default);
if (defaultBranch) {
this.modelValue = defaultBranch.name;
this.branchFormGroup.get('branch').patchValue(this.modelValue, {emitEvent: false});
this.propagateChange(this.modelValue);
}
}
}
);
}
}
writeValue(value: string | null): void {
this.searchText = '';
this.modelValue = value;
if (value != null) {
this.branchFormGroup.get('branch').patchValue(value, {emitEvent: false});
} else {
this.branchFormGroup.get('branch').patchValue('', {emitEvent: false});
this.selectDefaultBranchIfNeeded();
}
this.dirty = true;
}
onFocus() {
if (this.dirty) {
this.branchFormGroup.get('branch').updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
updateView(value: string | null) {
if (this.modelValue !== value) {
this.modelValue = value;
this.propagateChange(this.modelValue);
}
}
displayBranchFn(branch?: string): string | undefined {
return branch ? branch : undefined;
}
fetchBranches(searchText?: string): Observable<Array<string>> {
this.searchText = searchText;
return this.getBranches().pipe(
map(branches => branches.map(branch => branch.name).filter(branchName => {
return searchText ? branchName.toUpperCase().startsWith(searchText.toUpperCase()) : true;
}))
);
}
getBranches(): Observable<Array<BranchInfo>> {
if (!this.branches) {
const branchesObservable = this.entitiesVersionControlService.listBranches({ignoreLoading: true, ignoreErrors: true});
this.branches = branchesObservable.pipe(
catchError(() => of([] as Array<BranchInfo>)),
publishReplay(1),
refCount()
);
}
return this.branches;
}
clear() {
this.branchFormGroup.get('branch').patchValue(null, {emitEvent: true});
setTimeout(() => {
this.branchInput.nativeElement.blur();
this.branchInput.nativeElement.focus();
}, 0);
}
}

3
ui-ngx/src/app/shared/models/constants.ts

@ -134,7 +134,8 @@ export const HelpLinks = {
widgetsConfigStatic: helpBaseUrl + '/docs/user-guide/ui/dashboards#static',
ruleNodePushToCloud: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud',
ruleNodePushToEdge: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#push-to-edge',
queue: helpBaseUrl + '/docs/user-guide/queue'
queue: helpBaseUrl + '/docs/user-guide/queue',
versionControlSettings: helpBaseUrl + '/docs/user-guide/ui/version-control-settings'
}
};

21
ui-ngx/src/app/shared/models/settings.models.ts

@ -396,3 +396,24 @@ export function createSmsProviderConfiguration(type: SmsProviderType): SmsProvid
}
return smsProviderConfiguration;
}
export enum VersionControlAuthMethod {
USERNAME_PASSWORD = 'USERNAME_PASSWORD',
PRIVATE_KEY = 'PRIVATE_KEY'
}
export const versionControlAuthMethodTranslationMap = new Map<VersionControlAuthMethod, string>([
[VersionControlAuthMethod.USERNAME_PASSWORD, 'admin.auth-method-username-password'],
[VersionControlAuthMethod.PRIVATE_KEY, 'admin.auth-method-private-key']
]);
export interface EntitiesVersionControlSettings {
repositoryUri: string;
defaultBranch: string;
authMethod: VersionControlAuthMethod;
username: string;
password: string;
privateKeyFileName: string;
privateKey: string;
privateKeyPassword: string;
}

55
ui-ngx/src/app/shared/models/vc.models.ts

@ -0,0 +1,55 @@
///
/// Copyright © 2016-2022 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.
///
import { EntityId } from '@shared/models/id/entity-id';
export interface VersionCreateConfig {
saveRelations: boolean;
}
export enum VersionCreateRequestType {
SINGLE_ENTITY = 'SINGLE_ENTITY',
COMPLEX = 'COMPLEX'
}
export interface VersionCreateRequest {
versionName: string;
branch: string;
type: VersionCreateRequestType;
}
export interface SingleEntityVersionCreateRequest extends VersionCreateRequest {
entityId: EntityId;
config: VersionCreateConfig;
type: VersionCreateRequestType.SINGLE_ENTITY;
}
export interface BranchInfo {
name: string;
default: boolean;
}
export interface EntityVersion {
id: string;
name: string;
}
export interface VersionCreationResult {
version: EntityVersion;
added: number;
modified: number;
removed: number;
}

7
ui-ngx/src/app/shared/shared.module.ts

@ -163,6 +163,7 @@ import { HtmlComponent } from '@shared/components/html.component';
import { SafePipe } from '@shared/pipe/safe.pipe';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { MultipleImageInputComponent } from '@shared/components/multiple-image-input.component';
import { BranchAutocompleteComponent } from '@shared/components/vc/branch-autocomplete.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -284,7 +285,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent,
ProtobufContentComponent
ProtobufContentComponent,
BranchAutocompleteComponent
],
imports: [
CommonModule,
@ -484,7 +486,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
WidgetsBundleSearchComponent,
CopyButtonComponent,
TogglePasswordComponent,
ProtobufContentComponent
ProtobufContentComponent,
BranchAutocompleteComponent
]
})
export class SharedModule { }

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

@ -319,8 +319,28 @@
"queue-partitions": "Partitions",
"queue-submit-strategy": "Submit strategy",
"queue-processing-strategy": "Processing strategy",
"queue-configuration": "Queue configuration"
},
"queue-configuration": "Queue configuration",
"git-settings": "Git settings",
"git-repository-settings": "Git repository settings",
"repository-url": "Repository URL",
"repository-url-required": "Repository URL is required.",
"default-branch": "Default branch name",
"authentication-settings": "Authentication settings",
"auth-method": "Authentication method",
"auth-method-username-password": "Password / access token",
"auth-method-private-key": "Private key",
"password-access-token": "Password / access token",
"change-password-access-token": "Change password / access token",
"private-key": "Private key",
"drop-private-key-file-or": "Drag and drop a private key file or",
"passphrase": "Passphrase",
"enter-passphrase": "Enter passphrase",
"change-passphrase": "Change passphrase",
"check-access": "Check access",
"check-vc-access-success": "Git repository access successfully verified!",
"delete-git-settings-title": "Are you sure you want to delete git settings?",
"delete-git-settings-text": "Be careful, after the confirmation the git settings will be removed and git synchronization feature will be unavailable."
},
"alarm": {
"alarm": "Alarm",
"alarms": "Alarms",
@ -3088,6 +3108,18 @@
"json-value-invalid": "JSON value has an invalid format",
"json-value-required": "JSON value is required."
},
"version-control": {
"branch": "Branch",
"select-branch": "Select branch",
"branch-required": "Branch is required",
"export-entity-version": "Export entity version",
"entity-version-exported": "Entity version successfully exported",
"version-name": "Version name",
"version-name-required": "Version name is required",
"export-entity-relations": "Export entity relations",
"export-entity-version-result-message": "Entity exported with version '{{name}}' and commit id '{{commitId}}'.",
"export-to-git": "Export to Git"
},
"widget": {
"widget-library": "Widgets Library",
"widget-bundle": "Widgets Bundle",

Loading…
Cancel
Save