Browse Source

Merge remote-tracking branch 'origin/master' into feature/dashboard-tb-logo-as-navigate-home

pull/14057/head
deaflynx 1 year ago
parent
commit
448ce31397
  1. 1
      application/src/main/java/org/thingsboard/server/controller/ImageController.java
  2. 4
      application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java
  3. 31
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  4. 2
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  5. 2
      application/src/main/resources/thingsboard.yml
  6. 61
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  7. 50
      application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java
  8. 3
      common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java
  9. 1
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java
  10. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java
  11. 12
      common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java
  12. 4
      ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
  13. 7
      ui-ngx/src/app/core/utils.ts
  14. 10
      ui-ngx/src/app/modules/home/components/relation/relation-table.component.html
  15. 131
      ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html
  16. 100
      ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html
  17. 158
      ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html
  18. 4
      ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts
  19. 127
      ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html
  20. 33
      ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html
  21. 99
      ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html
  22. 2
      ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html
  23. 8
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts
  24. 2
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  25. 3
      ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss
  26. 32
      ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts
  27. 11
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts
  28. 24
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts
  29. 24
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts
  30. 2
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html
  31. 2
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  32. 21
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  33. 6
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html
  34. 7
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts
  35. 15
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts
  36. 40
      ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts
  37. 57
      ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html
  38. 89
      ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts
  39. 2
      ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html
  40. 9
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
  41. 29
      ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts
  42. 5
      ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html
  43. 5
      ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts
  44. 7
      ui-ngx/src/app/shared/models/time/time.models.ts
  45. 88
      ui-ngx/src/app/shared/models/widget/home-widgets/api-usage-model.definition.ts
  46. 62
      ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts
  47. 35
      ui-ngx/src/app/shared/models/widget/maps/map.models.ts
  48. 4
      ui-ngx/src/app/shared/models/widget/widget-model.definition.ts
  49. 2440
      ui-ngx/src/assets/dashboard/api_usage.json
  50. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json

1
application/src/main/java/org/thingsboard/server/controller/ImageController.java

@ -300,6 +300,7 @@ public class ImageController extends BaseController {
tbImageService.putETag(cacheKey, descriptor.getEtag()); tbImageService.putETag(cacheKey, descriptor.getEtag());
var result = ResponseEntity.ok() var result = ResponseEntity.ok()
.header("Content-Type", descriptor.getMediaType()) .header("Content-Type", descriptor.getMediaType())
.header("Content-Security-Policy", "default-src 'none'")
.eTag(descriptor.getEtag()); .eTag(descriptor.getEtag());
if (!cacheKey.isPublic()) { if (!cacheKey.isPublic()) {
result result

4
application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java

@ -442,13 +442,13 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService
boolean check(long threshold, long warnThreshold, long value); boolean check(long threshold, long warnThreshold, long value);
} }
private void checkStartOfNextCycle() { public void checkStartOfNextCycle() {
updateLock.lock(); updateLock.lock();
try { try {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
myUsageStates.values().forEach(state -> { myUsageStates.values().forEach(state -> {
if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) { if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) {
state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth()); state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextMonth());
if (log.isTraceEnabled()) { if (log.isTraceEnabled()) {
log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs()); log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs());
} }

31
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -93,14 +93,13 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS
@TbCoreComponent @TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10;
private final ConcurrentMap<EdgeId, EdgeGrpcSession> sessions = new ConcurrentHashMap<>(); private final ConcurrentMap<EdgeId, EdgeGrpcSession> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Lock> sessionNewEventsLocks = new ConcurrentHashMap<>(); private final ConcurrentMap<EdgeId, Lock> sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map<EdgeId, Boolean> sessionNewEvents = new HashMap<>(); private final Map<EdgeId, Boolean> sessionNewEvents = new HashMap<>();
private final ConcurrentMap<EdgeId, ScheduledFuture<?>> sessionEdgeEventChecks = new ConcurrentHashMap<>(); private final ConcurrentMap<EdgeId, ScheduledFuture<?>> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>(); private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Boolean> edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); private final ConcurrentMap<EdgeId, Boolean> edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
private final List<EdgeGrpcSession> zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}") @Value("${edges.rpc.port}")
private int rpcPort; private int rpcPort;
@ -193,7 +192,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS); this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!"); log.info("Edge RPC service initialized!");
} }
@ -518,14 +517,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
private void destroySession(EdgeGrpcSession session) { private void destroySession(EdgeGrpcSession session) {
try (session) { try (session) {
for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) { if (!session.destroy()) {
if (session.destroy()) { log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
break; session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
} else { zombieSessions.add(session);
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {}
}
} }
} }
} }
@ -634,7 +629,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
} }
} }
private void destroyKafkaSessionIfDisconnectedAndConsumerActive() { private void cleanupZombieSessions() {
try { try {
List<EdgeId> toRemove = new ArrayList<>(); List<EdgeId> toRemove = new ArrayList<>();
for (EdgeGrpcSession session : sessions.values()) { for (EdgeGrpcSession session : sessions.values()) {
@ -655,9 +650,19 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
} }
} }
} }
zombieSessions.removeIf(zombie -> {
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return false;
}
});
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e); log.warn("Failed to cleanup kafka sessions", e);
} }
} }
} }

2
application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java

@ -238,7 +238,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
} }
@Override @Override

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

@ -1542,7 +1542,7 @@ swagger:
version: "${SWAGGER_VERSION:}" version: "${SWAGGER_VERSION:}"
# The group name (definition) on the API doc UI page. # The group name (definition) on the API doc UI page.
group_name: "${SWAGGER_GROUP_NAME:thingsboard}" group_name: "${SWAGGER_GROUP_NAME:thingsboard}"
# Control the initial display state of API operations and tags (none, list or full) # Control the initial display state of API operations and tags (none or list)
doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" doc_expansion: "${SWAGGER_DOC_EXPANSION:list}"
# Queue configuration parameters # Queue configuration parameters

61
application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java

@ -519,6 +519,67 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); Assert.assertEquals(1, filteredAssetAlamData.getTotalElements());
} }
@Test
public void testFindAlarmsWithEntityFilterAndLatestValues() throws Exception {
loginTenantAdmin();
List<Device> devices = new ArrayList<>();
List<String> temps = new ArrayList<>();
List<String> deviceNames = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Device device = new Device();
device.setCustomerId(customerId);
device.setName("Device" + i);
device.setType("default");
device.setLabel("testLabel" + (int) (Math.random() * 1000));
device = doPost("/api/device", device, Device.class);
devices.add(device);
deviceNames.add(device.getName());
int temp = i * 10;
temps.add(String.valueOf(temp));
JsonNode content = JacksonUtil.toJsonNode("{\"temperature\": " + temp + "}");
doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", content)
.andExpect(status().isOk());
Thread.sleep(1);
}
for (int i = 0; i < devices.size(); i++) {
Alarm alarm = new Alarm();
alarm.setCustomerId(customerId);
alarm.setOriginator(devices.get(i).getId());
String type = "device alarm" + i;
alarm.setType(type);
alarm.setSeverity(AlarmSeverity.WARNING);
doPost("/api/alarm", alarm, Alarm.class);
Thread.sleep(1);
}
AlarmDataPageLink pageLink = new AlarmDataPageLink();
pageLink.setPage(0);
pageLink.setPageSize(100);
pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "created_time")));
List<EntityKey> alarmFields = new ArrayList<>();
alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
List<EntityKey> entityFields = new ArrayList<>();
entityFields.add(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
List<EntityKey> latestValues = new ArrayList<>();
latestValues.add(new EntityKey(EntityKeyType.TIME_SERIES, "temperature"));
EntityTypeFilter deviceTypeFilter = new EntityTypeFilter();
deviceTypeFilter.setEntityType(EntityType.DEVICE);
AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields);
PageData<AlarmData> alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10);
List<String> retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList();
assertThat(retrievedAlarmTemps).containsExactlyInAnyOrderElementsOf(temps);
List<String> retrievedDeviceNames = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).toList();
assertThat(retrievedDeviceNames).containsExactlyInAnyOrderElementsOf(deviceNames);
}
private void testCountAlarmsByQuery(List<Alarm> alarms) throws Exception { private void testCountAlarmsByQuery(List<Alarm> alarms) throws Exception {
AlarmCountQuery countQuery = new AlarmCountQuery(); AlarmCountQuery countQuery = new AlarmCountQuery();

50
application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java

@ -20,9 +20,12 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.ApiUsageState;
import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
@ -33,8 +36,17 @@ import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import java.lang.reflect.Field;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static java.time.ZoneOffset.UTC;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoUnit.MONTHS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@DaoSqlTest @DaoSqlTest
@ -48,6 +60,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
private TenantId tenantId; private TenantId tenantId;
private Tenant savedTenant; private Tenant savedTenant;
private TenantProfile savedTenantProfile;
private static final int MAX_ENABLE_VALUE = 5000; private static final int MAX_ENABLE_VALUE = 5000;
private static final long VALUE_WARNING = 4500L; private static final long VALUE_WARNING = 4500L;
@ -59,7 +72,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
loginSysAdmin(); loginSysAdmin();
TenantProfile tenantProfile = createTenantProfile(); TenantProfile tenantProfile = createTenantProfile();
TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class);
Assert.assertNotNull(savedTenantProfile); Assert.assertNotNull(savedTenantProfile);
Tenant tenant = new Tenant(); Tenant tenant = new Tenant();
@ -109,6 +122,41 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest {
assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState()); assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState());
} }
@Test
public void checkStartOfNextCycle_setsNextCycleToNextMonth() throws Exception {
ApiUsageState apiUsageState = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID()));
apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED);
apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setReExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setTransportState(ApiUsageStateValue.ENABLED);
apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setTenantId(tenantId);
apiUsageState.setEntityId(tenantId);
long now = System.currentTimeMillis();
long currentCycleTs = now - TimeUnit.DAYS.toMillis(30);
long nextCycleTs = now - TimeUnit.MINUTES.toMillis(5); // < 1h ago
TenantApiUsageState tenantApiUsageState = new TenantApiUsageState(savedTenantProfile, apiUsageState);
tenantApiUsageState.setCycles(currentCycleTs, nextCycleTs);
Map<EntityId, BaseApiUsageState> map = new HashMap<>();
map.put(tenantId, tenantApiUsageState);
Field fieldToSet = DefaultTbApiUsageStateService.class.getDeclaredField("myUsageStates");
fieldToSet.setAccessible(true);
fieldToSet.set(service, map);
service.checkStartOfNextCycle();
long firstOfNextMonth = LocalDate.now()
.with((temporal) -> temporal.with(DAY_OF_MONTH, 1)
.plus(1, MONTHS))
.atStartOfDay(UTC).toInstant().toEpochMilli();
assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth);
}
private TenantProfile createTenantProfile() { private TenantProfile createTenantProfile() {
TenantProfile tenantProfile = new TenantProfile(); TenantProfile tenantProfile = new TenantProfile();
tenantProfile.setName("Tenant Profile"); tenantProfile.setName("Tenant Profile");

3
common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.MobileAppId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mobile.layout.MobileLayoutConfig; import org.thingsboard.server.common.data.mobile.layout.MobileLayoutConfig;
import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoXss;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@Data @Data
@ -40,9 +41,11 @@ public class MobileAppBundle extends BaseData<MobileAppBundleId> implements HasT
private TenantId tenantId; private TenantId tenantId;
@Schema(description = "Application bundle title. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Application bundle title. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @NotBlank
@NoXss
@Length(fieldName = "title") @Length(fieldName = "title")
private String title; private String title;
@Schema(description = "Application bundle description.") @Schema(description = "Application bundle description.")
@NoXss
@Length(fieldName = "description") @Length(fieldName = "description")
private String description; private String description;
@Schema(description = "Android application id") @Schema(description = "Android application id")

1
common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java

@ -62,6 +62,7 @@ public class NotificationRule extends BaseData<NotificationRuleId> implements Ha
@Valid @Valid
private NotificationRuleRecipientsConfig recipientsConfig; private NotificationRuleRecipientsConfig recipientsConfig;
@Valid
private NotificationRuleConfig additionalConfig; private NotificationRuleConfig additionalConfig;
private NotificationRuleId externalId; private NotificationRuleId externalId;

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java

@ -16,12 +16,14 @@
package org.thingsboard.server.common.data.notification.rule; package org.thingsboard.server.common.data.notification.rule;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.validation.NoXss;
import java.io.Serializable; import java.io.Serializable;
@Data @Data
public class NotificationRuleConfig implements Serializable { public class NotificationRuleConfig implements Serializable {
@NoXss
private String description; private String description;
} }

12
common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java

@ -60,16 +60,4 @@ public class SchedulerUtils {
return LocalDate.now(UTC).with(TemporalAdjusters.firstDayOfNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli(); return LocalDate.now(UTC).with(TemporalAdjusters.firstDayOfNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli();
} }
public static long getStartOfNextNextMonth() {
return getStartOfNextNextMonth(UTC);
}
public static long getStartOfNextNextMonth(ZoneId zoneId) {
return LocalDate.now(UTC).with(firstDayOfNextNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli();
}
public static TemporalAdjuster firstDayOfNextNextMonth() {
return (temporal) -> temporal.with(DAY_OF_MONTH, 1).plus(2, MONTHS);
}
} }

4
ui-ngx/src/app/core/interceptors/global-http-interceptor.ts

@ -30,6 +30,7 @@ import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { parseHttpErrorMessage } from '@core/utils'; import { parseHttpErrorMessage } from '@core/utils';
import { getInterceptorConfig } from './interceptor.util'; import { getInterceptorConfig } from './interceptor.util';
import { DomSanitizer } from '@angular/platform-browser';
const tmpHeaders = {}; const tmpHeaders = {};
@ -46,6 +47,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
private dialogService: DialogService, private dialogService: DialogService,
private translate: TranslateService, private translate: TranslateService,
private authService: AuthService, private authService: AuthService,
private sanitizer: DomSanitizer
) {} ) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
@ -129,7 +131,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
} }
if (unhandled && !ignoreErrors) { if (unhandled && !ignoreErrors) {
const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType); const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType, this.sanitizer);
this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout); this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout);
} }
return throwError(() => errorResponse); return throwError(() => errorResponse);

7
ui-ngx/src/app/core/utils.ts

@ -32,6 +32,8 @@ import {
isNotEmptyTbFunction, isNotEmptyTbFunction,
TbFunction TbFunction
} from '@shared/models/js-function.models'; } from '@shared/models/js-function.models';
import { DomSanitizer } from '@angular/platform-browser';
import { SecurityContext } from '@angular/core';
const varsRegex = /\${([^}]*)}/g; const varsRegex = /\${([^}]*)}/g;
@ -854,7 +856,7 @@ export function getEntityDetailsPageURL(id: string, entityType: EntityType): str
} }
export function parseHttpErrorMessage(errorResponse: HttpErrorResponse, export function parseHttpErrorMessage(errorResponse: HttpErrorResponse,
translate: TranslateService, responseType?: string): {message: string; timeout: number} { translate: TranslateService, responseType?: string, sanitizer?:DomSanitizer): {message: string; timeout: number} {
let error = null; let error = null;
let errorMessage: string; let errorMessage: string;
let timeout = 0; let timeout = 0;
@ -882,6 +884,9 @@ export function parseHttpErrorMessage(errorResponse: HttpErrorResponse,
errorText += errorKey ? translate.instant(errorKey) : errorResponse.statusText; errorText += errorKey ? translate.instant(errorKey) : errorResponse.statusText;
errorMessage = errorText; errorMessage = errorText;
} }
if(sanitizer) {
errorMessage = sanitizer.sanitize(SecurityContext.HTML,errorMessage);
}
return {message: errorMessage, timeout}; return {message: errorMessage, timeout};
} }

10
ui-ngx/src/app/modules/home/components/relation/relation-table.component.html

@ -117,9 +117,13 @@
<ng-container *ngIf="direction === directions.FROM" matColumnDef="toName"> <ng-container *ngIf="direction === directions.FROM" matColumnDef="toName">
<mat-header-cell *matHeaderCellDef mat-sort-header style="width: 33%"> {{ 'relation.to-entity-name' | translate }} </mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header style="width: 33%"> {{ 'relation.to-entity-name' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let relation"> <mat-cell *matCellDef="let relation">
<a class="link" [routerLink]="relation.entityURL"> @if(relation.entityURL){
{{ relation.toName | customTranslate }} <a class="link" [routerLink]="relation.entityURL">
</a> {{ relation.toName | customTranslate }}
</a>
} @else {
{{ relation.toName | customTranslate }}
}
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container *ngIf="direction === directions.TO" matColumnDef="fromEntityTypeName"> <ng-container *ngIf="direction === directions.TO" matColumnDef="fromEntityTypeName">

131
ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html

@ -15,76 +15,79 @@
limitations under the License. limitations under the License.
--> -->
<section *ngIf="!versionCreateResult$" class="flex flex-col" style="height: 100%;"> @if (!versionCreateResult$) {
<mat-toolbar> <section class="flex flex-col" style="height: 100%;">
<h2>{{ 'version-control.create-entities-version' | translate }}</h2> <mat-toolbar>
<span class="flex-1"></span> <h2>{{ 'version-control.create-entities-version' | translate }}</h2>
</mat-toolbar> <span class="flex-1"></span>
<mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate" </mat-toolbar>
*ngIf="isLoading$ | async"> <mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate"
</mat-progress-bar> [class.hidden]="(isLoading$ | async) === false">
<form [formGroup]="createVersionFormGroup" class="flex flex-col" style="flex: 1; padding-top: 16px; overflow: auto;"> </mat-progress-bar>
<div class="flex flex-col gt-sm:flex-row gt-sm:gap-2"> <form [formGroup]="createVersionFormGroup" class="flex flex-col" style="flex: 1; padding-top: 16px; overflow: auto;">
<tb-branch-autocomplete <div class="flex flex-col gt-sm:flex-row gt-sm:gap-2">
class="flex-1" <tb-branch-autocomplete
required class="flex-1"
formControlName="branch"> appearance="outline"
</tb-branch-autocomplete> required
<mat-form-field class="mat-block flex-1"> formControlName="branch">
<mat-label translate>version-control.version-name</mat-label> </tb-branch-autocomplete>
<input required matInput formControlName="versionName"> <mat-form-field class="mat-block flex-1" appearance="outline">
<mat-error *ngIf="createVersionFormGroup.get('versionName').hasError('required') || <mat-label translate>version-control.version-name</mat-label>
createVersionFormGroup.get('versionName').hasError('pattern')"> <input required matInput formControlName="versionName">
{{ 'version-control.version-name-required' | translate }} <mat-error [class.hidden]="!(createVersionFormGroup.get('versionName').hasError('required') ||
</mat-error> createVersionFormGroup.get('versionName').hasError('pattern'))">
{{ 'version-control.version-name-required' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block" style="margin-bottom: 24px;" appearance="outline">
<mat-label translate>version-control.default-sync-strategy</mat-label>
<mat-select required formControlName="syncStrategy">
@for (strategy of syncStrategies; track strategy) {
<mat-option [value]="strategy">
{{syncStrategyTranslations.get(strategy) | translate}}
</mat-option>
}
</mat-select>
<mat-hint [innerHTML]="(syncStrategyHints.get(createVersionFormGroup.get('syncStrategy').value) | translate) | safe:'html'"></mat-hint>
</mat-form-field> </mat-form-field>
<tb-entity-types-version-create
formControlName="entityTypes">
</tb-entity-types-version-create>
</form>
<div class="flex items-center justify-end gap-2" style="padding-top: 16px;">
<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) || createVersionFormGroup.invalid || !createVersionFormGroup.dirty">
{{ 'action.create' | translate }}
</button>
</div> </div>
<mat-form-field class="mat-block" style="margin-bottom: 24px;">
<mat-label translate>version-control.default-sync-strategy</mat-label>
<mat-select required formControlName="syncStrategy">
<mat-option *ngFor="let strategy of syncStrategies" [value]="strategy">
{{syncStrategyTranslations.get(strategy) | translate}}
</mat-option>
</mat-select>
<mat-hint [innerHTML]="(syncStrategyHints.get(createVersionFormGroup.get('syncStrategy').value) | translate) | safe:'html'"></mat-hint>
</mat-form-field>
<tb-entity-types-version-create
formControlName="entityTypes">
</tb-entity-types-version-create>
</form>
<div class="flex items-center justify-end gap-2" style="padding-top: 16px;">
<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) || createVersionFormGroup.invalid || !createVersionFormGroup.dirty">
{{ 'action.create' | translate }}
</button>
</div>
</section>
<section *ngIf="versionCreateResult$">
<section *ngIf="resultMessage">
<div class="mat-headline-6 vc-result-message" [innerHtml]="resultMessage"></div>
</section> </section>
<div *ngIf="(versionCreateResult$ | async)?.done || hasError; else progress" class="flex items-center justify-end gap-2"> } @else {
<button mat-button color="primary" <div class="mat-headline-6 vc-result-message" [class.hidden]="!resultMessage" [innerHtml]="resultMessage"></div>
type="button" @if ((versionCreateResult$ | async)?.done || hasError) {
[disabled]="(isLoading$ | async)" <div class="flex items-center justify-end gap-2">
(click)="cancel()" cdkFocusInitial> <button mat-button color="primary"
{{ 'action.close' | translate }} type="button"
</button> [disabled]="(isLoading$ | async)"
</div> (click)="cancel()" cdkFocusInitial>
<ng-template #progress> {{ 'action.close' | translate }}
</button>
</div>
} @else {
<section class="flex flex-col items-center justify-center"> <section class="flex flex-col items-center justify-center">
<div class="mat-headline-6 vc-result-message progress"> <div class="mat-headline-6 vc-result-message progress">
<span translate>version-control.creating-version</span> <span translate>version-control.creating-version</span>
<mat-progress-bar mode="indeterminate"></mat-progress-bar> <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div> </div>
</section> </section>
</ng-template> }
</section> }

100
ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html

@ -15,59 +15,63 @@
limitations under the License. limitations under the License.
--> -->
<section *ngIf="!versionLoadResult$" class="flex flex-col" style="height: 100%;"> @if (!versionLoadResult$) {
<mat-toolbar> <section class="flex flex-col" style="height: 100%;">
<h2>{{ 'version-control.restore-entities-from-version' | translate: {versionName} }}</h2> <mat-toolbar>
<span class="flex-1"></span> <h2>{{ 'version-control.restore-entities-from-version' | translate: {versionName} }}</h2>
</mat-toolbar> <span class="flex-1"></span>
<mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate" </mat-toolbar>
*ngIf="isLoading$ | async"> <mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate"
</mat-progress-bar> [class.hidden]="(isLoading$ | async) === false">
<form [formGroup]="loadVersionFormGroup" class="flex flex-col" style="flex: 1; padding: 16px 0; overflow-y: auto; overflow-x: hidden"> </mat-progress-bar>
<tb-entity-types-version-load <form [formGroup]="loadVersionFormGroup" class="flex flex-col" style="flex: 1; padding: 16px 0; overflow-y: auto; overflow-x: hidden">
formControlName="entityTypes"> <tb-entity-types-version-load
</tb-entity-types-version-load> formControlName="entityTypes">
</form> </tb-entity-types-version-load>
<div tb-hint-tooltip-icon="{{ 'version-control.rollback-on-error-hint' | translate }}" [formGroup]="loadVersionFormGroup"> </form>
<mat-slide-toggle formControlName="rollbackOnError"> <div tb-hint-tooltip-icon="{{ 'version-control.rollback-on-error-hint' | translate }}" [formGroup]="loadVersionFormGroup">
{{ 'version-control.rollback-on-error' | translate }} <mat-slide-toggle formControlName="rollbackOnError">
</mat-slide-toggle> {{ 'version-control.rollback-on-error' | translate }}
</div> </mat-slide-toggle>
<div class="flex items-center justify-end gap-2" style="padding-top: 16px;"> </div>
<button mat-button color="primary" <div class="flex items-center justify-end gap-2" style="padding-top: 16px;">
type="button" <button mat-button color="primary"
[disabled]="(isLoading$ | async)" type="button"
(click)="cancel()" cdkFocusInitial> [disabled]="(isLoading$ | async)"
{{ 'action.cancel' | translate }} (click)="cancel()" cdkFocusInitial>
</button> {{ 'action.cancel' | translate }}
<button mat-raised-button color="primary" </button>
type="button" <button mat-raised-button color="primary"
(click)="restore()" type="button"
[disabled]="(isLoading$ | async) || loadVersionFormGroup.invalid"> (click)="restore()"
{{ 'action.restore' | translate }} [disabled]="(isLoading$ | async) || loadVersionFormGroup.invalid">
</button> {{ 'action.restore' | translate }}
</div> </button>
</section> </div>
<section *ngIf="versionLoadResult$"> </section>
<div *ngIf="(versionLoadResult$ | async)?.done && !entityTypeLoadResults?.length && !errorMessage" class="mat-headline-6 vc-result-message"> } @else {
<div [class.hidden]="!((versionLoadResult$ | async)?.done && !entityTypeLoadResults?.length && !errorMessage)" class="mat-headline-6 vc-result-message">
{{ 'version-control.no-entities-restored' | translate }} {{ 'version-control.no-entities-restored' | translate }}
</div> </div>
<div *ngIf="errorMessage" class="mat-headline-6 vc-result-message error" [innerHTML]="errorMessage"></div> <div [class.hidden]="!errorMessage" class="mat-headline-6 vc-result-message error" [innerHTML]="errorMessage"></div>
<div *ngFor="let entityTypeLoadResult of entityTypeLoadResults" class="mat-headline-6 vc-result-message">{{ entityTypeLoadResultMessage(entityTypeLoadResult) }}</div> @for (entityTypeLoadResult of entityTypeLoadResults; track entityTypeLoadResult.entityType) {
<div *ngIf="(versionLoadResult$ | async)?.done || hasError; else progress" class="flex items-center justify-end gap-2"> <div class="mat-headline-6 vc-result-message">{{ entityTypeLoadResultMessage(entityTypeLoadResult) }}</div>
<button mat-button color="primary" }
type="button" @if ((versionLoadResult$ | async)?.done || hasError) {
[disabled]="(isLoading$ | async)" <div class="flex items-center justify-end gap-2">
(click)="cancel()" cdkFocusInitial> <button mat-button color="primary"
{{ 'action.close' | translate }} type="button"
</button> [disabled]="(isLoading$ | async)"
</div> (click)="cancel()" cdkFocusInitial>
<ng-template #progress> {{ 'action.close' | translate }}
</button>
</div>
} @else {
<section class="flex flex-col items-center justify-center"> <section class="flex flex-col items-center justify-center">
<div class="mat-headline-6 vc-result-message progress"> <div class="mat-headline-6 vc-result-message progress">
<span translate>version-control.restoring-entities-from-version</span> <span translate>version-control.restoring-entities-from-version</span>
<mat-progress-bar mode="indeterminate"></mat-progress-bar> <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div> </div>
</section> </section>
</ng-template> }
</section> }

158
ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html

@ -18,86 +18,90 @@
<fieldset [formGroup]="entityTypesVersionCreateFormGroup" class="fields-group flex flex-col"> <fieldset [formGroup]="entityTypesVersionCreateFormGroup" class="fields-group flex flex-col">
<legend class="group-title" translate>version-control.entities-to-export</legend> <legend class="group-title" translate>version-control.entities-to-export</legend>
<div class="flex flex-col" style="flex: 1; overflow: auto;"> <div class="flex flex-col" style="flex: 1; overflow: auto;">
<div *ngFor="let entityTypeFormGroup of entityTypesFormGroupArray(); trackBy: trackByEntityType; @for (entityTypeFormGroup of entityTypesFormGroupArray(); track entityTypeFormGroup; let index = $index, isLast = $last) {
let $index = index; last as isLast;" <div class="flex flex-row items-center justify-start" [class.pb-2]="!isLast">
class="flex flex-row items-center justify-start" [class.pb-2]="!isLast"> <mat-expansion-panel class="entity-type-config flex-1" [class.load]="loading" [formGroup]="entityTypeFormGroup"
<mat-expansion-panel class="entity-type-config flex-1" [class.load]="loading" [formGroup]="entityTypeFormGroup" [expanded]="entityTypesFormGroupExpanded(entityTypeFormGroup)" (opened)="loading = false">
[expanded]="entityTypesFormGroupExpanded(entityTypeFormGroup)" (opened)="loading = false"> <mat-expansion-panel-header>
<mat-expansion-panel-header> <div class="flex flex-1 flex-row items-center justify-start">
<div class="flex flex-1 flex-row items-center justify-start"> <mat-panel-title>
<mat-panel-title> <div class="flex flex-1 flex-row items-center justify-start">
<div class="flex flex-1 flex-row items-center justify-start"> <div>{{ entityTypeText(entityTypeFormGroup) }}</div>
<div>{{ entityTypeText(entityTypeFormGroup) }}</div> </div>
</div> </mat-panel-title>
</mat-panel-title> <span class="flex-1"></span>
<span class="flex-1"></span> <button [class.hidden]="disabled" mat-icon-button style="min-width: 40px;"
<button *ngIf="!disabled" mat-icon-button style="min-width: 40px;" type="button"
type="button" (click)="removeEntityType(index)"
(click)="removeEntityType($index)" matTooltip="{{ 'action.remove' | translate }}"
matTooltip="{{ 'action.remove' | translate }}" matTooltipPosition="above">
matTooltipPosition="above"> <mat-icon>delete</mat-icon>
<mat-icon>delete</mat-icon> </button>
</button> </div>
</div> </mat-expansion-panel-header>
</mat-expansion-panel-header> <div class="entity-type-config-content flex flex-col gap-2">
<div class="entity-type-config-content flex flex-col gap-2"> <mat-divider></mat-divider>
<mat-divider></mat-divider> <div class="flex flex-row xs:flex-col gt-xs:gap-4">
<div class="flex flex-row xs:flex-col gt-xs:gap-4"> <tb-entity-type-select
<tb-entity-type-select showLabel
showLabel formControlName="entityType"
formControlName="entityType" appearance="outline"
required required
[filterAllowedEntityTypes]="false" [filterAllowedEntityTypes]="false"
[allowedEntityTypes]="allowedEntityTypes(entityTypeFormGroup)"> [allowedEntityTypes]="allowedEntityTypes(entityTypeFormGroup)">
</tb-entity-type-select> </tb-entity-type-select>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4" formGroupName="config"> <div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4" formGroupName="config">
<mat-form-field class="mat-block flex-1"> <mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>version-control.sync-strategy</mat-label> <mat-label translate>version-control.sync-strategy</mat-label>
<mat-select formControlName="syncStrategy"> <mat-select formControlName="syncStrategy">
<mat-option [value]="'default'"> <mat-option [value]="'default'">
{{ 'version-control.default' | translate }} {{ 'version-control.default' | translate }}
</mat-option> </mat-option>
<mat-option *ngFor="let strategy of syncStrategies" [value]="strategy"> @for (strategy of syncStrategies; track strategy) {
{{syncStrategyTranslations.get(strategy) | translate}} <mat-option [value]="strategy">
</mat-option> {{syncStrategyTranslations.get(strategy) | translate}}
</mat-select> </mat-option>
</mat-form-field> }
<div class="flex flex-1 flex-col"> </mat-select>
<mat-checkbox *ngIf="entityTypeFormGroup.get('entityType').value === entityTypes.DEVICE" formControlName="saveCredentials"> </mat-form-field>
{{ 'version-control.export-credentials' | translate }} <div class="flex flex-1 flex-col">
</mat-checkbox> <mat-checkbox [class.hidden]="entityTypeFormGroup.get('entityType').value !== entityTypes.DEVICE" formControlName="saveCredentials">
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveAttributes"> {{ 'version-control.export-credentials' | translate }}
{{ 'version-control.export-attributes' | translate }} </mat-checkbox>
</mat-checkbox> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveAttributes">
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveRelations"> {{ 'version-control.export-attributes' | translate }}
{{ 'version-control.export-relations' | translate }} </mat-checkbox>
</mat-checkbox> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveRelations">
<mat-checkbox *ngIf="typesWithCalculatedFields.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveCalculatedFields"> {{ 'version-control.export-relations' | translate }}
{{ 'version-control.export-calculated-fields' | translate }} </mat-checkbox>
</mat-checkbox> <mat-checkbox [class.hidden]="!typesWithCalculatedFields.has(entityTypeFormGroup.get('entityType').value)" formControlName="saveCalculatedFields">
{{ 'version-control.export-calculated-fields' | translate }}
</mat-checkbox>
</div>
</div> </div>
</div> </div>
<div class="flex flex-row xs:flex-col gt-xs:min-h-19 gt-xs:items-center gt-xs:justify-start gt-xs:gap-4"
formGroupName="config">
<mat-slide-toggle formControlName="allEntities">
{{ 'version-control.all-entities' | translate }}
</mat-slide-toggle>
@if (!entityTypeFormGroup.get('config').get('allEntities').value) {
<tb-entity-list
class="flex-1"
appearance="outline"
subscriptSizing="dynamic"
[entityType]="entityTypeFormGroup.get('entityType').value"
required
formControlName="entityIds">
</tb-entity-list>
}
</div>
</div> </div>
<div class="flex flex-row xs:flex-col gt-xs:min-h-19 gt-xs:items-center gt-xs:justify-start gt-xs:gap-4" </mat-expansion-panel>
formGroupName="config"> </div>
<mat-slide-toggle formControlName="allEntities"> } @empty {
{{ 'version-control.all-entities' | translate }} <span translate class="tb-prompt flex items-center justify-center">version-control.no-entities-to-export-prompt</span>
</mat-slide-toggle> }
<tb-entity-list
class="flex-1"
[class.!hidden]="entityTypeFormGroup.get('config').get('allEntities').value"
[entityType]="entityTypeFormGroup.get('entityType').value"
required
formControlName="entityIds">
</tb-entity-list>
</div>
</div>
</mat-expansion-panel>
</div>
<div *ngIf="!entityTypesFormGroupArray().length">
<span translate
class="tb-prompt flex items-center justify-center">version-control.no-entities-to-export-prompt</span>
</div>
</div> </div>
<div style="padding-top: 16px;" class="flex flex-row"> <div style="padding-top: 16px;" class="flex flex-row">
<button mat-raised-button color="primary" <button mat-raised-button color="primary"

4
ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts

@ -186,10 +186,6 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements
return !!(entityTypeControl as any).expanded; return !!(entityTypeControl as any).expanded;
} }
public trackByEntityType(index: number, entityTypeControl: AbstractControl): any {
return entityTypeControl;
}
public removeEntityType(index: number) { public removeEntityType(index: number) {
(this.entityTypesVersionCreateFormGroup.get('entityTypes') as UntypedFormArray).removeAt(index); (this.entityTypesVersionCreateFormGroup.get('entityTypes') as UntypedFormArray).removeAt(index);
} }

127
ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html

@ -18,73 +18,72 @@
<fieldset class="fields-group flex flex-col" [formGroup]="entityTypesVersionLoadFormGroup"> <fieldset class="fields-group flex flex-col" [formGroup]="entityTypesVersionLoadFormGroup">
<legend class="group-title" translate>version-control.entities-to-restore</legend> <legend class="group-title" translate>version-control.entities-to-restore</legend>
<div class="flex flex-col" style="flex: 1; overflow: auto;"> <div class="flex flex-col" style="flex: 1; overflow: auto;">
<div *ngFor="let entityTypeFormGroup of entityTypesFormGroupArray(); trackBy: trackByEntityType; @for (entityTypeFormGroup of entityTypesFormGroupArray(); track entityTypeFormGroup; let index = $index, isLast = $last) {
let $index = index; last as isLast;" <div class="flex flex-row items-center justify-start" [class.pb-2]="!isLast">
class="flex flex-row items-center justify-start" [class.pb-2]="!isLast"> <mat-expansion-panel class="entity-type-config flex-1" [class.load]="loading" [formGroup]="entityTypeFormGroup"
<mat-expansion-panel class="entity-type-config flex-1" [class.load]="loading" [formGroup]="entityTypeFormGroup" [expanded]="entityTypesFormGroupExpanded(entityTypeFormGroup)" (opened)="loading = false">
[expanded]="entityTypesFormGroupExpanded(entityTypeFormGroup)" (opened)="loading = false"> <mat-expansion-panel-header>
<mat-expansion-panel-header> <div class="flex flex-1 flex-row items-center justify-start">
<div class="flex flex-1 flex-row items-center justify-start"> <mat-panel-title>
<mat-panel-title> <div class="flex flex-1 flex-row items-center justify-start">
<div class="flex flex-1 flex-row items-center justify-start"> <div>{{ entityTypeText(entityTypeFormGroup) }}</div>
<div>{{ entityTypeText(entityTypeFormGroup) }}</div> </div>
</div> </mat-panel-title>
</mat-panel-title> <span class="flex-1"></span>
<span class="flex-1"></span> <button [class.hidden]="disabled" mat-icon-button style="min-width: 40px;"
<button *ngIf="!disabled" mat-icon-button style="min-width: 40px;" type="button"
type="button" (click)="removeEntityType(index)"
(click)="removeEntityType($index)" matTooltip="{{ 'action.remove' | translate }}"
matTooltip="{{ 'action.remove' | translate }}" matTooltipPosition="above">
matTooltipPosition="above"> <mat-icon>delete</mat-icon>
<mat-icon>delete</mat-icon> </button>
</button> </div>
</div> </mat-expansion-panel-header>
</mat-expansion-panel-header> <div class="entity-type-config-content flex flex-col gap-2">
<div class="entity-type-config-content flex flex-col gap-2"> <mat-divider></mat-divider>
<mat-divider></mat-divider> <div class="flex flex-row xs:flex-col gt-xs:gap-8">
<div class="flex flex-row xs:flex-col gt-xs:gap-8"> <tb-entity-type-select
<tb-entity-type-select class="flex-1"
class="flex-1" showLabel
showLabel formControlName="entityType"
formControlName="entityType" required
required appearance="outline"
[filterAllowedEntityTypes]="false" [filterAllowedEntityTypes]="false"
[allowedEntityTypes]="allowedEntityTypes(entityTypeFormGroup)"> [allowedEntityTypes]="allowedEntityTypes(entityTypeFormGroup)">
</tb-entity-type-select> </tb-entity-type-select>
<div class="flex flex-1 flex-row xs:flex-col xs:gap-2 gt-xs:gap-4" formGroupName="config"> <div class="flex flex-1 flex-row xs:flex-col xs:gap-2 gt-xs:gap-4" formGroupName="config">
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<mat-checkbox #removeOtherEntitiesCheckbox <mat-checkbox #removeOtherEntitiesCheckbox
formControlName="removeOtherEntities" formControlName="removeOtherEntities"
(change)="onRemoveOtherEntities(removeOtherEntitiesCheckbox, entityTypeFormGroup)"> (change)="onRemoveOtherEntities(removeOtherEntitiesCheckbox, entityTypeFormGroup)">
{{ 'version-control.remove-other-entities' | translate }} {{ 'version-control.remove-other-entities' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox formControlName="findExistingEntityByName"> <mat-checkbox formControlName="findExistingEntityByName">
{{ 'version-control.find-existing-entity-by-name' | translate }} {{ 'version-control.find-existing-entity-by-name' | translate }}
</mat-checkbox> </mat-checkbox>
</div> </div>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<mat-checkbox *ngIf="entityTypeFormGroup.get('entityType').value === entityTypes.DEVICE" formControlName="loadCredentials"> <mat-checkbox [class.hidden]="entityTypeFormGroup.get('entityType').value !== entityTypes.DEVICE" formControlName="loadCredentials">
{{ 'version-control.load-credentials' | translate }} {{ 'version-control.load-credentials' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadAttributes"> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadAttributes">
{{ 'version-control.load-attributes' | translate }} {{ 'version-control.load-attributes' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadRelations"> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadRelations">
{{ 'version-control.load-relations' | translate }} {{ 'version-control.load-relations' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="typesWithCalculatedFields.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadCalculatedFields"> <mat-checkbox [class.hidden]="!typesWithCalculatedFields.has(entityTypeFormGroup.get('entityType').value)" formControlName="loadCalculatedFields">
{{ 'version-control.load-calculated-fields' | translate }} {{ 'version-control.load-calculated-fields' | translate }}
</mat-checkbox> </mat-checkbox>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </mat-expansion-panel>
</mat-expansion-panel> </div>
</div> } @empty {
<div *ngIf="!entityTypesFormGroupArray().length"> <span translate class="tb-prompt flex items-center justify-center">version-control.no-entities-to-restore-prompt</span>
<span translate }
class="tb-prompt flex items-center justify-center">version-control.no-entities-to-restore-prompt</span>
</div>
</div> </div>
<div style="padding-top: 16px;" class="flex flex-row"> <div style="padding-top: 16px;" class="flex flex-row">
<button mat-raised-button color="primary" <button mat-raised-button color="primary"

33
ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html

@ -15,39 +15,40 @@
limitations under the License. limitations under the License.
--> -->
<section *ngIf="!versionCreateResult$"> @if (!versionCreateResult$) {
<mat-toolbar> <mat-toolbar>
<h2>{{ 'version-control.create-entity-version' | translate }}</h2> <h2>{{ 'version-control.create-entity-version' | translate }}</h2>
<span class="flex-1"></span> <span class="flex-1"></span>
</mat-toolbar> </mat-toolbar>
<mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate" <mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate"
*ngIf="isLoading$ | async"> [class.hidden]="(isLoading$ | async) === false">
</mat-progress-bar> </mat-progress-bar>
<form [formGroup]="createVersionFormGroup" style="padding-top: 16px;"> <form [formGroup]="createVersionFormGroup" style="padding-top: 16px;">
<fieldset [disabled]="isLoading$ | async"> <fieldset [disabled]="isLoading$ | async">
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<tb-branch-autocomplete <tb-branch-autocomplete
required required
appearance="outline"
formControlName="branch"> formControlName="branch">
</tb-branch-autocomplete> </tb-branch-autocomplete>
<mat-form-field class="mat-block flex-1"> <mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>version-control.version-name</mat-label> <mat-label translate>version-control.version-name</mat-label>
<input required matInput formControlName="versionName"> <input required matInput formControlName="versionName">
<mat-error *ngIf="createVersionFormGroup.get('versionName').hasError('required') || <mat-error [class.hidden]="!(createVersionFormGroup.get('versionName').hasError('required') ||
createVersionFormGroup.get('versionName').hasError('pattern')"> createVersionFormGroup.get('versionName').hasError('pattern'))">
{{ 'version-control.version-name-required' | translate }} {{ 'version-control.version-name-required' | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<mat-checkbox *ngIf="entityId.entityType === entityTypes.DEVICE" formControlName="saveCredentials" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="entityId.entityType !== entityTypes.DEVICE" formControlName="saveCredentials" style="margin-bottom: 16px;">
{{ 'version-control.export-credentials' | translate }} {{ 'version-control.export-credentials' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityId.entityType)" formControlName="saveAttributes" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityId.entityType)" formControlName="saveAttributes" style="margin-bottom: 16px;">
{{ 'version-control.export-attributes' | translate }} {{ 'version-control.export-attributes' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="!entityTypesWithoutRelatedData.has(entityId.entityType)" formControlName="saveRelations" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="entityTypesWithoutRelatedData.has(entityId.entityType)" formControlName="saveRelations" style="margin-bottom: 16px;">
{{ 'version-control.export-relations' | translate }} {{ 'version-control.export-relations' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="typesWithCalculatedFields.has(entityId.entityType)" formControlName="saveCalculatedFields" class="mb-4"> <mat-checkbox [class.hidden]="!typesWithCalculatedFields.has(entityId.entityType)" formControlName="saveCalculatedFields" class="mb-4">
{{ 'version-control.export-calculated-fields' | translate }} {{ 'version-control.export-calculated-fields' | translate }}
</mat-checkbox> </mat-checkbox>
</div> </div>
@ -67,10 +68,9 @@
{{ 'action.create' | translate }} {{ 'action.create' | translate }}
</button> </button>
</div> </div>
</section> } @else {
<section *ngIf="versionCreateResult$"> @if ((versionCreateResult$ | async)?.done || resultMessage) {
<section *ngIf="(versionCreateResult$ | async)?.done || resultMessage; else progress"> <section [class.hidden]="!resultMessage">
<section *ngIf="resultMessage">
<div class="mat-headline-6 vc-result-message">{{ resultMessage }}</div> <div class="mat-headline-6 vc-result-message">{{ resultMessage }}</div>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button mat-button color="primary" <button mat-button color="primary"
@ -81,13 +81,12 @@
</button> </button>
</div> </div>
</section> </section>
</section> } @else {
<ng-template #progress>
<section class="flex flex-col items-center justify-center"> <section class="flex flex-col items-center justify-center">
<div class="mat-headline-6 vc-result-message progress"> <div class="mat-headline-6 vc-result-message progress">
<span translate>version-control.creating-version</span> <span translate>version-control.creating-version</span>
<mat-progress-bar mode="indeterminate"></mat-progress-bar> <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div> </div>
</section> </section>
</ng-template> }
</section> }

99
ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html

@ -15,51 +15,53 @@
limitations under the License. limitations under the License.
--> -->
<section *ngIf="!versionLoadResult$"> @if (!versionLoadResult$) {
<mat-toolbar *ngIf="entityDataInfo"> @if (entityDataInfo) {
<h2>{{ 'version-control.restore-entity-from-version' | translate: {versionName} }}</h2> <mat-toolbar>
<span class="flex-1"></span> <h2 tbTruncateWithTooltip>{{ 'version-control.restore-entity-from-version' | translate: {versionName} }}</h2>
</mat-toolbar> <span class="flex-1"></span>
<mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate" </mat-toolbar>
*ngIf="entityDataInfo && (isLoading$ | async)"> <mat-progress-bar color="warn" style="z-index: 10; width: 100%; margin-bottom: -4px;" mode="indeterminate"
</mat-progress-bar> [class.hidden]="(isLoading$ | async) === false">
<mat-spinner *ngIf="!entityDataInfo" mode="indeterminate" diameter="80"></mat-spinner> </mat-progress-bar>
<form *ngIf="entityDataInfo" [formGroup]="restoreFormGroup" style="padding-top: 16px;"> <form [formGroup]="restoreFormGroup" style="padding-top: 16px;">
<fieldset [disabled]="isLoading$ | async"> <fieldset [disabled]="isLoading$ | async">
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<mat-checkbox *ngIf="entityDataInfo.hasCredentials" formControlName="loadCredentials" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="!entityDataInfo?.hasCredentials" formControlName="loadCredentials" style="margin-bottom: 16px;">
{{ 'version-control.load-credentials' | translate }} {{ 'version-control.load-credentials' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="entityDataInfo.hasAttributes" formControlName="loadAttributes" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="!entityDataInfo?.hasAttributes" formControlName="loadAttributes" style="margin-bottom: 16px;">
{{ 'version-control.load-attributes' | translate }} {{ 'version-control.load-attributes' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="entityDataInfo.hasRelations" formControlName="loadRelations" style="margin-bottom: 16px;"> <mat-checkbox [class.hidden]="!entityDataInfo?.hasRelations" formControlName="loadRelations" style="margin-bottom: 16px;">
{{ 'version-control.load-relations' | translate }} {{ 'version-control.load-relations' | translate }}
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="entityDataInfo.hasCalculatedFields" formControlName="loadCalculatedFields" class="mb-4"> <mat-checkbox [class.hidden]="!entityDataInfo?.hasCalculatedFields" formControlName="loadCalculatedFields" class="mb-4">
{{ 'version-control.load-calculated-fields' | translate }} {{ 'version-control.load-calculated-fields' | translate }}
</mat-checkbox> </mat-checkbox>
</div> </div>
</fieldset> </fieldset>
</form> </form>
<div *ngIf="entityDataInfo" class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button mat-button color="primary" <button mat-button color="primary"
type="button" type="button"
[disabled]="(isLoading$ | async)" [disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial> (click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }} {{ 'action.cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
type="button" type="button"
(click)="restore()" (click)="restore()"
[disabled]="(isLoading$ | async) || restoreFormGroup.invalid"> [disabled]="(isLoading$ | async) || restoreFormGroup.invalid">
{{ 'action.restore' | translate }} {{ 'action.restore' | translate }}
</button> </button>
</div> </div>
</section> } @else {
<section *ngIf="versionLoadResult$"> <mat-spinner mode="indeterminate" diameter="80"></mat-spinner>
<section *ngIf="(versionLoadResult$ | async)?.done || errorMessage; else progress"> }
<section *ngIf="errorMessage"> } @else {
@if ((versionLoadResult$ | async)?.done || errorMessage) {
<section [class.hidden]="!errorMessage">
<div class="mat-headline-6 vc-result-message error" [innerHTML]="errorMessage"></div> <div class="mat-headline-6 vc-result-message error" [innerHTML]="errorMessage"></div>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button mat-button color="primary" <button mat-button color="primary"
@ -70,13 +72,12 @@
</button> </button>
</div> </div>
</section> </section>
</section> } @else {
<ng-template #progress>
<section class="flex flex-col items-center justify-center"> <section class="flex flex-col items-center justify-center">
<div class="mat-headline-6 vc-result-message progress"> <div class="mat-headline-6 vc-result-message progress">
<span translate>version-control.restoring-entity-version</span> <span translate>version-control.restoring-entity-version</span>
<mat-progress-bar mode="indeterminate"></mat-progress-bar> <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div> </div>
</section> </section>
</ng-template> }
</section> }

2
ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html

@ -19,7 +19,7 @@
<form [formGroup]="confirmFormGroup" style="padding-top: 16px;"> <form [formGroup]="confirmFormGroup" style="padding-top: 16px;">
<fieldset [disabled]="(isLoading$ | async)"> <fieldset [disabled]="(isLoading$ | async)">
<div style="user-select: none;" [innerHTML]="removeOtherEntitiesConfirmText"></div> <div style="user-select: none;" [innerHTML]="removeOtherEntitiesConfirmText"></div>
<mat-form-field class="mat-block"> <mat-form-field class="mat-block" appearance="outline">
<input matInput formControlName="verification"/> <input matInput formControlName="verification"/>
</mat-form-field> </mat-form-field>
</fieldset> </fieldset>

8
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts

@ -24,6 +24,7 @@ import {
NgZone, NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
SecurityContext,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@ -53,6 +54,7 @@ import {
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
import { hidePageSizePixelValue } from '@shared/models/constants'; import { hidePageSizePixelValue } from '@shared/models/constants';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { DomSanitizer } from '@angular/platform-browser';
@Component({ @Component({
selector: 'tb-manage-widget-actions', selector: 'tb-manage-widget-actions',
@ -106,7 +108,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
private dialogs: DialogService, private dialogs: DialogService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private elementRef: ElementRef, private elementRef: ElementRef,
private zone: NgZone) { private zone: NgZone,
private sanitizer: DomSanitizer) {
super(); super();
const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC }; const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC };
this.pageLink = new PageLink(10, 0, null, sortOrder); this.pageLink = new PageLink(10, 0, null, sortOrder);
@ -289,7 +292,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
} }
const title = this.translate.instant('widget-config.delete-action-title'); const title = this.translate.instant('widget-config.delete-action-title');
const content = this.translate.instant('widget-config.delete-action-text', {actionName: action.name}); const content = this.translate.instant('widget-config.delete-action-text', {actionName: action.name});
this.dialogs.confirm(title, content, const safeContent = this.sanitizer.sanitize(SecurityContext.HTML, content);
this.dialogs.confirm(title, safeContent,
this.translate.instant('action.no'), this.translate.instant('action.no'),
this.translate.instant('action.yes'), true).subscribe( this.translate.instant('action.yes'), true).subscribe(
(res) => { (res) => {

2
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html

@ -57,7 +57,7 @@
<mat-option *ngFor="let column of configuredColumns; let $index = index" <mat-option *ngFor="let column of configuredColumns; let $index = index"
[value]="$index" [value]="$index"
[disabled]="usedCellClickColumns.includes($index)"> [disabled]="usedCellClickColumns.includes($index)">
{{ getCellClickColumnInfo($index, column) }} {{ getCellClickColumnInfo($index, column) | customTranslate }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-icon matSuffix <mat-icon matSuffix

3
ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss

@ -50,6 +50,7 @@ $warning-color: #FAA405;
color: $enabled-color; color: $enabled-color;
} }
.mat-mdc-progress-bar { .mat-mdc-progress-bar {
--mdc-linear-progress-track-color: #{rgba($enabled-color, 0.06)};
--mdc-linear-progress-active-indicator-color: #{$enabled-color}; --mdc-linear-progress-active-indicator-color: #{$enabled-color};
} }
} }
@ -58,6 +59,7 @@ $warning-color: #FAA405;
color: $disabled-color; color: $disabled-color;
} }
.mat-mdc-progress-bar { .mat-mdc-progress-bar {
--mdc-linear-progress-track-color: #{rgba($disabled-color, 0.06)};
--mdc-linear-progress-active-indicator-color: #{$disabled-color}; --mdc-linear-progress-active-indicator-color: #{$disabled-color};
} }
} }
@ -66,6 +68,7 @@ $warning-color: #FAA405;
color: $warning-color; color: $warning-color;
} }
.mat-mdc-progress-bar { .mat-mdc-progress-bar {
--mdc-linear-progress-track-color: #{rgba($warning-color, 0.06)};
--mdc-linear-progress-active-indicator-color: #{$warning-color}; --mdc-linear-progress-active-indicator-color: #{$warning-color};
} }
} }

32
ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts

@ -20,16 +20,16 @@ import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/wi
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ImagePipe } from '@shared/pipe/image.pipe'; import { ImagePipe } from '@shared/pipe/image.pipe';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { DataKey, DatasourceType, widgetType } from "@shared/models/widget.models"; import { DatasourceType, widgetType } from '@shared/models/widget.models';
import { WidgetSubscriptionOptions } from "@core/api/widget-api.models"; import { WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { formattedDataFormDatasourceData } from "@core/utils"; import { formattedDataFormDatasourceData } from '@core/utils';
import { UtilsService } from "@core/services/utils.service"; import { UtilsService } from '@core/services/utils.service';
import { import {
ApiUsageDataKeysSettings,
apiUsageDefaultSettings, apiUsageDefaultSettings,
ApiUsageWidgetSettings ApiUsageWidgetSettings,
} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models"; getUniqueDataKeys
} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models';
@Component({ @Component({
selector: 'tb-api-usage-widget', selector: 'tb-api-usage-widget',
@ -80,7 +80,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy {
type: DatasourceType.entity, type: DatasourceType.entity,
name: '', name: '',
entityAliasId: this.settings.dsEntityAliasId, entityAliasId: this.settings.dsEntityAliasId,
dataKeys: this.getUniqueDataKeys(this.settings.dataKeys) dataKeys: getUniqueDataKeys(this.settings.apiUsageDataKeys)
} }
const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = { const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = {
@ -122,7 +122,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy {
} }
parseApiUsages() { parseApiUsages() {
this.settings.dataKeys.forEach((key) => { this.settings.apiUsageDataKeys.forEach((key) => {
this.apiUsages.push({ this.apiUsages.push({
label: this.utils.customTranslation(key.label, key.label), label: this.utils.customTranslation(key.label, key.label),
state: key.state, state: key.state,
@ -134,20 +134,6 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy {
}) })
} }
getUniqueDataKeys(data: ApiUsageDataKeysSettings[]): DataKey[] {
const seenNames = new Set<string>();
return data
.flatMap(item => [item.status, item.maxLimit, item.current])
.filter(key => {
if (seenNames.has(key.name)) {
return false;
}
seenNames.add(key.name);
return true;
});
};
ngOnDestroy() { ngOnDestroy() {
if (this.contentResize$) { if (this.contentResize$) {
this.contentResize$.disconnect(); this.contentResize$.disconnect();

11
ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts

@ -510,11 +510,9 @@ export class LiquidLevelWidgetComponent implements OnInit {
let content: string; let content: string;
let container: JQuery<HTMLElement>; let container: JQuery<HTMLElement>;
const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); const jQueryContainerElement = $(this.liquidLevelContent.nativeElement);
let value = 'N/A'; let value: number | string = 'N/A';
if (isNumeric(data)) { if (isNumeric(data)) {
value = this.widgetUnitsConvertor(convertLiters(this.convertOutputData(percentage), this.widgetUnits as CapacityUnits, ConversionType.from)) value = +this.widgetUnitsConvertor(convertLiters(this.convertOutputData(percentage), this.widgetUnits as CapacityUnits, ConversionType.from)).toFixed(this.ctx.widgetConfig.decimals || 0);
.toFixed(this.settings.decimals || 0);
} }
this.valueColor.update(value); this.valueColor.update(value);
const valueTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.valueFont), const valueTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.valueFont),
@ -528,10 +526,9 @@ export class LiquidLevelWidgetComponent implements OnInit {
let volume: number | string; let volume: number | string;
if (this.widgetUnits !== CapacityUnits.percent) { if (this.widgetUnits !== CapacityUnits.percent) {
const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to); const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to);
volume = this.widgetUnitsConvertor(convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)) volume = +this.widgetUnitsConvertor(convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)).toFixed(this.ctx.widgetConfig.decimals || 0);
.toFixed(this.settings.decimals || 0);
} else { } else {
volume = this.widgetUnitsConvertor(this.volume).toFixed(this.settings.decimals || 0); volume = +this.widgetUnitsConvertor(this.volume).toFixed(this.ctx.widgetConfig.decimals || 0);
} }
const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont), const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont),

24
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts

@ -17,10 +17,10 @@
import { IAliasController } from '@core/api/widget-api.models'; import { IAliasController } from '@core/api/widget-api.models';
import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models';
import { DataKey, Widget, widgetType } from '@shared/models/widget.models'; import { DataKey, Widget, widgetType } from '@shared/models/widget.models';
import { Observable } from "rxjs"; import { Observable } from 'rxjs';
import { BackgroundSettings, BackgroundType } from "@shared/models/widget-settings.models"; import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models';
import { DataKeyType } from "@shared/models/telemetry/telemetry.models"; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { materialColors } from "@shared/models/material.models"; import { materialColors } from '@shared/models/material.models';
export interface ApiUsageSettingsContext { export interface ApiUsageSettingsContext {
aliasController: IAliasController; aliasController: IAliasController;
@ -33,7 +33,7 @@ export interface ApiUsageSettingsContext {
export interface ApiUsageWidgetSettings { export interface ApiUsageWidgetSettings {
dsEntityAliasId: string; dsEntityAliasId: string;
dataKeys: ApiUsageDataKeysSettings[]; apiUsageDataKeys: ApiUsageDataKeysSettings[];
targetDashboardState: string; targetDashboardState: string;
background: BackgroundSettings; background: BackgroundSettings;
padding: string; padding: string;
@ -80,7 +80,7 @@ const generateDataKey = (label: string, status: string, maxLimit: string, curren
export const apiUsageDefaultSettings: ApiUsageWidgetSettings = { export const apiUsageDefaultSettings: ApiUsageWidgetSettings = {
dsEntityAliasId: '', dsEntityAliasId: '',
dataKeys: [ apiUsageDataKeys: [
generateDataKey('{i18n:api-usage.transport-messages}', 'transportApiState', 'transportMsgLimit', 'transportMsgCount'), generateDataKey('{i18n:api-usage.transport-messages}', 'transportApiState', 'transportMsgLimit', 'transportMsgCount'),
generateDataKey('{i18n:api-usage.transport-data-points}', 'transportApiState', 'transportDataPointsLimit', 'transportDataPointsCount'), generateDataKey('{i18n:api-usage.transport-data-points}', 'transportApiState', 'transportDataPointsLimit', 'transportDataPointsCount'),
generateDataKey('{i18n:api-usage.rule-engine-executions}', 'ruleEngineApiState', 'ruleEngineExecutionLimit', 'ruleEngineExecutionCount'), generateDataKey('{i18n:api-usage.rule-engine-executions}', 'ruleEngineApiState', 'ruleEngineExecutionLimit', 'ruleEngineExecutionCount'),
@ -104,3 +104,15 @@ export const apiUsageDefaultSettings: ApiUsageWidgetSettings = {
padding: '0' padding: '0'
}; };
export const getUniqueDataKeys = (data: ApiUsageDataKeysSettings[]): DataKey[] => {
const seenNames = new Set<string>();
return data
.flatMap(item => [item.status, item.maxLimit, item.current])
.filter(key => {
if (seenNames.has(key.name)) {
return false;
}
seenNames.add(key.name);
return true;
});
};

24
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts

@ -37,15 +37,15 @@ import {
ApiUsageDataKeysSettings, ApiUsageDataKeysSettings,
apiUsageDefaultSettings, apiUsageDefaultSettings,
ApiUsageSettingsContext ApiUsageSettingsContext
} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models"; } from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models';
import { deepClone } from "@core/utils"; import { deepClone } from '@core/utils';
import { Observable, of } from "rxjs"; import { Observable, of } from 'rxjs';
import { import {
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigDialogData DataKeyConfigDialogData
} from "@home/components/widget/lib/settings/common/key/data-key-config-dialog.component"; } from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component';
import { MatDialog } from "@angular/material/dialog"; import { MatDialog } from '@angular/material/dialog';
import { CdkDragDrop } from "@angular/cdk/drag-drop"; import { CdkDragDrop } from '@angular/cdk/drag-drop';
@Component({ @Component({
selector: 'tb-api-usage-widget-settings', selector: 'tb-api-usage-widget-settings',
@ -82,11 +82,11 @@ export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
} }
protected doUpdateSettings(settingsForm: UntypedFormGroup, settings: WidgetSettings) { protected doUpdateSettings(settingsForm: UntypedFormGroup, settings: WidgetSettings) {
settingsForm.setControl('dataKeys', this.prepareDataKeysFormArray(settings?.dataKeys), {emitEvent: false}); settingsForm.setControl('apiUsageDataKeys', this.prepareDataKeysFormArray(settings?.apiUsageDataKeys), {emitEvent: false});
} }
dataKeysFormArray(): UntypedFormArray { dataKeysFormArray(): UntypedFormArray {
return this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray; return this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray;
} }
trackByDataKey(index: number): any { trackByDataKey(index: number): any {
@ -104,7 +104,7 @@ export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
} }
removeDataKey(index: number) { removeDataKey(index: number) {
(this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray).removeAt(index); (this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray).removeAt(index);
} }
addDataKey() { addDataKey() {
@ -115,7 +115,7 @@ export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
maxLimit: null, maxLimit: null,
current: null current: null
}; };
const dataKeysArray = this.apiUsageWidgetSettingsForm.get('dataKeys') as UntypedFormArray; const dataKeysArray = this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray;
const dataKeyControl = this.fb.control(dataKey, [this.apiUsageDataKeyValidator()]); const dataKeyControl = this.fb.control(dataKey, [this.apiUsageDataKeyValidator()]);
dataKeysArray.push(dataKeyControl); dataKeysArray.push(dataKeyControl);
} }
@ -131,7 +131,7 @@ export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { protected prepareInputSettings(settings: WidgetSettings): WidgetSettings {
return { return {
dsEntityAliasId: settings?.dsEntityAliasId, dsEntityAliasId: settings?.dsEntityAliasId,
dataKeys: settings?.dataKeys, apiUsageDataKeys: settings?.apiUsageDataKeys,
targetDashboardState: settings?.targetDashboardState, targetDashboardState: settings?.targetDashboardState,
background: settings?.background, background: settings?.background,
padding: settings.padding padding: settings.padding
@ -141,7 +141,7 @@ export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent {
protected onSettingsSet(settings: WidgetSettings) { protected onSettingsSet(settings: WidgetSettings) {
this.apiUsageWidgetSettingsForm = this.fb.group({ this.apiUsageWidgetSettingsForm = this.fb.group({
dsEntityAliasId: [settings?.dsEntityAliasId], dsEntityAliasId: [settings?.dsEntityAliasId],
dataKeys: this.prepareDataKeysFormArray(settings?.dataKeys), apiUsageDataKeys: this.prepareDataKeysFormArray(settings?.apiUsageDataKeys),
targetDashboardState: [settings?.targetDashboardState], targetDashboardState: [settings?.targetDashboardState],
background: [settings?.background, []], background: [settings?.background, []],
padding: [settings.padding, []] padding: [settings.padding, []]

2
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html

@ -47,7 +47,7 @@
<table mat-table [dataSource]="source.timeseriesDatasource" [trackBy]="trackByRowTimestamp" <table mat-table [dataSource]="source.timeseriesDatasource" [trackBy]="trackByRowTimestamp"
matSort [matSortActive]="source.pageLink.sortOrder.property" [matSortDirection]="source.pageLink.sortDirection()" matSortDisableClear> matSort [matSortActive]="source.pageLink.sortOrder.property" [matSortDirection]="source.pageLink.sortDirection()" matSortDisableClear>
<ng-container *ngIf="showTimestamp" [matColumnDef]="'0'"> <ng-container *ngIf="showTimestamp" [matColumnDef]="'0'">
<mat-header-cell *matHeaderCellDef mat-sort-header>Timestamp</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'widgets.table.timestamp-column-name' | translate }}</mat-header-cell>
<mat-cell *matCellDef="let row; let rowIndex = index" <mat-cell *matCellDef="let row; let rowIndex = index"
[innerHTML]="cellContent(source, null, 0, row, row[0], rowIndex) | async" [innerHTML]="cellContent(source, null, 0, row, row[0], rowIndex) | async"
[style]="cellStyle(source, null, 0, row, row[0], rowIndex) | async"> [style]="cellStyle(source, null, 0, row, row[0], rowIndex) | async">

2
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts

@ -513,7 +513,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
let title = ''; let title = '';
const header = this.sources[index].header.find(column => column.index.toString() === value); const header = this.sources[index].header.find(column => column.index.toString() === value);
if (value === '0') { if (value === '0') {
title = 'Timestamp'; title = this.translate.instant('widgets.table.timestamp-column-name');
} else if (value === 'actions') { } else if (value === 'actions') {
title = 'Actions'; title = 'Actions';
} else { } else {

21
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -926,7 +926,26 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
if (this.modelValue) { if (this.modelValue) {
const configuredColumns = new Array<CellClickColumnInfo>(); const configuredColumns = new Array<CellClickColumnInfo>();
if (this.modelValue.config?.datasources[0]?.dataKeys?.length) { if (this.modelValue.config?.datasources[0]?.dataKeys?.length) {
configuredColumns.push(...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys)); const {
displayEntityLabel,
displayEntityName,
displayEntityType,
entityNameColumnTitle,
entityLabelColumnTitle
} = this.modelValue.config.settings;
const displayEntitiesArray = [];
if (isDefined(displayEntityName)) {
const displayName = entityNameColumnTitle ? entityNameColumnTitle : 'entityName';
displayEntitiesArray.push({name: displayName, label: displayName});
}
if (isDefined(displayEntityLabel)) {
const displayLabel = entityLabelColumnTitle ? entityLabelColumnTitle : 'entityLabel';
displayEntitiesArray.push({name: displayLabel, label: displayLabel});
}
if (isDefined(displayEntityType)) {
displayEntitiesArray.push({name: 'entityType', label: 'entityType'});
}
configuredColumns.push(...displayEntitiesArray, ...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys));
} }
if (this.modelValue.config?.alarmSource?.dataKeys?.length) { if (this.modelValue.config?.alarmSource?.dataKeys?.length) {
configuredColumns.push(...this.keysToCellClickColumns(this.modelValue.config.alarmSource.dataKeys)); configuredColumns.push(...this.keysToCellClickColumns(this.modelValue.config.alarmSource.dataKeys));

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

@ -28,6 +28,12 @@
[class.!hidden]="isEdit || dashboardScope !== 'tenant'"> [class.!hidden]="isEdit || dashboardScope !== 'tenant'">
{{'dashboard.export' | translate }} {{'dashboard.export' | translate }}
</button> </button>
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'import')"
[class.!hidden]="isEdit || dashboardScope !== 'tenant'">
{{'dashboard.update-new-version' | translate }}
</button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
[disabled]="(isLoading$ | async)" [disabled]="(isLoading$ | async)"
(click)="onEntityAction($event, 'makePublic')" (click)="onEntityAction($event, 'makePublic')"

7
ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts

@ -22,7 +22,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
Dashboard, Dashboard, DashboardInfo,
getDashboardAssignedCustomersText, getDashboardAssignedCustomersText,
isCurrentPublicDashboardCustomer, isCurrentPublicDashboardCustomer,
isPublicDashboard isPublicDashboard
@ -31,13 +31,14 @@ import { DashboardService } from '@core/http/dashboard.service';
import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { isEqual } from '@core/utils'; import { isEqual } from '@core/utils';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { PageLink } from "@shared/models/page/page-link";
@Component({ @Component({
selector: 'tb-dashboard-form', selector: 'tb-dashboard-form',
templateUrl: './dashboard-form.component.html', templateUrl: './dashboard-form.component.html',
styleUrls: ['./dashboard-form.component.scss'] styleUrls: ['./dashboard-form.component.scss']
}) })
export class DashboardFormComponent extends EntityComponent<Dashboard> { export class DashboardFormComponent extends EntityComponent<Dashboard, PageLink, DashboardInfo> {
dashboardScope: 'tenant' | 'customer' | 'customer_user' | 'edge'; dashboardScope: 'tenant' | 'customer' | 'customer_user' | 'edge';
customerId: string; customerId: string;
@ -117,7 +118,7 @@ export class DashboardFormComponent extends EntityComponent<Dashboard> {
onPublicLinkCopied($event) { onPublicLinkCopied($event) {
this.store.dispatch(new ActionNotificationShow( this.store.dispatch(new ActionNotificationShow(
{ {
message: this.translate.instant('dashboard.public-link-copied-message'), message: this.translate.instant('dashboard.public-link-copied-message'),
type: 'success', type: 'success',
duration: 750, duration: 750,

15
ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts

@ -19,18 +19,24 @@ import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module'; import { SharedModule } from '@shared/shared.module';
import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; import { HomeDialogsModule } from '../../dialogs/home-dialogs.module';
import { DashboardFormComponent } from '@modules/home/pages/dashboard/dashboard-form.component'; import { DashboardFormComponent } from '@modules/home/pages/dashboard/dashboard-form.component';
import { ManageDashboardCustomersDialogComponent } from '@modules/home/pages/dashboard/manage-dashboard-customers-dialog.component'; import {
ManageDashboardCustomersDialogComponent
} from '@modules/home/pages/dashboard/manage-dashboard-customers-dialog.component';
import { DashboardRoutingModule } from './dashboard-routing.module'; import { DashboardRoutingModule } from './dashboard-routing.module';
import { MakeDashboardPublicDialogComponent } from '@modules/home/pages/dashboard/make-dashboard-public-dialog.component'; import {
MakeDashboardPublicDialogComponent
} from '@modules/home/pages/dashboard/make-dashboard-public-dialog.component';
import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.component'; import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.component';
import { ImportDashboardFileDialogComponent } from "@home/pages/dashboard/import-dashboard-file-dialog.component";
@NgModule({ @NgModule({
declarations: [ declarations: [
DashboardFormComponent, DashboardFormComponent,
DashboardTabsComponent, DashboardTabsComponent,
ManageDashboardCustomersDialogComponent, ManageDashboardCustomersDialogComponent,
MakeDashboardPublicDialogComponent MakeDashboardPublicDialogComponent,
ImportDashboardFileDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -40,4 +46,5 @@ import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.com
DashboardRoutingModule DashboardRoutingModule
] ]
}) })
export class DashboardModule { } export class DashboardModule {
}

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

@ -77,11 +77,16 @@ import {
EntityAliasesDialogComponent, EntityAliasesDialogComponent,
EntityAliasesDialogData EntityAliasesDialogData
} from '@home/components/alias/entity-aliases-dialog.component'; } from '@home/components/alias/entity-aliases-dialog.component';
import {
DashboardInfoDialogData,
ImportDashboardFileDialogComponent
} from "@home/pages/dashboard/import-dashboard-file-dialog.component";
import { PageLink } from "@shared/models/page/page-link";
@Injectable() @Injectable()
export class DashboardsTableConfigResolver { export class DashboardsTableConfigResolver {
private readonly config: EntityTableConfig<DashboardInfo | Dashboard> = new EntityTableConfig<DashboardInfo | Dashboard>(); private readonly config: EntityTableConfig<Dashboard, PageLink, DashboardInfo> = new EntityTableConfig<Dashboard, PageLink, DashboardInfo>();
constructor(private store: Store<AppState>, constructor(private store: Store<AppState>,
private dashboardService: DashboardService, private dashboardService: DashboardService,
@ -375,7 +380,7 @@ export class DashboardsTableConfigResolver {
return actions; return actions;
} }
openDashboard($event: Event, dashboard: DashboardInfo) { openDashboard($event: Event, dashboard: Dashboard) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -422,13 +427,27 @@ export class DashboardsTableConfigResolver {
)); ));
} }
exportDashboard($event: Event, dashboard: DashboardInfo) { exportDashboard($event: Event, dashboard: Dashboard) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
this.importExport.exportDashboard(dashboard.id.id); this.importExport.exportDashboard(dashboard.id.id);
} }
importDashboardFile($event: Event, dashboard: Dashboard) {
if ($event) {
$event.stopPropagation();
}
return this.dialog.open<ImportDashboardFileDialogComponent, DashboardInfoDialogData,
boolean>(ImportDashboardFileDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
dashboard
}
}).afterClosed();
}
addDashboardsToCustomer($event: Event) { addDashboardsToCustomer($event: Event) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
@ -449,7 +468,7 @@ export class DashboardsTableConfigResolver {
}); });
} }
makePublic($event: Event, dashboard: DashboardInfo) { makePublic($event: Event, dashboard: Dashboard) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -470,7 +489,7 @@ export class DashboardsTableConfigResolver {
); );
} }
makePrivate($event: Event, dashboard: DashboardInfo) { makePrivate($event: Event, dashboard: Dashboard) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -492,7 +511,7 @@ export class DashboardsTableConfigResolver {
); );
} }
manageAssignedCustomers($event: Event, dashboard: DashboardInfo) { manageAssignedCustomers($event: Event, dashboard: Dashboard) {
const assignedCustomersIds = dashboard.assignedCustomers ? const assignedCustomersIds = dashboard.assignedCustomers ?
dashboard.assignedCustomers.map(customerInfo => customerInfo.customerId.id) : []; dashboard.assignedCustomers.map(customerInfo => customerInfo.customerId.id) : [];
this.showManageAssignedCustomersDialog($event, [dashboard.id.id], 'manage', assignedCustomersIds); this.showManageAssignedCustomersDialog($event, [dashboard.id.id], 'manage', assignedCustomersIds);
@ -529,7 +548,7 @@ export class DashboardsTableConfigResolver {
}); });
} }
unassignFromCustomer($event: Event, dashboard: DashboardInfo, customerId: string) { unassignFromCustomer($event: Event, dashboard: Dashboard, customerId: string) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -579,7 +598,7 @@ export class DashboardsTableConfigResolver {
); );
} }
onDashboardAction(action: EntityAction<DashboardInfo>): boolean { onDashboardAction(action: EntityAction<Dashboard>): boolean {
switch (action.action) { switch (action.action) {
case 'open': case 'open':
this.openDashboard(action.event, action.entity); this.openDashboard(action.event, action.entity);
@ -587,6 +606,9 @@ export class DashboardsTableConfigResolver {
case 'export': case 'export':
this.exportDashboard(action.event, action.entity); this.exportDashboard(action.event, action.entity);
return true; return true;
case 'import':
this.importDashboardFile(action.event, action.entity);
return true;
case 'makePublic': case 'makePublic':
this.makePublic(action.event, action.entity); this.makePublic(action.event, action.entity);
return true; return true;

57
ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html

@ -0,0 +1,57 @@
<!--
Copyright © 2016-2025 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.
-->
<form [formGroup]="uploadFileFormGroup" (ngSubmit)="save()" style="width: 800px;">
<mat-toolbar color="primary">
<h2>{{ 'dashboard.update-new-version' | translate }}</h2>
<span class="flex-1"></span>
<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 style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<tb-file-input [contentConvertFunction]="loadDataFromJsonContent"
[existingFileName]="currentFileName"
(fileNameChanged)="currentFileName = $event"
label="{{'dashboard.upload-file-to-update' | translate}}"
formControlName="file"
dropLabel="{{ 'import.drop-json-file-or' | translate }}"
accept=".json,application/json"
allowedExtensions="json">
</tb-file-input>
</div>
<div mat-dialog-actions class="flex flex-row items-center justify-end">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || uploadFileFormGroup.invalid
|| !uploadFileFormGroup.dirty">
{{ 'action.save' | translate }}
</button>
</div>
</form>

89
ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts

@ -0,0 +1,89 @@
///
/// Copyright © 2016-2025 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 } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup } from '@angular/forms';
import { DashboardService } from '@core/http/dashboard.service';
import { Dashboard } from '@app/shared/models/dashboard.models';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
export interface DashboardInfoDialogData {
dashboard: Dashboard;
}
@Component({
selector: 'tb-import-dashboard-file-dialog',
templateUrl: './import-dashboard-file-dialog.component.html',
styleUrls: []
})
export class ImportDashboardFileDialogComponent extends DialogComponent<ImportDashboardFileDialogComponent> implements OnInit {
private dashboard: Dashboard;
currentFileName: string = '';
uploadFileFormGroup: FormGroup;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: DashboardInfoDialogData,
private dashboardService: DashboardService,
protected dialogRef: MatDialogRef<ImportDashboardFileDialogComponent>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.dashboard = data.dashboard;
}
ngOnInit(): void {
this.uploadFileFormGroup = this.fb.group({
file: [null]
});
}
cancel(): void {
this.dialogRef.close();
}
save() {
const fileControl = this.uploadFileFormGroup.get('file');
if (!fileControl || !fileControl.value) {
return;
}
const dashboardContent = {
...fileControl.value,
description: this.dashboard.configuration.description
};
this.dashboard.configuration = dashboardContent;
this.dashboardService.saveDashboard(this.dashboard).subscribe(() => {
this.dialogRef.close(true);
})
}
loadDataFromJsonContent(content: string): any {
try {
const importData = JSON.parse(content);
return importData ? importData['configuration'] : importData;
} catch (err) {
this.store.dispatch(new ActionNotificationShow({message: err.message, type: 'error'}));
return null;
}
}
}

2
ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html

@ -233,7 +233,7 @@
<div class="notification-content mini" [style.background-color]="preview.processedTemplates.MICROSOFT_TEAMS?.themeColor"> <div class="notification-content mini" [style.background-color]="preview.processedTemplates.MICROSOFT_TEAMS?.themeColor">
<div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div> <div class="subject">{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}</div>
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }} {{ preview.processedTemplates.MICROSOFT_TEAMS.body }}
<button mat-raised-button>{{ preview.processedTemplates.MICROSOFT_TEAMS.button.text }}</button> <button mat-raised-button>{{ preview.processedTemplates.MICROSOFT_TEAMS.button?.text }}</button>
</div> </div>
</section> </section>
</div> </div>

9
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts

@ -26,6 +26,7 @@ import {
OnInit, OnInit,
QueryList, QueryList,
Renderer2, Renderer2,
SecurityContext,
SkipSelf, SkipSelf,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
@ -97,6 +98,7 @@ import { HttpStatusCode } from '@angular/common/http';
import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models';
import { EntityDebugSettings } from '@shared/models/entity.models'; import { EntityDebugSettings } from '@shared/models/entity.models';
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import { DomSanitizer } from '@angular/platform-browser';
@Component({ @Component({
selector: 'tb-rulechain-page', selector: 'tb-rulechain-page',
@ -273,6 +275,7 @@ export class RuleChainPageComponent extends PageComponent
private renderer: Renderer2, private renderer: Renderer2,
private viewContainerRef: ViewContainerRef, private viewContainerRef: ViewContainerRef,
private changeDetector: ChangeDetectorRef, private changeDetector: ChangeDetectorRef,
private sanitizer:DomSanitizer,
public dialog: MatDialog, public dialog: MatDialog,
public dialogService: DialogService, public dialogService: DialogService,
public fb: FormBuilder) { public fb: FormBuilder) {
@ -1360,9 +1363,13 @@ export class RuleChainPageComponent extends PageComponent
name = node.name; name = node.name;
desc = this.translate.instant(ruleNodeTypeDescriptors.get(node.component.type).name) + ' - ' + node.component.name; desc = this.translate.instant(ruleNodeTypeDescriptors.get(node.component.type).name) + ' - ' + node.component.name;
if (node.additionalInfo) { if (node.additionalInfo) {
details = node.additionalInfo.description; details = this.sanitizer.sanitize(SecurityContext.HTML, node.additionalInfo.description);
} }
} }
name = this.sanitizer.sanitize(SecurityContext.HTML, name);
desc = this.sanitizer.sanitize(SecurityContext.HTML, desc);
let tooltipContent = '<div class="tb-rule-node-tooltip">' + let tooltipContent = '<div class="tb-rule-node-tooltip">' +
'<div id="tb-node-content">' + '<div id="tb-node-content">' +
'<div class="tb-node-title">' + name + '</div>' + '<div class="tb-node-title">' + name + '</div>' +

29
ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts

@ -29,6 +29,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { TimeService } from '@core/services/time.service'; import { TimeService } from '@core/services/time.service';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { isDefined } from '@core/utils';
@Component({ @Component({
selector: 'tb-datapoints-limit', selector: 'tb-datapoints-limit',
@ -69,7 +70,11 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator
@Input() @Input()
disabled: boolean; disabled: boolean;
private propagateChange = (v: any) => { }; private propagateChangeValue: any;
private propagateChange = (v: any) => {
this.propagateChangeValue = v;
};
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -79,6 +84,9 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
this.propagateChange = fn; this.propagateChange = fn;
if (isDefined(this.propagateChangeValue)) {
this.propagateChange(this.propagateChangeValue);
}
} }
registerOnTouched(fn: any): void { registerOnTouched(fn: any): void {
@ -115,19 +123,20 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator
} }
} }
private checkLimit(limit?: number): number { writeValue(value: number | null): void {
if (!limit || limit < this.minDatapointsLimit()) { this.modelValue = value;
return this.minDatapointsLimit(); let limit = this.modelValue;
if (!limit) {
limit = Math.ceil(this.maxDatapointsLimit() / 2);
} else if (limit < this.minDatapointsLimit()) {
limit = this.minDatapointsLimit();
} else if (limit > this.maxDatapointsLimit()) { } else if (limit > this.maxDatapointsLimit()) {
return this.maxDatapointsLimit(); limit = this.maxDatapointsLimit();
} }
return limit;
}
writeValue(value: number | null): void { this.updateView(limit);
this.modelValue = this.checkLimit(value);
this.datapointsLimitFormGroup.patchValue( this.datapointsLimitFormGroup.patchValue(
{ limit: this.modelValue }, {emitEvent: false} { limit: limit }, {emitEvent: false}
); );
} }

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

@ -15,7 +15,10 @@
limitations under the License. limitations under the License.
--> -->
<mat-form-field [formGroup]="branchFormGroup" class="mat-block" [floatLabel]="(selectionMode || emptyPlaceholder) ? 'always' : 'auto'" subscriptSizing="{{ subscriptSizing }}"> <mat-form-field [formGroup]="branchFormGroup" class="mat-block"
[floatLabel]="(selectionMode || emptyPlaceholder) ? 'always' : 'auto'"
[appearance]="appearance"
[subscriptSizing]="subscriptSizing">
<mat-label>{{ 'version-control.branch' | translate }}</mat-label> <mat-label>{{ 'version-control.branch' | translate }}</mat-label>
<input matInput type="text" placeholder="{{emptyPlaceholder || ((loading ? 'common.loading' : 'version-control.select-branch') | translate)}}" <input matInput type="text" placeholder="{{emptyPlaceholder || ((loading ? 'common.loading' : 'version-control.select-branch') | translate)}}"
#branchInput #branchInput

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

@ -35,7 +35,7 @@ import { BranchInfo } from '@shared/models/vc.models';
import { EntitiesVersionControlService } from '@core/http/entities-version-control.service'; import { EntitiesVersionControlService } from '@core/http/entities-version-control.service';
import { isNotEmptyStr } from '@core/utils'; import { isNotEmptyStr } from '@core/utils';
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { SubscriptSizing } from '@angular/material/form-field'; import { SubscriptSizing, MatFormFieldAppearance } from '@angular/material/form-field';
@Component({ @Component({
selector: 'tb-branch-autocomplete', selector: 'tb-branch-autocomplete',
@ -57,6 +57,9 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit
@Input() @Input()
subscriptSizing: SubscriptSizing = 'fixed'; subscriptSizing: SubscriptSizing = 'fixed';
@Input()
appearance: MatFormFieldAppearance = 'fill';
private requiredValue: boolean; private requiredValue: boolean;
get required(): boolean { get required(): boolean {

7
ui-ngx/src/app/shared/models/time/time.models.ts

@ -1125,6 +1125,7 @@ export const cloneSelectedTimewindow = (timewindow: Timewindow): Timewindow => {
export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly: boolean, export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly: boolean,
historyOnly: boolean, hasAggregation: boolean, hasTimezone = true): Timewindow => { historyOnly: boolean, hasAggregation: boolean, hasTimezone = true): Timewindow => {
const noneAggregation = hasAggregation && timewindow.aggregation?.type === AggregationType.NONE;
if (timewindow.selectedTab === TimewindowType.REALTIME) { if (timewindow.selectedTab === TimewindowType.REALTIME) {
if (quickIntervalOnly || timewindow.realtime.realtimeType === RealtimeWindowType.INTERVAL) { if (quickIntervalOnly || timewindow.realtime.realtimeType === RealtimeWindowType.INTERVAL) {
delete timewindow.realtime.timewindowMs; delete timewindow.realtime.timewindowMs;
@ -1138,7 +1139,7 @@ export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly:
delete timewindow.history?.quickInterval; delete timewindow.history?.quickInterval;
delete timewindow.history?.interval; delete timewindow.history?.interval;
if (!hasAggregation) { if (!hasAggregation || noneAggregation) {
delete timewindow.realtime.interval; delete timewindow.realtime.interval;
} }
} else { } else {
@ -1162,13 +1163,15 @@ export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly:
delete timewindow.realtime?.quickInterval; delete timewindow.realtime?.quickInterval;
delete timewindow.realtime?.interval; delete timewindow.realtime?.interval;
if (!hasAggregation) { if (!hasAggregation || noneAggregation) {
delete timewindow.history.interval; delete timewindow.history.interval;
} }
} }
if (!hasAggregation) { if (!hasAggregation) {
delete timewindow.aggregation; delete timewindow.aggregation;
} else if (!noneAggregation) {
delete timewindow.aggregation.limit;
} }
if (historyOnly) { if (historyOnly) {

88
ui-ngx/src/app/shared/models/widget/home-widgets/api-usage-model.definition.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2025 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 { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models';
import { FilterInfo, Filters } from '@shared/models/query/query.models';
import { Dashboard } from '@shared/models/dashboard.models';
import { Datasource, DatasourceType, Widget } from '@shared/models/widget.models';
import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition';
import {
ApiUsageWidgetSettings,
getUniqueDataKeys
} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models';
interface AliasFilterPair {
alias?: EntityAliasInfo;
filter?: FilterInfo;
}
interface ApiUsageDatasourcesInfo {
ds?: AliasFilterPair;
}
export const ApiUsageModelDefinition: WidgetModelDefinition<ApiUsageDatasourcesInfo> = {
testWidget(widget: Widget): boolean {
if (widget?.config?.settings) {
const settings = widget.config.settings;
if (settings.apiUsageDataKeys && Array.isArray(settings.apiUsageDataKeys)) {
return true;
}
}
return false;
},
prepareExportInfo(dashboard: Dashboard, widget: Widget): ApiUsageDatasourcesInfo {
const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings;
const info: ApiUsageDatasourcesInfo = {};
if (settings.dsEntityAliasId) {
info.ds = prepareExportDataSourcesInfo(dashboard, settings.dsEntityAliasId);
}
return info;
},
updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: ApiUsageDatasourcesInfo): void {
const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings;
if (info?.ds?.alias) {
settings.dsEntityAliasId = getEntityAliasId(entityAliases, info.ds.alias);
}
},
datasources(widget: Widget): Datasource[] {
const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings;
const datasources: Datasource[] = [];
if (settings.apiUsageDataKeys?.length && settings.dsEntityAliasId) {
datasources.push({
type: DatasourceType.entity,
name: '',
entityAliasId: settings.dsEntityAliasId,
dataKeys: getUniqueDataKeys(settings.apiUsageDataKeys)
});
}
return datasources;
},
hasTimewindow(): boolean {
return false;
}
};
const prepareExportDataSourcesInfo = (dashboard: Dashboard, settings: string): AliasFilterPair => {
const aliasAndFilter: AliasFilterPair = {};
const entityAlias = dashboard.configuration.entityAliases[settings];
if (entityAlias) {
aliasAndFilter.alias = {
alias: entityAlias.alias,
filter: entityAlias.filter
};
}
return aliasAndFilter;
}

62
ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts

@ -17,18 +17,15 @@
import { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; import { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models';
import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models'; import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models';
import { Dashboard } from '@shared/models/dashboard.models'; import { Dashboard } from '@shared/models/dashboard.models';
import { DataKey, Datasource, datasourcesHasAggregation, DatasourceType, Widget } from '@shared/models/widget.models'; import { Datasource, datasourcesHasAggregation, DatasourceType, Widget } from '@shared/models/widget.models';
import { import {
additionalMapDataSourcesToDatasources, additionalMapDataSourcesToDatasources,
BaseMapSettings, BaseMapSettings,
CirclesDataLayerSettings,
MapDataLayerSettings, MapDataLayerSettings,
MapDataLayerType, MapDataLayerType,
MapDataSourceSettings, MapDataSourceSettings,
mapDataSourceSettingsToDatasource, mapDataSourceSettingsToDatasource,
MapType, MapType
MarkersDataLayerSettings,
PolygonsDataLayerSettings
} from '@shared/models/widget/maps/map.models'; } from '@shared/models/widget/maps/map.models';
import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition';
@ -126,7 +123,7 @@ export const MapModelDefinition: WidgetModelDefinition<MapDatasourcesInfo> = {
datasources.push(...getMapDataLayersDatasources(settings.circles)); datasources.push(...getMapDataLayersDatasources(settings.circles));
} }
if (settings.additionalDataSources?.length) { if (settings.additionalDataSources?.length) {
datasources.push(...getMapDataLayersDatasources(settings.additionalDataSources)); datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources));
} }
return datasources; return datasources;
}, },
@ -137,13 +134,13 @@ export const MapModelDefinition: WidgetModelDefinition<MapDatasourcesInfo> = {
} else { } else {
const datasources: Datasource[] = []; const datasources: Datasource[] = [];
if (settings.markers?.length) { if (settings.markers?.length) {
datasources.push(...getMapLatestDataLayersDatasources(settings.markers, 'markers')); datasources.push(...getMapDataLayersDatasources(settings.markers, true, 'markers'));
} }
if (settings.polygons?.length) { if (settings.polygons?.length) {
datasources.push(...getMapLatestDataLayersDatasources(settings.polygons, 'polygons')); datasources.push(...getMapDataLayersDatasources(settings.polygons, true, 'polygons'));
} }
if (settings.circles?.length) { if (settings.circles?.length) {
datasources.push(...getMapLatestDataLayersDatasources(settings.circles, 'circles')); datasources.push(...getMapDataLayersDatasources(settings.circles, true, 'circles'));
} }
if (settings.additionalDataSources?.length) { if (settings.additionalDataSources?.length) {
datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources)); datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources));
@ -237,52 +234,21 @@ const prepareAliasAndFilterPair = (dashboard: Dashboard, settings: MapDataSource
} }
} }
const getMapDataLayersDatasources = (settings: MapDataLayerSettings[] | MapDataSourceSettings[]): Datasource[] => { const getMapDataLayersDatasources = (settings: MapDataLayerSettings[],
includeDataKeys = false, dataLayerType?: MapDataLayerType): Datasource[] => {
const datasources: Datasource[] = []; const datasources: Datasource[] = [];
settings.forEach((dsSettings) => { settings.forEach((dsSettings) => {
datasources.push(mapDataSourceSettingsToDatasource(dsSettings)); const datasource: Datasource = mapDataSourceSettingsToDatasource(dsSettings, null, includeDataKeys, dataLayerType);
if ((dsSettings as MapDataLayerSettings).additionalDataSources?.length) {
(dsSettings as MapDataLayerSettings).additionalDataSources.forEach((ds) => {
datasources.push(mapDataSourceSettingsToDatasource(ds));
});
}
});
return datasources;
};
const getMapLatestDataLayersDatasources = (settings: MapDataLayerSettings[],
dataLayerType: MapDataLayerType): Datasource[] => {
const datasources: Datasource[] = [];
settings.forEach((dsSettings) => {
const dataKeys: DataKey[] = getMapLatestDataLayerDatasourceDataKeys(dsSettings, dataLayerType);
const datasource: Datasource = mapDataSourceSettingsToDatasource(dsSettings);
datasource.dataKeys.push(...dataKeys);
datasources.push(datasource); datasources.push(datasource);
if ((dsSettings).additionalDataSources?.length) { if (dsSettings.additionalDataSources?.length) {
(dsSettings).additionalDataSources.forEach((ds) => { dsSettings.additionalDataSources.forEach((ds) => {
const additionalDatasource: Datasource = mapDataSourceSettingsToDatasource(ds); const additionalDatasource: Datasource = mapDataSourceSettingsToDatasource(ds);
additionalDatasource.dataKeys.push(...dataKeys); if (includeDataKeys) {
additionalDatasource.dataKeys.push(...datasource.dataKeys);
}
datasources.push(additionalDatasource); datasources.push(additionalDatasource);
}); });
} }
}); });
return datasources; return datasources;
}; };
const getMapLatestDataLayerDatasourceDataKeys = (settings: MapDataLayerSettings,
dataLayerType: MapDataLayerType): DataKey[] => {
const dataKeys = settings.additionalDataKeys || [];
switch (dataLayerType) {
case 'markers':
const markersSettings = settings as MarkersDataLayerSettings;
dataKeys.push(markersSettings.xKey, markersSettings.yKey);
break;
case 'polygons':
dataKeys.push((settings as PolygonsDataLayerSettings).polygonKey);
break;
case 'circles':
dataKeys.push((settings as CirclesDataLayerSettings).circleKey);
break;
}
return dataKeys;
};

35
ui-ngx/src/app/shared/models/widget/maps/map.models.ts

@ -24,6 +24,7 @@ import {
} from '@shared/models/widget.models'; } from '@shared/models/widget.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { import {
deepClone,
guid, guid,
hashCode, hashCode,
isDefinedAndNotNull, isDefinedAndNotNull,
@ -61,19 +62,44 @@ export interface TbMapDatasource extends Datasource {
mapDataIds: string[]; mapDataIds: string[];
} }
export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings, id = guid()): TbMapDatasource => { export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings | MapDataLayerSettings,
id = guid(),
includeDataKeys = false, dataLayerType?: MapDataLayerType): TbMapDatasource => {
const dataKeys = includeDataKeys ? mapDataLayerDatasourceDataKeys((settings as MapDataLayerSettings), dataLayerType) : [];
return { return {
type: settings.dsType, type: settings.dsType,
name: settings.dsLabel, name: settings.dsLabel,
deviceId: settings.dsDeviceId, deviceId: settings.dsDeviceId,
entityAliasId: settings.dsEntityAliasId, entityAliasId: settings.dsEntityAliasId,
filterId: settings.dsFilterId, filterId: settings.dsFilterId,
dataKeys: [], dataKeys: dataKeys,
latestDataKeys: [], latestDataKeys: [],
mapDataIds: [id] mapDataIds: [id]
}; };
}; };
const mapDataLayerDatasourceDataKeys = (settings: MapDataLayerSettings,
dataLayerType: MapDataLayerType): DataKey[] => {
const dataKeys = settings.additionalDataKeys?.length ? deepClone(settings.additionalDataKeys) : [];
switch (dataLayerType) {
case 'trips':
const tripsSettings = settings as TripsDataLayerSettings;
dataKeys.push(tripsSettings.xKey, tripsSettings.yKey);
break;
case 'markers':
const markersSettings = settings as MarkersDataLayerSettings;
dataKeys.push(markersSettings.xKey, markersSettings.yKey);
break;
case 'polygons':
dataKeys.push((settings as PolygonsDataLayerSettings).polygonKey);
break;
case 'circles':
dataKeys.push((settings as CirclesDataLayerSettings).circleKey);
break;
}
return dataKeys;
};
export enum DataLayerPatternType { export enum DataLayerPatternType {
pattern = 'pattern', pattern = 'pattern',
@ -658,10 +684,11 @@ export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings {
dataKeys: DataKey[]; dataKeys: DataKey[];
} }
export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[]): TbMapDatasource[] => { export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[],
includeDataKeys = true): TbMapDatasource[] => {
return additionalMapDataSources.map(addDs => { return additionalMapDataSources.map(addDs => {
const res = mapDataSourceSettingsToDatasource(addDs); const res = mapDataSourceSettingsToDatasource(addDs);
res.dataKeys = addDs.dataKeys; res.dataKeys = includeDataKeys ? addDs.dataKeys : [];
return res; return res;
}); });
}; };

4
ui-ngx/src/app/shared/models/widget/widget-model.definition.ts

@ -19,6 +19,7 @@ import { Dashboard } from '@shared/models/dashboard.models';
import { EntityAliases } from '@shared/models/alias.models'; import { EntityAliases } from '@shared/models/alias.models';
import { Filters } from '@shared/models/query/query.models'; import { Filters } from '@shared/models/query/query.models';
import { MapModelDefinition } from '@shared/models/widget/maps/map-model.definition'; import { MapModelDefinition } from '@shared/models/widget/maps/map-model.definition';
import { ApiUsageModelDefinition } from '@shared/models/widget/home-widgets/api-usage-model.definition';
export interface WidgetModelDefinition<T = any> { export interface WidgetModelDefinition<T = any> {
testWidget(widget: Widget): boolean; testWidget(widget: Widget): boolean;
@ -29,7 +30,8 @@ export interface WidgetModelDefinition<T = any> {
} }
const widgetModelRegistry: WidgetModelDefinition[] = [ const widgetModelRegistry: WidgetModelDefinition[] = [
MapModelDefinition MapModelDefinition,
ApiUsageModelDefinition
]; ];
export const findWidgetModelDefinition = (widget: Widget): WidgetModelDefinition => { export const findWidgetModelDefinition = (widget: Widget): WidgetModelDefinition => {

2440
ui-ngx/src/assets/dashboard/api_usage.json

File diff suppressed because it is too large

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

@ -1429,6 +1429,8 @@
"mobile-order": "Dashboard order in mobile application", "mobile-order": "Dashboard order in mobile application",
"mobile-hide": "Hide dashboard in mobile application", "mobile-hide": "Hide dashboard in mobile application",
"update-image": "Update dashboard image", "update-image": "Update dashboard image",
"update-new-version": "Upload new version",
"upload-file-to-update": "Upload file to update",
"take-screenshot": "Take screenshot", "take-screenshot": "Take screenshot",
"select-widget-title": "Select widget", "select-widget-title": "Select widget",
"select-widget-value": "{{title}}: select widget", "select-widget-value": "{{title}}: select widget",
@ -9030,6 +9032,7 @@
"show-empty-space-hidden-action": "Show empty space instead of hidden cell button action", "show-empty-space-hidden-action": "Show empty space instead of hidden cell button action",
"dont-reserve-space-hidden-action": "Don't reserve space for hidden action buttons", "dont-reserve-space-hidden-action": "Don't reserve space for hidden action buttons",
"display-timestamp": "Timestamp", "display-timestamp": "Timestamp",
"timestamp-column-name":"Timestamp",
"display-pagination": "Display pagination", "display-pagination": "Display pagination",
"default-page-size": "Default page size", "default-page-size": "Default page size",
"page-step-settings": "Page step settings", "page-step-settings": "Page step settings",
@ -9601,7 +9604,8 @@
"add-key": "Add key", "add-key": "Add key",
"no-key": "No key", "no-key": "No key",
"delete-key": "Delete key", "delete-key": "Delete key",
"target-dashboard-state": "Target dashboard state" "target-dashboard-state": "Target dashboard state",
"go-to-main-state": "Go to default view"
} }
}, },
"color": { "color": {

Loading…
Cancel
Save