Browse Source

Merge pull request #15008 from thingsboard/rc

RC
pull/15033/head
Viacheslav Klimov 6 months ago
committed by GitHub
parent
commit
d437106151
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .gitignore
  2. 39
      TEST_FAST.md
  3. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java
  4. 2
      application/src/main/resources/thingsboard.yml
  5. 52
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java
  6. 60
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java
  7. 2
      edqs/src/main/resources/edqs.yml
  8. 2
      msa/vc-executor/src/main/resources/tb-vc-executor.yml
  9. 43
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  10. 31
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  11. 2
      transport/coap/src/main/resources/tb-coap-transport.yml
  12. 2
      transport/http/src/main/resources/tb-http-transport.yml
  13. 2
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  14. 2
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  15. 2
      transport/snmp/src/main/resources/tb-snmp-transport.yml
  16. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  17. 1
      ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts
  18. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  19. 104
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.html
  20. 18
      ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html
  21. 11
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html
  22. 45
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts
  23. 2
      ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts
  24. 1
      ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts
  25. 1
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts
  26. 1
      ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts
  27. 1
      ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts
  28. 1
      ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts
  29. 1
      ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts
  30. 6
      ui-ngx/src/app/shared/components/image/image-gallery.component.html
  31. 1
      ui-ngx/src/app/shared/models/entity-type.models.ts
  32. 2
      ui-ngx/src/assets/dashboard/tenant_admin_home_page.json
  33. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

1
.gitignore

@ -37,3 +37,4 @@ rebuild-docker.sh
*/.run/**
.run/**
.run
.claude/

39
TEST_FAST.md

@ -10,22 +10,43 @@ mvn clean install -T6 -DskipTests
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
mvn test -pl dao -Dparallel=packages -DforkCount=4
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.edge.**' -DforkCount=4 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.service.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.mqtt.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.coap.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='org.thingsboard.server.transport.lwm2m.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dsurefire.excludes='**/nosql/*Test.java' -Dtest='**/*TestSuite.java' -DforkCount=4 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.edge.**' -DforkCount=4 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.service.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.mqtt.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.coap.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.lwm2m.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
mvn test -pl application -Dtest='**/*TestSuite.java' -DforkCount=4 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
#the rest of application tests
mvn test -pl application -Dtest='
!**/nosql/*Test.java,
!**/nosql/**,
!org.thingsboard.server.controller.**,
!org.thingsboard.server.edge.**,
!org.thingsboard.server.service.**,
!org.thingsboard.server.transport.mqtt.**,
!org.thingsboard.server.transport.coap.**,
!org.thingsboard.server.transport.lwm2m.**
!org.thingsboard.server.transport.lwm2m.**,
!**/*TestSuite.java
' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
```
## Testcontainers compatibility with the Docker API workaround
In case your tests failed to run testcontainers due to unsupported Docker API version
:coffee: testcontainers (Docker API 1.32) + :whale: docker 29 (min API 1.44) workaround
Add to /etc/docker/daemon.json and restart docker
```json
{
"min-api-version": "1.32"
}
```
Same works on Mac, except `daemon.json` are located in another folder and required to be edited from Docker Desktop UI.
Tip: If your testcontainer are struggling to find any Docker. You can try to remove the testcontainers property file. It will be recreated on the next testcontainers run.
```bash
rm ~/.testcontainers.properties
```

10
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java

@ -29,18 +29,10 @@ public class RestAuthenticationDetails implements Serializable {
private final Client userAgent;
public RestAuthenticationDetails(HttpServletRequest request) {
this.clientAddress = getClientIP(request);
this.clientAddress = request.getRemoteAddr();
this.userAgent = getUserAgent(request);
}
private static String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
private static Client getUserAgent(HttpServletRequest request) {
Parser uaParser = new Parser();
return uaParser.parse(request.getHeader("User-Agent"));

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

@ -2039,7 +2039,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
health:
elasticsearch:
# Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck

52
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java

@ -140,45 +140,61 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
}
private void startDownloading() {
long delay = 0;
// Step 1: state = 1
scheduler.schedule(() -> {
try {
state.set(1);
fireResourceChange(3);
Thread.sleep(100);
state.set(2);
fireResourceChange(3);
} catch (Exception e) {
}
}, 100, TimeUnit.MILLISECONDS);
state.set(1);
fireResourceChange(3);
log.info("Downloading started: state=[{}]", state.get());
}, delay, TimeUnit.MILLISECONDS);
delay += 100; // next step after 100 ms
// Step 2: state = 2
scheduler.schedule(() -> {
state.set(2);
fireResourceChange(3);
log.info("Downloading in progress: state=[{}]", state.get());
}, delay, TimeUnit.MILLISECONDS);
}
private void startUpdating(LwM2mServer identity) {
scheduler.schedule(() -> {
try {
// Update state + result
state.set(3);
fireResourceChange(3);
Thread.sleep(100);
updateResult.set(1);
fireResourceChange(5);
this.pkgName = TITLE;
fireResourceChange(6);
this.pkgVersion = TARGET_FW_VERSION;
fireResourceChange(7);
if (this.leshanClient != null) {
log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.stop(false);
log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity));
this.leshanClient.start();
this.pkgName = this.pkgNameDef;
this.pkgVersion = this.pkgVersionDef;
// Delayed reset pkgName/pkgVersion, after reboot + registration
scheduler.schedule(() -> {
this.pkgName = this.pkgNameDef;
fireResourceChange(6);
this.pkgVersion = this.pkgVersionDef;
fireResourceChange(7);
log.info("FW resources updating to new values: pkgName=[{}], pkgVersion=[{}]",
this.pkgName, this.pkgVersion);
}, 15, TimeUnit.SECONDS); // 15 sec — safe timing
}
} catch (Exception e) {
log.error("Error during firmware update", e);
}
}, 100, TimeUnit.MILLISECONDS);
}, 0, TimeUnit.SECONDS); // start immediately, without further delay
}
protected void setLeshanClient(LeshanClient leshanClient) {
this.leshanClient = leshanClient;
}
}

60
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java

@ -33,6 +33,8 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.thingsboard.server.controller.AbstractWebTest.TIMEOUT;
@Slf4j
public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
@ -85,10 +87,7 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
log.info("Write on Device resource /{}/{}/{}", getModel().id, getId(), resourceId);
switch (resourceId) {
case 2:
startDownloading();
return WriteResponse.success();
case 3:
case 2, 3:
startDownloading();
return WriteResponse.success();
default:
@ -123,25 +122,34 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
}
private void startDownloading() {
long delay = 0;
// Step 1: start downloading
scheduler.schedule(() -> {
try {
state.set(1);
updateResult.set(1);
fireResourceChange(7);
fireResourceChange(9);
Thread.sleep(100);
state.set(2);
fireResourceChange(7);
Thread.sleep(100);
state.set(3);
fireResourceChange(7);
Thread.sleep(100);
updateResult.set(3);
fireResourceChange(9);
} catch (Exception e) {
}
}, 100, TimeUnit.MILLISECONDS);
state.set(1);
updateResult.set(1);
fireResourceChange(7);
fireResourceChange(9);
}, delay, TimeUnit.MILLISECONDS);
delay += 100;
// Step 2: downloading in progress
scheduler.schedule(() -> {
state.set(2);
fireResourceChange(7);
}, delay, TimeUnit.MILLISECONDS);
delay += 100;
// Step 3: downloading finished
scheduler.schedule(() -> {
state.set(3);
fireResourceChange(7);
updateResult.set(3);
fireResourceChange(9);
}, delay, TimeUnit.MILLISECONDS);
}
private void startUpdating() {
@ -150,7 +158,13 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable {
updateResult.set(2);
fireResourceChange(7);
fireResourceChange(9);
// Optional: delayed log about FW update
scheduler.schedule(() -> {
log.info("FW resources updating to new values: state=[{}], updateResult=[{}]",
state.get(), updateResult.get());
}, 500, TimeUnit.MILLISECONDS);
}, 100, TimeUnit.MILLISECONDS);
}
}

2
edqs/src/main/resources/edqs.yml

@ -210,7 +210,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
health:
elasticsearch:
# Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck

2
msa/vc-executor/src/main/resources/tb-vc-executor.yml

@ -232,7 +232,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Service common properties
service:

43
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -22,6 +22,7 @@ import com.google.common.base.Strings;
import lombok.Getter;
import lombok.SneakyThrows;
import org.apache.commons.io.IOUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.commons.lang3.concurrent.LazyInitializer;
import org.apache.hc.core5.net.URIBuilder;
import org.springframework.core.ParameterizedTypeReference;
@ -94,6 +95,8 @@ import org.thingsboard.server.common.data.asset.AssetSearchQuery;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.device.DeviceSearchQuery;
import org.thingsboard.server.common.data.domain.Domain;
import org.thingsboard.server.common.data.domain.DomainInfo;
@ -4368,6 +4371,46 @@ public class RestClient implements Closeable {
}
@SneakyThrows(URISyntaxException.class)
public PageData<CalculatedFieldInfo> getCalculatedFields(PageLink pageLink,
Set<CalculatedFieldType> types,
EntityType entityType,
Set<UUID> entities,
Set<String> names) {
var urlBuilder = new URIBuilder(baseURL).appendPath("/api/calculatedFields");
urlBuilder.addParameter("pageSize", String.valueOf(pageLink.getPageSize()));
urlBuilder.addParameter("page", String.valueOf(pageLink.getPage()));
if (!isEmpty(pageLink.getTextSearch())) {
urlBuilder.addParameter("textSearch", pageLink.getTextSearch());
}
if (pageLink.getSortOrder() != null) {
urlBuilder.addParameter("sortProperty", pageLink.getSortOrder().getProperty());
urlBuilder.addParameter("sortOrder", pageLink.getSortOrder().getDirection().name());
}
if (!CollectionUtils.isEmpty(types)) {
for (CalculatedFieldType type : types) {
urlBuilder.addParameter("types", type.name());
}
}
if (entityType != null) {
urlBuilder.addParameter("entityType", entityType.name());
}
if (!CollectionUtils.isEmpty(entities)) {
for (UUID entity : entities) {
urlBuilder.addParameter("entities", entity.toString());
}
}
if (!CollectionUtils.isEmpty(names)) {
for (String name : names) {
urlBuilder.addParameter("name", name);
}
}
return restTemplate.exchange(
urlBuilder.build(),
HttpMethod.GET, HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<CalculatedFieldInfo>>() {}).getBody();
}
public void deleteCalculatedField(CalculatedFieldId calculatedFieldId) {
restTemplate.delete(baseURL + "/api/calculatedField/{calculatedFieldId}", calculatedFieldId.getId());
}

31
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -56,6 +56,7 @@ import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@ -115,18 +116,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
// ignore
} finally {
latch.countDown();
}
}
}).start();
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
@ -158,7 +148,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertNotSame(metaData, metadataCaptor.getValue());
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());
@ -184,18 +174,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
assertTrue(request.containsHeader("Foo"), "Custom header included");
assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value");
response.setStatusCode(200);
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
// ignore
} finally {
latch.countDown();
}
}
}).start();
latch.countDown();
} catch (Exception e) {
System.out.println("Exception handling request: " + e.toString());
e.printStackTrace();
@ -227,7 +206,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
ArgumentCaptor<TbMsg> msgCaptor = ArgumentCaptor.forClass(TbMsg.class);
ArgumentCaptor<TbMsgMetaData> metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class);
ArgumentCaptor<String> dataCaptor = ArgumentCaptor.forClass(String.class);
verify(ctx).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture());
assertNotSame(metaData, metadataCaptor.getValue());
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());

2
transport/coap/src/main/resources/tb-coap-transport.yml

@ -435,7 +435,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Notification system parameters
notification_system:

2
transport/http/src/main/resources/tb-http-transport.yml

@ -384,7 +384,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Notification system parameters
notification_system:

2
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -485,7 +485,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Notification system parameters
notification_system:

2
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -418,7 +418,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Notification system parameters
notification_system:

2
transport/snmp/src/main/resources/tb-snmp-transport.yml

@ -373,7 +373,7 @@ management:
web:
exposure:
# Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics).
include: '${METRICS_ENDPOINTS_EXPOSE:info}'
include: "${METRICS_ENDPOINTS_EXPOSE:info}"
# Notification system parameters
notification_system:

4
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts

@ -106,6 +106,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
this.entityComponent = AlarmRulesComponent;
this.entityTabsComponent = AlarmRulesTabsComponent;
this.rowPointer = true;
} else {
this.addAsTextButton = false;
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules');
this.detailsPanelEnabled = this.pageMode;
@ -117,7 +119,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
type: 'alarm-rule.alarm-rule',
typePlural: 'alarm-rule.alarm-rules',
list: 'alarm-rule.list',
add: 'action.add',
add: 'alarm-rule.add',
details: 'alarm-rule.details',
noEntities: 'alarm-rule.no-found',
search: 'action.search',

1
ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts

@ -59,7 +59,6 @@ export class ApiKeysTableConfig extends EntityTableConfig<ApiKeyInfo> {
this.entityType = EntityType.API_KEY;
this.detailsPanelEnabled = false;
this.addAsTextButton = true;
this.pageMode = false;
this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY);

2
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -110,6 +110,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.entityComponent = CalculatedFieldComponent;
this.entityTabsComponent = CalculatedFieldsTabsComponent;
this.rowPointer = true;
} else {
this.addAsTextButton = false;
}
this.tableTitle = this.translate.instant('entity.type-calculated-fields');
this.detailsPanelEnabled = this.pageMode;

104
ui-ngx/src/app/modules/home/components/entity/entities-table.component.html

@ -45,27 +45,19 @@
asButton strokedButton historyOnly [forAllTimeEnabled]="entitiesTableConfig.forAllTimeEnabled"></tb-timewindow>
</div>
<span class="flex-1"></span>
<div [class.!hidden]="!addEnabled()">
<ng-container *ngIf="!entitiesTableConfig.addActionDescriptors.length; else addActions">
<button *ngIf="!entitiesTableConfig.addAsTextButton"
mat-icon-button
<div [class.!hidden]="!addEnabled() || entitiesTableConfig.addAsTextButton">
<ng-container *ngIf="!entitiesTableConfig.addActionDescriptors.length; else iconCustom">
<button mat-icon-button
[disabled]="isLoading$ | async"
(click)="addEntity($event)"
matTooltip="{{ translations.add | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
</button>
<button *ngIf="entitiesTableConfig.addAsTextButton"
mat-stroked-button color="primary"
[disabled]="isLoading$ | async"
(click)="addEntity($event)">
<mat-icon>add</mat-icon>
{{ translations.add | translate }}
</button>
</ng-container>
<ng-template #addActions>
<ng-container *ngIf="entitiesTableConfig.addActionDescriptors.length === 1; else addActionsMenu">
<button *ngIf="!entitiesTableConfig.addAsTextButton && entitiesTableConfig.addActionDescriptors[0].isEnabled()"
<ng-template #iconCustom>
<ng-container *ngIf="entitiesTableConfig.addActionDescriptors.length === 1; else iconMenu">
<button *ngIf="entitiesTableConfig.addActionDescriptors[0].isEnabled()"
mat-icon-button
[disabled]="isLoading$ | async"
(click)="entitiesTableConfig.addActionDescriptors[0].onAction($event)"
@ -73,30 +65,14 @@
matTooltipPosition="above">
<tb-icon>{{entitiesTableConfig.addActionDescriptors[0].icon}}</tb-icon>
</button>
<button *ngIf="entitiesTableConfig.addAsTextButton && entitiesTableConfig.addActionDescriptors[0].isEnabled()"
mat-stroked-button color="primary"
[disabled]="isLoading$ | async"
(click)="entitiesTableConfig.addActionDescriptors[0].onAction($event)">
<tb-icon>{{entitiesTableConfig.addActionDescriptors[0].icon}}</tb-icon>
{{ entitiesTableConfig.addActionDescriptors[0].name }}
</button>
</ng-container>
<ng-template #addActionsMenu>
<ng-template #iconMenu>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ translations.add | translate }}"
matTooltipPosition="above"
[matMenuTriggerFor]="addActionsMenu">
<mat-icon>add</mat-icon>
</button>
<mat-menu #addActionsMenu="matMenu" xPosition="before">
<button mat-menu-item *ngFor="let actionDescriptor of entitiesTableConfig.addActionDescriptors"
[disabled]="isLoading$ | async"
[class.!hidden]="!actionDescriptor.isEnabled()"
(click)="actionDescriptor.onAction($event)">
<tb-icon matMenuItemIcon>{{actionDescriptor.icon}}</tb-icon>
<span>{{ actionDescriptor.name }}</span>
</button>
</mat-menu>
</ng-template>
</ng-template>
</div>
@ -118,6 +94,72 @@
matTooltipPosition="above">
<mat-icon>search</mat-icon>
</button>
<div [class.!hidden]="!addEnabled() || !entitiesTableConfig.addAsTextButton" class="pl-3" style="--mat-fab-small-container-elevation-shadow: none;">
<ng-container *ngIf="!entitiesTableConfig.addActionDescriptors.length; else textActions">
<button class="lt-sm:!hidden"
mat-flat-button color="primary"
[disabled]="isLoading$ | async"
(click)="addEntity($event)">
<mat-icon>add</mat-icon>
{{ translations.add | translate }}
</button>
<button class="gt-xs:!hidden"
mat-mini-fab color="primary"
[disabled]="isLoading$ | async"
(click)="addEntity($event)"
matTooltip="{{ translations.add | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
</button>
</ng-container>
<ng-template #textActions>
<ng-container *ngIf="entitiesTableConfig.addActionDescriptors.length === 1; else textMenu">
<ng-container *ngIf="entitiesTableConfig.addActionDescriptors[0].isEnabled()">
<button class="lt-sm:!hidden"
mat-flat-button color="primary"
[disabled]="isLoading$ | async"
(click)="entitiesTableConfig.addActionDescriptors[0].onAction($event)">
<tb-icon matButtonIcon>{{entitiesTableConfig.addActionDescriptors[0].icon}}</tb-icon>
{{ entitiesTableConfig.addActionDescriptors[0].name }}
</button>
<button class="gt-xs:!hidden"
mat-mini-fab color="primary"
[disabled]="isLoading$ | async"
(click)="entitiesTableConfig.addActionDescriptors[0].onAction($event)"
matTooltip="{{ entitiesTableConfig.addActionDescriptors[0].name }}"
matTooltipPosition="above">
<mat-icon>{{entitiesTableConfig.addActionDescriptors[0].icon}}</mat-icon>
</button>
</ng-container>
</ng-container>
<ng-template #textMenu>
<button class="lt-sm:!hidden"
mat-flat-button color="primary"
[disabled]="isLoading$ | async"
[matMenuTriggerFor]="addActionsMenu">
<mat-icon>add</mat-icon>
{{ translations.add | translate }}
</button>
<button class="gt-xs:!hidden"
mat-mini-fab color="primary"
[disabled]="isLoading$ | async"
[matMenuTriggerFor]="addActionsMenu"
matTooltip="{{ translations.add | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
</button>
</ng-template>
</ng-template>
</div>
<mat-menu #addActionsMenu="matMenu" xPosition="before">
<button mat-menu-item *ngFor="let actionDescriptor of entitiesTableConfig.addActionDescriptors"
[disabled]="isLoading$ | async"
[class.!hidden]="!actionDescriptor.isEnabled()"
(click)="actionDescriptor.onAction($event)">
<tb-icon matMenuItemIcon>{{actionDescriptor.icon}}</tb-icon>
<span>{{ actionDescriptor.name }}</span>
</button>
</mat-menu>
</div>
</mat-toolbar>
<mat-toolbar class="mat-mdc-table-toolbar" [class.!hidden]="!textSearchMode || !dataSource.selection.isEmpty()">

18
ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html

@ -32,7 +32,8 @@
</tb-branch-autocomplete>
</div>
<span class="flex-1"></span>
<div class="flex flex-row items-center justify-center xs:flex-col xs:items-end xs:justify-center">
<div class="flex flex-row items-center justify-center xs:flex-col xs:items-end xs:justify-center"
[class.xs:flex-col-reverse]="!singleEntityMode">
<button *ngIf="singleEntityMode" mat-stroked-button color="primary"
#createVersionButton
[disabled]="(isLoading$ | async) || (isReadOnly | async)"
@ -40,13 +41,6 @@
<mat-icon>update</mat-icon>
{{'version-control.create-version' | translate }}
</button>
<button *ngIf="!singleEntityMode" mat-stroked-button color="primary"
#complexCreateVersionButton
[disabled]="(isLoading$ | async) || (isReadOnly | async)"
(click)="toggleComplexCreateVersion($event, complexCreateVersionButton)">
<mat-icon>update</mat-icon>
{{'version-control.create-entities-version' | translate }}
</button>
<div class="flex flex-row">
<button mat-icon-button [disabled]="isLoading$ | async" (click)="updateData()"
matTooltip="{{ 'action.refresh' | translate }}"
@ -61,6 +55,14 @@
<mat-icon>search</mat-icon>
</button>
</div>
<button *ngIf="!singleEntityMode" mat-flat-button color="primary"
class="gt-xs:ml-3"
#complexCreateVersionButton
[disabled]="(isLoading$ | async) || (isReadOnly | async)"
(click)="toggleComplexCreateVersion($event, complexCreateVersionButton)">
<mat-icon>update</mat-icon>
{{'version-control.create-entities-version' | translate }}
</button>
</div>
</div>
</mat-toolbar>

11
ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html

@ -24,8 +24,15 @@
<tb-toggle-option value="last">{{ 'widgets.recent-dashboards.last' | translate }}</tb-toggle-option>
<tb-toggle-option value="starred">{{ 'widgets.recent-dashboards.starred' | translate }}</tb-toggle-option>
</tb-toggle-header>
<a *ngIf="authUser.authority === authority.TENANT_ADMIN" class="md:!hidden"
mat-flat-button color="primary" routerLink="/dashboards" [queryParams]="{action: 'add'}">{{ 'dashboard.add' | translate }}</a>
<ng-container *ngIf="authUser.authority === authority.TENANT_ADMIN">
@if (hasDevice) {
<a class="md:!hidden"
mat-flat-button color="primary" routerLink="/dashboards/all" [queryParams]="{action: 'add'}">{{ 'dashboard.add' | translate }}</a>
} @else {
<a class="md:!hidden"
mat-stroked-button color="primary" routerLink="/dashboards/all" [queryParams]="{action: 'add'}">{{ 'dashboard.add' | translate }}</a>
}
</ng-container>
</div>
</div>
<ng-container *ngIf="userDashboardsInfo; else loading" [ngSwitch]="dashboardsToggle.value">

45
ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts

@ -48,6 +48,13 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { MatSort } from '@angular/material/sort';
import { DashboardInfo } from '@shared/models/dashboard.models';
import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component';
import { UtilsService } from '@core/services/utils.service';
import { Datasource, DatasourceType, widgetType } from '@shared/models/widget.models';
import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { formattedDataFormDatasourceData } from '@core/utils';
import { AliasFilterType } from '@shared/models/alias.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models';
@Component({
selector: 'tb-recent-dashboards-widget',
@ -78,13 +85,16 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On
starredDashboardValue = null;
hasDashboardsAccess = true;
hasDevice = true;
dirty = false;
public customerId: string;
private isFullscreenMode = getCurrentAuthState(this.store).forceFullscreen;
private subscription: IWidgetSubscription;
constructor(protected store: Store<AppState>,
private cd: ChangeDetectorRef,
private utils: UtilsService,
private userSettingService: UserSettingsService) {
super(store);
}
@ -96,6 +106,41 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On
this.hasDashboardsAccess = [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER].includes(this.authUser.authority);
if (this.hasDashboardsAccess) {
this.reload();
if (window.location.pathname.startsWith('/home') && this.authUser.authority === Authority.TENANT_ADMIN) {
const ds: Datasource = {
type: DatasourceType.entityCount,
name: '',
entityFilter: {
entityType: EntityType.DEVICE,
type: AliasFilterType.entityType
},
dataKeys: [this.utils.createKey({ name: 'count'}, DataKeyType.count)]
}
const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = {
datasources: [ds],
useDashboardTimewindow: false,
type: widgetType.latest,
callbacks: {
onDataUpdated: (subscription) => {
const data = formattedDataFormDatasourceData(subscription.data);
this.hasDevice = (data[0].count || 0) !== 0;
this.cd.detectChanges();
}
}
};
this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe((subscription) => {
this.subscription = subscription;
});
}
}
}
ngOnDestroy() {
super.ngOnDestroy();
if (this.subscription) {
this.ctx.subscriptionApi.removeSubscription(this.subscription.id);
}
}

2
ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts

@ -175,7 +175,7 @@ export class EntityTableConfig<T extends BaseData<HasId>, P extends PageLink = P
selectionEnabled = true;
searchEnabled = true;
addEnabled = true;
addAsTextButton = false;
addAsTextButton = true;
entitiesDeleteEnabled = true;
detailsPanelEnabled = true;
hideDetailsTabsOnEdit = true;

1
ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts

@ -47,7 +47,6 @@ export class AiModelsTableConfigResolver {
) {
this.config.selectionEnabled = true;
this.config.entityType = EntityType.AI_MODEL;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.detailsPanelEnabled = false;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL);

1
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts

@ -57,7 +57,6 @@ export class MobileAppTableConfigResolver {
) {
this.config.selectionEnabled = false;
this.config.entityType = EntityType.MOBILE_APP;
this.config.addAsTextButton = true;
this.config.entitiesDeleteEnabled = false;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP);

1
ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts

@ -63,7 +63,6 @@ export class MobileBundleTableConfigResolver {
) {
this.config.selectionEnabled = false;
this.config.entityType = EntityType.MOBILE_APP_BUNDLE;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.detailsPanelEnabled = false;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP_BUNDLE);

1
ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts

@ -48,7 +48,6 @@ export class RecipientTableConfigResolver {
this.config.entityType = EntityType.NOTIFICATION_TARGET;
this.config.detailsPanelEnabled = false;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TARGET);

1
ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts

@ -49,7 +49,6 @@ export class RuleTableConfigResolver {
this.config.entityType = EntityType.NOTIFICATION_RULE;
this.config.detailsPanelEnabled = false;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_RULE);

1
ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts

@ -47,7 +47,6 @@ export class TemplateTableConfigResolver {
this.config.entityType = EntityType.NOTIFICATION_TEMPLATE;
this.config.detailsPanelEnabled = false;
this.config.addAsTextButton = true;
this.config.rowPointer = true;
this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TEMPLATE);

6
ui-ngx/src/app/shared/components/image/image-gallery.component.html

@ -74,10 +74,10 @@
<tb-icon>mdi:file-import</tb-icon>
</button>
<button [class.!hidden]="dialogMode && isScada"
class="gt-md:!hidden"
class="ml-3 gt-md:!hidden"
[disabled]="isLoading$ | async"
mat-icon-button
color="primary"
mat-mini-fab color="primary"
style="--mat-fab-small-container-elevation-shadow: none;"
(click)="uploadImage()"
matTooltip="{{ (isScada ? 'scada.upload-symbol' : 'image.upload-image' ) | translate }}"
matTooltipPosition="above">

1
ui-ngx/src/app/shared/models/entity-type.models.ts

@ -490,6 +490,7 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
type: 'entity.type-calculated-field',
typePlural: 'entity.type-calculated-fields',
list: 'calculated-fields.list',
add: 'calculated-fields.add',
details: 'calculated-fields.calculated-field-details',
noEntities: 'calculated-fields.no-found',
search: 'action.search',

2
ui-ngx/src/assets/dashboard/tenant_admin_home_page.json

@ -224,7 +224,7 @@
"padding": "16px",
"settings": {
"useMarkdownTextFunction": false,
"markdownTextPattern": "<div class=\"tb-card-content\">\n <div class=\"tb-content-container\">\n <div class=\"tb-card-header\">\n <div class=\"tb-card-title\">\n <a class=\"tb-home-widget-title tb-home-widget-link\" routerLink=\"/entities/devices\">{{ 'device.devices' | translate }}</a>\n </div>\n <div class=\"flex flex-row gap-3\">\n <a class=\"md:!hidden\" mat-stroked-button color=\"primary\" href=\"https://thingsboard.io/docs\" target=\"_blank\">{{ 'widgets.devices.view-docs' | translate }}</a>\n <a mat-flat-button color=\"primary\" routerLink=\"/entities/devices\" [queryParams]=\"{action: 'add'}\">{{ 'device.add' | translate }}</a>\n </div>\n </div>\n <div class=\"tb-item-cards\">\n <a class=\"tb-item-card tb-inactive\" routerLink=\"/entities/devices\" [queryParams]=\"{active: false}\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.inactive</div>\n </div>\n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${inactiveDevices:0}</div>\n </div>\n </a>\n <a class=\"tb-item-card tb-active\" routerLink=\"/entities/devices\" [queryParams]=\"{active: true}\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.active</div>\n </div> \n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${activeDevices:0}</div>\n </div>\n </a>\n <a class=\"tb-item-card tb-total md:!hidden\" routerLink=\"/entities/devices\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.total</div>\n </div> \n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${totalDevices:0}</div>\n </div>\n </a>\n </div>\n </div>\n</div>",
"markdownTextPattern": "<div class=\"tb-card-content\">\n <div class=\"tb-content-container\">\n <div class=\"tb-card-header\">\n <div class=\"tb-card-title\">\n <a class=\"tb-home-widget-title tb-home-widget-link\" routerLink=\"/entities/devices\">{{ 'device.devices' | translate }}</a>\n </div>\n <div class=\"flex flex-row gap-3\">\n <a class=\"md:!hidden\" mat-button color=\"primary\" href=\"https://thingsboard.io/docs\" target=\"_blank\">{{ 'widgets.devices.view-docs' | translate }}</a>\n <a *ngIf=\"${totalDevices:0} === 0\" mat-flat-button color=\"primary\" routerLink=\"/entities/devices\" [queryParams]=\"{action: 'add'}\">{{ 'device.add' | translate }}</a>\n <a *ngIf=\"${totalDevices:0} !== 0\" mat-stroked-button color=\"primary\" routerLink=\"/entities/devices\" [queryParams]=\"{action: 'add'}\">{{ 'device.add' | translate }}</a>\n </div>\n </div>\n <div class=\"tb-item-cards\">\n <a class=\"tb-item-card tb-inactive\" routerLink=\"/entities/devices\" [queryParams]=\"{active: false}\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.inactive</div>\n </div>\n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${inactiveDevices:0}</div>\n </div>\n </a>\n <a class=\"tb-item-card tb-active\" routerLink=\"/entities/devices\" [queryParams]=\"{active: true}\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.active</div>\n </div> \n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${activeDevices:0}</div>\n </div>\n </a>\n <a class=\"tb-item-card tb-total md:!hidden\" routerLink=\"/entities/devices\">\n <div class=\"tb-item-title-container\">\n <div class=\"tb-item-title tb-home-widget-link\" translate>widgets.devices.total</div>\n </div> \n <div class=\"tb-count-container\">\n <div class=\"tb-count\">${totalDevices:0}</div>\n </div>\n </a>\n </div>\n </div>\n</div>",
"applyDefaultMarkdownStyle": false,
"markdownCss": ".tb-card-content {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: row;\n}\n\n.tb-content-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: 12px;\n}\n\n.tb-card-header {\n height: 36px;\n display: flex;\n flex-direction: row;\n justify-content: space-between;\n}\n\n.tb-item-cards {\n flex: 1;\n display: flex;\n flex-direction: row;\n gap: 12px;\n overflow: hidden;\n}\n\na.tb-item-card {\n flex: 1;\n display: flex;\n flex-direction: column;\n padding: 8px 12px;\n border: 1px solid;\n border-radius: 10px;\n margin-bottom: 12px;\n overflow: hidden;\n justify-content: space-evenly;\n}\n\na.tb-item-card.tb-inactive {\n background: rgba(209, 39, 48, 0.04);\n border-color: rgba(209, 39, 48, 0.06);\n}\n\na.tb-item-card.tb-active {\n background: rgba(48, 86, 128, 0.04);\n border-color: rgba(48, 86, 128, 0.12);\n}\n\na.tb-item-card.tb-total {\n background: rgba(0, 0, 0, 0.01);\n border-color: rgba(0, 0, 0, 0.05);\n}\n\n.tb-item-title-container {\n display: grid;\n}\n\n.tb-item-title {\n font-weight: 400;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.2px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis; \n color: rgba(0, 0, 0, 0.76);\n}\n\n.tb-item-title.tb-home-widget-link:after {\n position: absolute;\n right: 0;\n}\n\na.tb-item-card:hover .tb-item-title.tb-home-widget-link:after { \n color: rgba(0, 0, 0, 0.38);\n}\n\na.tb-item-card:hover {\n box-shadow: 0px 4px 10px rgba(23, 33, 90, 0.08);\n}\n\n.tb-count-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n\n.tb-count {\n font-style: normal;\n font-weight: 500;\n font-size: 24px;\n line-height: 36px;\n white-space: nowrap;\n color: rgba(0, 0, 0, 0.87);\n}\n\n@media screen and (max-width: 959px) {\n .tb-item-cards {\n flex-direction: column;\n }\n a.tb-item-card {\n margin-bottom: 0;\n }\n}\n\n@media screen and (max-width: 1279px) {\n a.tb-item-card {\n flex-direction: row;\n align-items: center;\n }\n .tb-item-title.tb-home-widget-link:after {\n position: relative;\n }\n .tb-count-container {\n align-items: flex-end;\n }\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-item-title {\n font-size: 11px;\n line-height: 16px;\n }\n .tb-count {\n font-size: 16px;\n line-height: 24px;\n }\n a.tb-item-card {\n padding: 4px 8px;\n margin-bottom: 6px;\n }\n a.tb-item-card:hover {\n box-shadow: 0px 2px 5px rgba(23, 33, 90, 0.08);\n }\n}\n"
},

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

@ -1102,6 +1102,7 @@
"expression": "Expression",
"no-found": "No calculated fields found",
"list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }",
"add": "Add calculated field",
"selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected",
"type": {
"simple": "Simple",

Loading…
Cancel
Save