Browse Source

Merge branch 'master' of github.com:thingsboard/thingsboard

pull/3246/head
Igor Kulikov 6 years ago
parent
commit
aee7e09cab
  1. 26
      application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java
  2. 12
      dao/src/main/resources/sql/schema-entities.sql
  3. 2
      rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js
  4. 4
      ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html
  5. 3
      ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts
  6. 1
      ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html
  7. 6
      ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html
  8. 16
      ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts
  9. 29
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss
  10. 194
      ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts
  11. 5
      ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html
  12. 14
      ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts
  13. 2
      ui-ngx/src/app/shared/models/constants.ts

26
application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java

@ -29,7 +29,8 @@ import org.thingsboard.server.dao.model.sqlts.dictionary.TsKvDictionaryComposite
import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity;
import org.thingsboard.server.dao.sqlts.dictionary.TsKvDictionaryRepository; import org.thingsboard.server.dao.sqlts.dictionary.TsKvDictionaryRepository;
import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository;
import org.thingsboard.server.dao.util.NoSqlAnyDao; import org.thingsboard.server.dao.util.NoSqlTsDao;
import org.thingsboard.server.dao.util.SqlTsLatestDao;
import org.thingsboard.server.service.install.EntityDatabaseSchemaService; import org.thingsboard.server.service.install.EntityDatabaseSchemaService;
import java.sql.Connection; import java.sql.Connection;
@ -51,11 +52,13 @@ import static org.thingsboard.server.service.install.migrate.CassandraToSqlColum
@Service @Service
@Profile("install") @Profile("install")
@NoSqlAnyDao @NoSqlTsDao
@SqlTsLatestDao
@Slf4j @Slf4j
public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateService { public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateService {
private static final int LATEST_KEY_LENGTH = 255; private static final int MAX_KEY_LENGTH = 255;
private static final int MAX_STR_V_LENGTH = 10000000;
@Autowired @Autowired
private EntityDatabaseSchemaService entityDatabaseSchemaService; private EntityDatabaseSchemaService entityDatabaseSchemaService;
@ -95,11 +98,10 @@ public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateServ
log.error("Unexpected error during ThingsBoard entities data migration!", e); log.error("Unexpected error during ThingsBoard entities data migration!", e);
throw e; throw e;
} }
entityDatabaseSchemaService.createDatabaseIndexes();
} }
private List<CassandraToSqlTable> tables = Arrays.asList( private List<CassandraToSqlTable> tables = Arrays.asList(
new CassandraToSqlTable("ts_kv_latest_cf", new CassandraToSqlTable("ts_kv_latest_cf", "ts_kv_latest",
idColumn("entity_id"), idColumn("entity_id"),
stringColumn("key"), stringColumn("key"),
bigintColumn("ts"), bigintColumn("ts"),
@ -129,6 +131,12 @@ public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateServ
String strV = data[4].getValue(); String strV = data[4].getValue();
if (strV != null) { if (strV != null) {
if (strV.length() > MAX_STR_V_LENGTH) {
log.warn("[ts_kv_latest] Value size [{}] exceeds maximum size [{}] of column [str_v] and will be truncated!",
strV.length(), MAX_STR_V_LENGTH);
log.warn("Affected data:\n{}", strV);
strV = strV.substring(0, MAX_STR_V_LENGTH);
}
latestEntity.setStrValue(strV); latestEntity.setStrValue(strV);
} else { } else {
Long longV = null; Long longV = null;
@ -170,9 +178,11 @@ public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateServ
} }
protected Integer getOrSaveKeyId(String strKey) { protected Integer getOrSaveKeyId(String strKey) {
if (strKey.length() > LATEST_KEY_LENGTH) { if (strKey.length() > MAX_KEY_LENGTH) {
log.warn("Key is long. Max key length is 255\n{}", strKey); log.warn("[ts_kv_latest] Value size [{}] exceeds maximum size [{}] of column [key] and will be truncated!",
strKey = strKey.substring(0, LATEST_KEY_LENGTH); strKey.length(), MAX_KEY_LENGTH);
log.warn("Affected data:\n{}", strKey);
strKey = strKey.substring(0, MAX_KEY_LENGTH);
} }
Integer keyId = tsKvDictionaryMap.get(strKey); Integer keyId = tsKvDictionaryMap.get(strKey);

12
dao/src/main/resources/sql/schema-entities.sql

@ -20,7 +20,17 @@ CREATE TABLE IF NOT EXISTS tb_schema_settings
CONSTRAINT tb_schema_settings_pkey PRIMARY KEY (schema_version) CONSTRAINT tb_schema_settings_pkey PRIMARY KEY (schema_version)
); );
INSERT INTO tb_schema_settings (schema_version) VALUES (3001000) ON CONFLICT (schema_version) DO UPDATE SET schema_version = 3001000; CREATE OR REPLACE PROCEDURE insert_tb_schema_settings()
LANGUAGE plpgsql AS
$$
BEGIN
IF (SELECT COUNT(*) FROM tb_schema_settings) = 0 THEN
INSERT INTO tb_schema_settings (schema_version) VALUES (3001000);
END IF;
END;
$$;
call insert_tb_schema_settings();
CREATE TABLE IF NOT EXISTS admin_settings ( CREATE TABLE IF NOT EXISTS admin_settings (
id uuid NOT NULL CONSTRAINT admin_settings_pkey PRIMARY KEY, id uuid NOT NULL CONSTRAINT admin_settings_pkey PRIMARY KEY,

2
rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js

File diff suppressed because one or more lines are too long

4
ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html

@ -78,7 +78,9 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container [matColumnDef]="column.def" *ngFor="let column of columns; trackBy: trackByColumnDef;"> <ng-container [matColumnDef]="column.def" *ngFor="let column of columns; trackBy: trackByColumnDef;">
<mat-header-cell [ngStyle]="headerStyle(column)" *matHeaderCellDef mat-sort-header> {{ column.title }} </mat-header-cell> <mat-header-cell [ngStyle]="headerStyle(column)" *matHeaderCellDef mat-sort-header [disabled]="isSorting(column)">
{{ column.title }}
</mat-header-cell>
<mat-cell *matCellDef="let alarm;" <mat-cell *matCellDef="let alarm;"
[innerHTML]="cellContent(alarm, column)" [innerHTML]="cellContent(alarm, column)"
[ngStyle]="cellStyle(alarm, column)"> [ngStyle]="cellStyle(alarm, column)">

3
ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts

@ -840,6 +840,9 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
isSorting(column: EntityColumn): boolean {
return column.type === DataKeyType.alarm && column.name.startsWith('details.');
}
} }
class AlarmsDatasource implements DataSource<AlarmDataInfo> { class AlarmsDatasource implements DataSource<AlarmDataInfo> {

1
ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html

@ -34,6 +34,7 @@
[ruleNode]="ruleNode" [ruleNode]="ruleNode"
[ruleChainId]="ruleChainId" [ruleChainId]="ruleChainId"
[isEdit]="true" [isEdit]="true"
[isAdd]="true"
[isReadOnly]="false"> [isReadOnly]="false">
</tb-rule-node> </tb-rule-node>
</fieldset> </fieldset>

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

@ -15,6 +15,12 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-details-buttons" *ngIf="!isAdd && ruleNode.component.type === ruleNodeType.RULE_CHAIN">
<button mat-raised-button color="primary"
(click)="openRuleChain($event)">
{{'rulechain.open-rulechain' | translate }}
</button>
</div>
<div class="mat-padding" fxLayout="column"> <div class="mat-padding" fxLayout="column">
<form [formGroup]="ruleNodeFormGroup"> <form [formGroup]="ruleNodeFormGroup">
<fieldset [disabled]="(isLoading$ | async) || !isEdit || isReadOnly"> <fieldset [disabled]="(isLoading$ | async) || !isEdit || isReadOnly">

16
ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts

@ -24,6 +24,7 @@ import { EntityType } from '@shared/models/entity-type.models';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainService } from '@core/http/rule-chain.service';
import { RuleNodeConfigComponent } from './rule-node-config.component'; import { RuleNodeConfigComponent } from './rule-node-config.component';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'tb-rule-node', selector: 'tb-rule-node',
@ -46,6 +47,9 @@ export class RuleNodeDetailsComponent extends PageComponent implements OnInit, O
@Input() @Input()
isReadOnly: boolean; isReadOnly: boolean;
@Input()
isAdd = false;
ruleNodeType = RuleNodeType; ruleNodeType = RuleNodeType;
entityType = EntityType; entityType = EntityType;
@ -55,7 +59,8 @@ export class RuleNodeDetailsComponent extends PageComponent implements OnInit, O
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
private fb: FormBuilder, private fb: FormBuilder,
private ruleChainService: RuleChainService) { private ruleChainService: RuleChainService,
private router: Router) {
super(store); super(store);
this.ruleNodeFormGroup = this.fb.group({}); this.ruleNodeFormGroup = this.fb.group({});
} }
@ -133,4 +138,13 @@ export class RuleNodeDetailsComponent extends PageComponent implements OnInit, O
this.ruleNodeConfigComponent.validate(); this.ruleNodeConfigComponent.validate();
} }
} }
openRuleChain($event: Event) {
if ($event) {
$event.stopPropagation();
}
if (this.ruleNode.targetRuleChainId) {
this.router.navigateByUrl(`/ruleChains/${this.ruleNode.targetRuleChainId}`);
}
}
} }

29
ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss

@ -179,6 +179,35 @@
} }
} }
.fc-nodeopen{
display: block;
position: absolute;
top: 11px;
right: -12px;
border: 1px solid #FFFFFF;
border-radius: 4px;
line-height: 18px;
height: 22px;
width: 22px;
background: #886CB1;
color: #fff;
text-align: center;
cursor: pointer;
box-sizing: border-box;
mat-icon{
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
font-size: 16px;
}
&:hover{
background-color: #4E2D7E;
}
}
.fc-arrow-marker { .fc-arrow-marker {
polygon { polygon {
fill: #808080; fill: #808080;

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

@ -20,6 +20,7 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
Inject, Inject,
OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
SkipSelf, SkipSelf,
@ -64,7 +65,7 @@ import {
} from '@shared/models/rule-node.models'; } from '@shared/models/rule-node.models';
import { FcRuleNodeModel, FcRuleNodeTypeModel, RuleChainMenuContextInfo } from './rulechain-page.models'; import { FcRuleNodeModel, FcRuleNodeTypeModel, RuleChainMenuContextInfo } from './rulechain-page.models';
import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainService } from '@core/http/rule-chain.service';
import { fromEvent, NEVER, Observable, of } from 'rxjs'; import { fromEvent, NEVER, Observable, of, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, mergeMap, tap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, mergeMap, tap } from 'rxjs/operators';
import { ISearchableComponent } from '../../models/searchable-component.models'; import { ISearchableComponent } from '../../models/searchable-component.models';
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
@ -85,7 +86,7 @@ import Timeout = NodeJS.Timeout;
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class RuleChainPageComponent extends PageComponent export class RuleChainPageComponent extends PageComponent
implements AfterViewInit, OnInit, HasDirtyFlag, ISearchableComponent { implements AfterViewInit, OnInit, OnDestroy, HasDirtyFlag, ISearchableComponent {
get isDirty(): boolean { get isDirty(): boolean {
return this.isDirtyValue || this.isImport; return this.isDirtyValue || this.isImport;
@ -234,6 +235,8 @@ export class RuleChainPageComponent extends PageComponent
flowchartConstants = FlowchartConstants; flowchartConstants = FlowchartConstants;
private rxSubscription: Subscription;
private tooltipTimeout: Timeout; private tooltipTimeout: Timeout;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
@ -247,7 +250,13 @@ export class RuleChainPageComponent extends PageComponent
public dialogService: DialogService, public dialogService: DialogService,
public fb: FormBuilder) { public fb: FormBuilder) {
super(store); super(store);
this.init();
this.rxSubscription = this.route.data.subscribe(
() => {
this.reset();
this.init();
}
);
} }
ngOnInit() { ngOnInit() {
@ -266,6 +275,11 @@ export class RuleChainPageComponent extends PageComponent
this.ruleChainCanvas.adjustCanvasSize(true); this.ruleChainCanvas.adjustCanvasSize(true);
} }
ngOnDestroy() {
super.ngOnDestroy();
this.rxSubscription.unsubscribe();
}
onSearchTextUpdated(searchText: string) { onSearchTextUpdated(searchText: string) {
this.ruleNodeSearch = searchText; this.ruleNodeSearch = searchText;
this.updateRuleNodesHighlight(); this.updateRuleNodesHighlight();
@ -299,87 +313,102 @@ export class RuleChainPageComponent extends PageComponent
this.createRuleChainModel(); this.createRuleChainModel();
} }
private reset(): void {
this.selectedObjects = [];
this.ruleChainModel.nodes = [];
this.ruleChainModel.edges = [];
this.ruleNodeTypesModel = {};
if (this.ruleChainCanvas) {
this.ruleChainCanvas.adjustCanvasSize(true);
}
this.isEditingRuleNode = false;
this.isEditingRuleNodeLink = false;
this.updateRuleNodesHighlight();
}
private initHotKeys(): void { private initHotKeys(): void {
this.hotKeys.push( if (!this.hotKeys.length) {
new Hotkey('ctrl+a', (event: KeyboardEvent) => { this.hotKeys.push(
if (this.enableHotKeys) { new Hotkey('ctrl+a', (event: KeyboardEvent) => {
event.preventDefault(); if (this.enableHotKeys) {
this.ruleChainCanvas.modelService.selectAll(); event.preventDefault();
return false; this.ruleChainCanvas.modelService.selectAll();
} return false;
return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.select-all-objects'))
);
this.hotKeys.push(
new Hotkey('ctrl+c', (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
this.copyRuleNodes();
return false;
}
return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.copy-selected'))
);
this.hotKeys.push(
new Hotkey('ctrl+v', (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
if (this.itembuffer.hasRuleNodes()) {
this.pasteRuleNodes();
} }
return false; return true;
} }, ['INPUT', 'SELECT', 'TEXTAREA'],
return true; this.translate.instant('rulenode.select-all-objects'))
}, ['INPUT', 'SELECT', 'TEXTAREA'], );
this.translate.instant('action.paste')) this.hotKeys.push(
); new Hotkey('ctrl+c', (event: KeyboardEvent) => {
this.hotKeys.push( if (this.enableHotKeys) {
new Hotkey('esc', (event: KeyboardEvent) => { event.preventDefault();
if (this.enableHotKeys) { this.copyRuleNodes();
event.preventDefault(); return false;
event.stopPropagation(); }
this.ruleChainCanvas.modelService.deselectAll(); return true;
return false; }, ['INPUT', 'SELECT', 'TEXTAREA'],
} this.translate.instant('rulenode.copy-selected'))
return true; );
}, ['INPUT', 'SELECT', 'TEXTAREA'], this.hotKeys.push(
this.translate.instant('rulenode.deselect-all-objects')) new Hotkey('ctrl+v', (event: KeyboardEvent) => {
); if (this.enableHotKeys) {
this.hotKeys.push( event.preventDefault();
new Hotkey('ctrl+s', (event: KeyboardEvent) => { if (this.itembuffer.hasRuleNodes()) {
if (this.enableHotKeys) { this.pasteRuleNodes();
event.preventDefault(); }
this.saveRuleChain(); return false;
return false; }
} return true;
return true; }, ['INPUT', 'SELECT', 'TEXTAREA'],
}, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('action.paste'))
this.translate.instant('action.apply')) );
); this.hotKeys.push(
this.hotKeys.push( new Hotkey('esc', (event: KeyboardEvent) => {
new Hotkey('ctrl+z', (event: KeyboardEvent) => { if (this.enableHotKeys) {
if (this.enableHotKeys) { event.preventDefault();
event.preventDefault(); event.stopPropagation();
this.revertRuleChain(); this.ruleChainCanvas.modelService.deselectAll();
return false; return false;
} }
return true; return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'], }, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('action.decline-changes')) this.translate.instant('rulenode.deselect-all-objects'))
); );
this.hotKeys.push( this.hotKeys.push(
new Hotkey('del', (event: KeyboardEvent) => { new Hotkey('ctrl+s', (event: KeyboardEvent) => {
if (this.enableHotKeys) { if (this.enableHotKeys) {
event.preventDefault(); event.preventDefault();
this.ruleChainCanvas.modelService.deleteSelected(); this.saveRuleChain();
return false; return false;
} }
return true; return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'], }, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.delete-selected-objects')) this.translate.instant('action.apply'))
); );
this.hotKeys.push(
new Hotkey('ctrl+z', (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
this.revertRuleChain();
return false;
}
return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('action.decline-changes'))
);
this.hotKeys.push(
new Hotkey('del', (event: KeyboardEvent) => {
if (this.enableHotKeys) {
event.preventDefault();
this.ruleChainCanvas.modelService.deleteSelected();
return false;
}
return true;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('rulenode.delete-selected-objects'))
);
}
} }
updateRuleChainLibrary() { updateRuleChainLibrary() {
@ -1396,6 +1425,7 @@ export class RuleChainPageComponent extends PageComponent
scroll: true scroll: true
}, },
side: 'right', side: 'right',
distance: 12,
trackOrigin: true trackOrigin: true
} }
); );

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

@ -59,4 +59,9 @@
&times; &times;
</div> </div>
</section> </section>
<section *ngIf="node.component.type === RuleNodeType.RULE_CHAIN && modelservice.isEditable()">
<div class="fc-nodeopen" (click)="openRuleChain($event, node)">
<mat-icon class="material-icons" svgIcon="mdi:login"></mat-icon>
</div>
</section>
</div> </div>

14
ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts

@ -17,6 +17,8 @@
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FcNodeComponent } from 'ngx-flowchart/dist/ngx-flowchart'; import { FcNodeComponent } from 'ngx-flowchart/dist/ngx-flowchart';
import { FcRuleNode, RuleNodeType } from '@shared/models/rule-node.models';
import { Router } from '@angular/router';
@Component({ @Component({
// tslint:disable-next-line:component-selector // tslint:disable-next-line:component-selector
@ -27,8 +29,10 @@ import { FcNodeComponent } from 'ngx-flowchart/dist/ngx-flowchart';
export class RuleNodeComponent extends FcNodeComponent implements OnInit { export class RuleNodeComponent extends FcNodeComponent implements OnInit {
iconUrl: SafeResourceUrl; iconUrl: SafeResourceUrl;
RuleNodeType = RuleNodeType;
constructor(private sanitizer: DomSanitizer) { constructor(private sanitizer: DomSanitizer,
private router: Router) {
super(); super();
} }
@ -39,4 +43,12 @@ export class RuleNodeComponent extends FcNodeComponent implements OnInit {
} }
} }
openRuleChain($event: Event, node: FcRuleNode) {
if ($event) {
$event.stopPropagation();
}
if (node.targetRuleChainId) {
this.router.navigateByUrl(`/ruleChains/${node.targetRuleChainId}`);
}
}
} }

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

@ -105,7 +105,7 @@ export const HelpLinks = {
ruleNodeRestApiCall: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#rest-api-call-node', ruleNodeRestApiCall: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#rest-api-call-node',
ruleNodeSendEmail: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#send-email-node', ruleNodeSendEmail: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#send-email-node',
tenants: helpBaseUrl + '/docs/user-guide/ui/tenants', tenants: helpBaseUrl + '/docs/user-guide/ui/tenants',
customers: helpBaseUrl + '/docs/user-guide/customers', customers: helpBaseUrl + '/docs/user-guide/ui/customers',
users: helpBaseUrl + '/docs/user-guide/ui/users', users: helpBaseUrl + '/docs/user-guide/ui/users',
devices: helpBaseUrl + '/docs/user-guide/ui/devices', devices: helpBaseUrl + '/docs/user-guide/ui/devices',
assets: helpBaseUrl + '/docs/user-guide/ui/assets', assets: helpBaseUrl + '/docs/user-guide/ui/assets',

Loading…
Cancel
Save