250 changed files with 4852 additions and 2071 deletions
@ -0,0 +1,123 @@ |
|||
-- |
|||
-- Copyright © 2016-2020 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. |
|||
-- |
|||
|
|||
CREATE OR REPLACE PROCEDURE drop_partitions_by_max_ttl(IN partition_type varchar, IN system_ttl bigint, INOUT deleted bigint) |
|||
LANGUAGE plpgsql AS |
|||
$$ |
|||
DECLARE |
|||
max_tenant_ttl bigint; |
|||
max_customer_ttl bigint; |
|||
max_ttl bigint; |
|||
date timestamp; |
|||
partition_by_max_ttl_date varchar; |
|||
partition_month varchar; |
|||
partition_day varchar; |
|||
partition_year varchar; |
|||
partition varchar; |
|||
partition_to_delete varchar; |
|||
|
|||
|
|||
BEGIN |
|||
SELECT max(attribute_kv.long_v) |
|||
FROM tenant |
|||
INNER JOIN attribute_kv ON tenant.id = attribute_kv.entity_id |
|||
WHERE attribute_kv.attribute_key = 'TTL' |
|||
into max_tenant_ttl; |
|||
SELECT max(attribute_kv.long_v) |
|||
FROM customer |
|||
INNER JOIN attribute_kv ON customer.id = attribute_kv.entity_id |
|||
WHERE attribute_kv.attribute_key = 'TTL' |
|||
into max_customer_ttl; |
|||
max_ttl := GREATEST(system_ttl, max_customer_ttl, max_tenant_ttl); |
|||
if max_ttl IS NOT NULL AND max_ttl > 0 THEN |
|||
date := to_timestamp(EXTRACT(EPOCH FROM current_timestamp) - (max_ttl / 1000)); |
|||
partition_by_max_ttl_date := get_partition_by_max_ttl_date(partition_type, date); |
|||
RAISE NOTICE 'Partition by max ttl: %', partition_by_max_ttl_date; |
|||
IF partition_by_max_ttl_date IS NOT NULL THEN |
|||
CASE |
|||
WHEN partition_type = 'DAYS' THEN |
|||
partition_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); |
|||
partition_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); |
|||
partition_day := SPLIT_PART(partition_by_max_ttl_date, '_', 5); |
|||
WHEN partition_type = 'MONTHS' THEN |
|||
partition_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); |
|||
partition_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); |
|||
ELSE |
|||
partition_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); |
|||
END CASE; |
|||
FOR partition IN SELECT tablename |
|||
FROM pg_tables |
|||
WHERE schemaname = 'public' |
|||
AND tablename like 'ts_kv_' || '%' |
|||
AND tablename != 'ts_kv_latest' |
|||
AND tablename != 'ts_kv_dictionary' |
|||
LOOP |
|||
IF partition != partition_by_max_ttl_date THEN |
|||
IF partition_year IS NOT NULL THEN |
|||
IF SPLIT_PART(partition, '_', 3)::integer < partition_year::integer THEN |
|||
partition_to_delete := partition; |
|||
ELSE |
|||
IF partition_month IS NOT NULL THEN |
|||
IF SPLIT_PART(partition, '_', 4)::integer < partition_month::integer THEN |
|||
partition_to_delete := partition; |
|||
ELSE |
|||
IF partition_day IS NOT NULL THEN |
|||
IF SPLIT_PART(partition, '_', 5)::integer < partition_day::integer THEN |
|||
partition_to_delete := partition; |
|||
END IF; |
|||
END IF; |
|||
END IF; |
|||
END IF; |
|||
END IF; |
|||
END IF; |
|||
END IF; |
|||
IF partition_to_delete IS NOT NULL THEN |
|||
RAISE NOTICE 'Partition to delete by max ttl: %', partition_to_delete; |
|||
EXECUTE format('DROP TABLE %I', partition_to_delete); |
|||
deleted := deleted + 1; |
|||
END IF; |
|||
END LOOP; |
|||
END IF; |
|||
END IF; |
|||
END |
|||
$$; |
|||
|
|||
CREATE OR REPLACE FUNCTION get_partition_by_max_ttl_date(IN partition_type varchar, IN date timestamp, OUT partition varchar) AS |
|||
$$ |
|||
BEGIN |
|||
CASE |
|||
WHEN partition_type = 'DAYS' THEN |
|||
partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM') || '_' || to_char(date, 'dd'); |
|||
WHEN partition_type = 'MONTHS' THEN |
|||
partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM'); |
|||
WHEN partition_type = 'YEARS' THEN |
|||
partition := 'ts_kv_' || to_char(date, 'yyyy'); |
|||
WHEN partition_type = 'INDEFINITE' THEN |
|||
partition := NULL; |
|||
ELSE |
|||
partition := NULL; |
|||
END CASE; |
|||
IF partition IS NOT NULL THEN |
|||
IF NOT EXISTS(SELECT |
|||
FROM pg_tables |
|||
WHERE schemaname = 'public' |
|||
AND tablename = partition) THEN |
|||
partition := NULL; |
|||
RAISE NOTICE 'Failed to found partition by ttl'; |
|||
END IF; |
|||
END IF; |
|||
END; |
|||
$$ LANGUAGE plpgsql; |
|||
@ -0,0 +1,125 @@ |
|||
-- |
|||
-- Copyright © 2016-2020 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. |
|||
-- |
|||
|
|||
CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS |
|||
$$ |
|||
BEGIN |
|||
uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || |
|||
'-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); |
|||
END; |
|||
$$ LANGUAGE plpgsql; |
|||
|
|||
CREATE OR REPLACE FUNCTION delete_device_records_from_ts_kv(tenant_id varchar, customer_id varchar, ttl bigint, |
|||
OUT deleted bigint) AS |
|||
$$ |
|||
BEGIN |
|||
EXECUTE format( |
|||
'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT to_uuid(device.id) as entity_id FROM device WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', |
|||
tenant_id, customer_id, ttl) into deleted; |
|||
END; |
|||
$$ LANGUAGE plpgsql; |
|||
|
|||
CREATE OR REPLACE FUNCTION delete_asset_records_from_ts_kv(tenant_id varchar, customer_id varchar, ttl bigint, |
|||
OUT deleted bigint) AS |
|||
$$ |
|||
BEGIN |
|||
EXECUTE format( |
|||
'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT to_uuid(asset.id) as entity_id FROM asset WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', |
|||
tenant_id, customer_id, ttl) into deleted; |
|||
END; |
|||
$$ LANGUAGE plpgsql; |
|||
|
|||
CREATE OR REPLACE FUNCTION delete_customer_records_from_ts_kv(tenant_id varchar, customer_id varchar, ttl bigint, |
|||
OUT deleted bigint) AS |
|||
$$ |
|||
BEGIN |
|||
EXECUTE format( |
|||
'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT to_uuid(customer.id) as entity_id FROM customer WHERE tenant_id = %L and id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', |
|||
tenant_id, customer_id, ttl) into deleted; |
|||
END; |
|||
$$ LANGUAGE plpgsql; |
|||
|
|||
CREATE OR REPLACE PROCEDURE cleanup_timeseries_by_ttl(IN null_uuid varchar(31), |
|||
IN system_ttl bigint, INOUT deleted bigint) |
|||
LANGUAGE plpgsql AS |
|||
$$ |
|||
DECLARE |
|||
tenant_cursor CURSOR FOR select tenant.id as tenant_id |
|||
from tenant; |
|||
tenant_id_record varchar; |
|||
customer_id_record varchar; |
|||
tenant_ttl bigint; |
|||
customer_ttl bigint; |
|||
deleted_for_entities bigint; |
|||
tenant_ttl_ts bigint; |
|||
customer_ttl_ts bigint; |
|||
BEGIN |
|||
OPEN tenant_cursor; |
|||
FETCH tenant_cursor INTO tenant_id_record; |
|||
WHILE FOUND |
|||
LOOP |
|||
EXECUTE format( |
|||
'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', |
|||
tenant_id_record, 'TTL') INTO tenant_ttl; |
|||
if tenant_ttl IS NULL THEN |
|||
tenant_ttl := system_ttl; |
|||
END IF; |
|||
IF tenant_ttl > 0 THEN |
|||
tenant_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - tenant_ttl::bigint * 1000)::bigint; |
|||
deleted_for_entities := delete_device_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); |
|||
deleted := deleted + deleted_for_entities; |
|||
RAISE NOTICE '% telemetry removed for devices where tenant_id = %', deleted_for_entities, tenant_id_record; |
|||
deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); |
|||
deleted := deleted + deleted_for_entities; |
|||
RAISE NOTICE '% telemetry removed for assets where tenant_id = %', deleted_for_entities, tenant_id_record; |
|||
END IF; |
|||
FOR customer_id_record IN |
|||
SELECT customer.id AS customer_id FROM customer WHERE customer.tenant_id = tenant_id_record |
|||
LOOP |
|||
EXECUTE format( |
|||
'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', |
|||
customer_id_record, 'TTL') INTO customer_ttl; |
|||
IF customer_ttl IS NULL THEN |
|||
customer_ttl_ts := tenant_ttl_ts; |
|||
ELSE |
|||
IF customer_ttl > 0 THEN |
|||
customer_ttl_ts := |
|||
(EXTRACT(EPOCH FROM current_timestamp) * 1000 - |
|||
customer_ttl::bigint * 1000)::bigint; |
|||
END IF; |
|||
END IF; |
|||
IF customer_ttl_ts IS NOT NULL AND customer_ttl_ts > 0 THEN |
|||
deleted_for_entities := |
|||
delete_customer_records_from_ts_kv(tenant_id_record, customer_id_record, |
|||
customer_ttl_ts); |
|||
deleted := deleted + deleted_for_entities; |
|||
RAISE NOTICE '% telemetry removed for customer with id = % where tenant_id = %', deleted_for_entities, customer_id_record, tenant_id_record; |
|||
deleted_for_entities := |
|||
delete_device_records_from_ts_kv(tenant_id_record, customer_id_record, |
|||
customer_ttl_ts); |
|||
deleted := deleted + deleted_for_entities; |
|||
RAISE NOTICE '% telemetry removed for devices where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; |
|||
deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, |
|||
customer_id_record, |
|||
customer_ttl_ts); |
|||
deleted := deleted + deleted_for_entities; |
|||
RAISE NOTICE '% telemetry removed for assets where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; |
|||
END IF; |
|||
END LOOP; |
|||
FETCH tenant_cursor INTO tenant_id_record; |
|||
END LOOP; |
|||
END |
|||
$$; |
|||
@ -0,0 +1,89 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.ttl; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.scheduling.annotation.Scheduled; |
|||
import org.thingsboard.server.dao.util.PsqlTsAnyDao; |
|||
|
|||
import java.sql.Connection; |
|||
import java.sql.DriverManager; |
|||
import java.sql.ResultSet; |
|||
import java.sql.SQLException; |
|||
import java.sql.SQLWarning; |
|||
import java.sql.Statement; |
|||
|
|||
@PsqlTsAnyDao |
|||
@Slf4j |
|||
public abstract class AbstractTimeseriesCleanUpService { |
|||
|
|||
@Value("${sql.ttl.ts_key_value_ttl}") |
|||
protected long systemTtl; |
|||
|
|||
@Value("${sql.ttl.enabled}") |
|||
private boolean ttlTaskExecutionEnabled; |
|||
|
|||
@Value("${spring.datasource.url}") |
|||
private String dbUrl; |
|||
|
|||
@Value("${spring.datasource.username}") |
|||
private String dbUserName; |
|||
|
|||
@Value("${spring.datasource.password}") |
|||
private String dbPassword; |
|||
|
|||
@Scheduled(initialDelayString = "${sql.ttl.execution_interval_ms}", fixedDelayString = "${sql.ttl.execution_interval_ms}") |
|||
public void cleanUp() { |
|||
if (ttlTaskExecutionEnabled) { |
|||
try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { |
|||
doCleanUp(conn); |
|||
} catch (SQLException e) { |
|||
log.error("SQLException occurred during TTL task execution ", e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
protected abstract void doCleanUp(Connection connection); |
|||
|
|||
protected long executeQuery(Connection conn, String query) { |
|||
long removed = 0L; |
|||
try { |
|||
Statement statement = conn.createStatement(); |
|||
ResultSet resultSet = statement.executeQuery(query); |
|||
getWarnings(statement); |
|||
resultSet.next(); |
|||
removed = resultSet.getLong(1); |
|||
log.debug("Successfully executed query: {}", query); |
|||
} catch (SQLException e) { |
|||
log.debug("Failed to execute query: {} due to: {}", query, e.getMessage()); |
|||
} |
|||
return removed; |
|||
} |
|||
|
|||
private void getWarnings(Statement statement) throws SQLException { |
|||
SQLWarning warnings = statement.getWarnings(); |
|||
if (warnings != null) { |
|||
log.debug("{}", warnings.getMessage()); |
|||
SQLWarning nextWarning = warnings.getNextWarning(); |
|||
while (nextWarning != null) { |
|||
log.debug("{}", nextWarning.getMessage()); |
|||
nextWarning = nextWarning.getNextWarning(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.ttl; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.dao.model.ModelConstants; |
|||
import org.thingsboard.server.dao.util.PsqlTsDao; |
|||
|
|||
import java.sql.Connection; |
|||
|
|||
@PsqlTsDao |
|||
@Service |
|||
@Slf4j |
|||
public class PsqlTimeseriesCleanUpService extends AbstractTimeseriesCleanUpService { |
|||
|
|||
@Value("${sql.postgres.ts_key_value_partitioning}") |
|||
private String partitionType; |
|||
|
|||
@Override |
|||
protected void doCleanUp(Connection connection) { |
|||
long totalPartitionsRemoved = executeQuery(connection, "call drop_partitions_by_max_ttl('" + partitionType + "'," + systemTtl + ", 0);"); |
|||
log.info("Total partitions removed by TTL: [{}]", totalPartitionsRemoved); |
|||
long totalEntitiesTelemetryRemoved = executeQuery(connection, "call cleanup_timeseries_by_ttl('" + ModelConstants.NULL_UUID_STR + "'," + systemTtl + ", 0);"); |
|||
log.info("Total telemetry removed stats by TTL for entities: [{}]", totalEntitiesTelemetryRemoved); |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.ttl; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.dao.model.ModelConstants; |
|||
import org.thingsboard.server.dao.util.TimescaleDBTsDao; |
|||
|
|||
import java.sql.Connection; |
|||
|
|||
@TimescaleDBTsDao |
|||
@Service |
|||
@Slf4j |
|||
public class TimescaleTimeseriesCleanUpService extends AbstractTimeseriesCleanUpService { |
|||
|
|||
@Override |
|||
protected void doCleanUp(Connection connection) { |
|||
long totalEntitiesTelemetryRemoved = executeQuery(connection, "call cleanup_timeseries_by_ttl('" + ModelConstants.NULL_UUID_STR + "'," + systemTtl + ", 0);"); |
|||
log.info("Total telemetry removed stats by TTL for entities: [{}]", totalEntitiesTelemetryRemoved); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.util; |
|||
|
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; |
|||
|
|||
@ConditionalOnExpression("'${database.ts.type}'=='sql' && '${spring.jpa.database-platform}'=='org.hibernate.dialect.PostgreSQLDialect'") |
|||
public @interface PsqlTsDao { } |
|||
File diff suppressed because one or more lines are too long
@ -0,0 +1,76 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { TranslateParser } from '@ngx-translate/core'; |
|||
import { isDefinedAndNotNull } from '@core/utils'; |
|||
|
|||
@Injectable({ providedIn: 'root' }) |
|||
export class TranslateDefaultParser extends TranslateParser { |
|||
templateMatcher: RegExp = /{{\s?([^{}\s]*)\s?}}/g; |
|||
|
|||
// tslint:disable-next-line:ban-types
|
|||
public interpolate(expr: string | Function, params?: any): string { |
|||
let result: string; |
|||
|
|||
if (typeof expr === 'string') { |
|||
result = this.interpolateString(expr, params); |
|||
} else if (typeof expr === 'function') { |
|||
result = this.interpolateFunction(expr, params); |
|||
if (typeof result === 'string') { |
|||
result = this.interpolateString(result, params); |
|||
} |
|||
} else { |
|||
result = expr as string; |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
getValue(target: any, key: string): any { |
|||
const keys = typeof key === 'string' ? key.split('.') : [key]; |
|||
key = ''; |
|||
do { |
|||
key += keys.shift(); |
|||
if (isDefinedAndNotNull(target) && isDefinedAndNotNull(target[key]) && (typeof target[key] === 'object' || !keys.length)) { |
|||
target = target[key]; |
|||
key = ''; |
|||
} else if (!keys.length) { |
|||
target = undefined; |
|||
} else { |
|||
key += '.'; |
|||
} |
|||
} while (keys.length); |
|||
|
|||
return target; |
|||
} |
|||
|
|||
// tslint:disable-next-line:ban-types
|
|||
private interpolateFunction(fn: Function, params?: any) { |
|||
return fn(params); |
|||
} |
|||
|
|||
private interpolateString(expr: string, params?: any) { |
|||
if (!params) { |
|||
return expr; |
|||
} |
|||
|
|||
return expr.replace(this.templateMatcher, (substring: string, b: string) => { |
|||
const r = this.getValue(params, b); |
|||
return isDefinedAndNotNull(r) ? r : substring; |
|||
}); |
|||
} |
|||
} |
|||
@ -1,349 +1,355 @@ |
|||
<!--=======================================================================--> |
|||
<!--=================== New TB 3.0 / Angular 8 Example =================--> |
|||
<!--=======================================================================--> |
|||
<!--<form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup"--> |
|||
<!-- class="edit-entity-form"--> |
|||
<!-- (ngSubmit)="save()" style="width: 600px;">--> |
|||
<!-- <mat-toolbar fxLayout="row" color="primary">--> |
|||
<!-- <h2>Edit {{entityType.toLowerCase()}} {{entityName}}</h2>--> |
|||
<!-- <span fxFlex></span>--> |
|||
<!-- <button mat-button mat-icon-button--> |
|||
<!-- (click)="cancel()"--> |
|||
<!-- type="button">--> |
|||
<!-- <mat-icon class="material-icons">close</mat-icon>--> |
|||
<!-- </button>--> |
|||
<!-- </mat-toolbar>--> |
|||
<!-- <mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">--> |
|||
<!-- </mat-progress-bar>--> |
|||
<!-- <div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>--> |
|||
<!-- <div mat-dialog-content>--> |
|||
<!-- <div class="mat-padding" fxLayout="column">--> |
|||
<!-- <mat-form-field class="mat-block">--> |
|||
<!-- <mat-label>Entity name</mat-label>--> |
|||
<!-- <input matInput formControlName="entityName" required>--> |
|||
<!-- <mat-error *ngIf="editEntityFormGroup.get('entityName').hasError('required')">--> |
|||
<!-- Entity name required--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div> --> |
|||
<!-- </div>--> |
|||
<!-- <div mat-dialog-actions fxLayout="row">--> |
|||
<!-- <span fxFlex></span>--> |
|||
<!-- <button mat-button mat-raised-button color="primary"--> |
|||
<!-- type="submit"--> |
|||
<!-- [disabled]="(isLoading$ | async) || editEntityForm.invalid || !editEntityForm.dirty">--> |
|||
<!-- {{ 'action.save' | translate }}--> |
|||
<!-- </button>--> |
|||
<!-- <button mat-button color="primary"--> |
|||
<!-- style="margin-right: 20px;"--> |
|||
<!-- type="button"--> |
|||
<!-- [disabled]="(isLoading$ | async)"--> |
|||
<!-- (click)="cancel()" cdkFocusInitial>--> |
|||
<!-- {{ 'action.cancel' | translate }}--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!--</form>--> |
|||
<!--=======================================================================--> |
|||
<!--===== There are two example templates: for edit and add entity =====--> |
|||
<!--=======================================================================--> |
|||
<!--======================== Edit entity example ========================--> |
|||
<!--=======================================================================--> |
|||
<!-- --> |
|||
<!--<md-dialog aria-label="Edit entity">--> |
|||
<!-- <form name="editEntityForm" class="edit-entity-form" ng-submit="vm.save()">--> |
|||
<!-- <md-toolbar>--> |
|||
<!-- <div class="md-toolbar-tools">--> |
|||
<!-- <h2>Edit {{vm.entityType.toLowerCase()}} {{vm.entityName}}</h2>--> |
|||
<!-- <span flex></span>--> |
|||
<!-- <md-button class="md-icon-button" ng-click="vm.cancel()">--> |
|||
<!-- <ng-md-icon icon="close" aria-label="Close"></ng-md-icon>--> |
|||
<!-- </md-button>--> |
|||
<!--<form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup"--> |
|||
<!-- (ngSubmit)="save()" class="edit-entity-form">--> |
|||
<!-- <mat-toolbar fxLayout="row" color="primary">--> |
|||
<!-- <h2>Edit {{entityType.toLowerCase()}} {{entityName}}</h2>--> |
|||
<!-- <span fxFlex></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 fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Entity Name</mat-label>--> |
|||
<!-- <input matInput formControlName="entityName" required readonly="">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Entity Label</mat-label>--> |
|||
<!-- <input matInput formControlName="entityLabel">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Entity Type</mat-label>--> |
|||
<!-- <input matInput formControlName="entityType" readonly>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Type</mat-label>--> |
|||
<!-- <input matInput formControlName="type" readonly>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- <div formGroupName="attributes" fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Latitude</mat-label>--> |
|||
<!-- <input type="number" step="any" matInput formControlName="latitude">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Longitude</mat-label>--> |
|||
<!-- <input type="number" step="any" matInput formControlName="longitude">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- </md-toolbar>--> |
|||
<!-- <md-dialog-content>--> |
|||
<!-- <div class="md-dialog-content">--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Entity Name</label>--> |
|||
<!-- <input ng-model="vm.entityName" readonly>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Entity Type</label>--> |
|||
<!-- <input ng-model="vm.entityType" readonly>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Type</label>--> |
|||
<!-- <input ng-model="vm.type" readonly>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Latitude</label>--> |
|||
<!-- <input name="latitude" type="number" step="any" ng-model="vm.attributes.latitude">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Longitude</label>--> |
|||
<!-- <input name="longitude" type="number" step="any" ng-model="vm.attributes.longitude">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Address</label>--> |
|||
<!-- <input ng-model="vm.attributes.address">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Owner</label>--> |
|||
<!-- <input ng-model="vm.attributes.owner">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Integer Value</label>--> |
|||
<!-- <input name="integerNumber" type="number" step="1" ng-pattern="/^-?[0-9]+$/" ng-model="vm.attributes.number">--> |
|||
<!-- <div ng-messages="editEntityForm.integerNumber.$error">--> |
|||
<!-- <div ng-message="pattern">Invalid integer value.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <div class="boolean-value-input" layout="column" layout-align="center start" flex>--> |
|||
<!-- <label class="checkbox-label">Boolean Value</label>--> |
|||
<!-- <md-checkbox ng-model="vm.attributes.booleanValue" style="margin-bottom: 40px;">--> |
|||
<!-- {{ (vm.attributes.booleanValue ? "value.true" : "value.false") | translate }}--> |
|||
<!-- </md-checkbox>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Address</mat-label>--> |
|||
<!-- <input matInput formControlName="address">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Owner</mat-label>--> |
|||
<!-- <input matInput formControlName="owner">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Integer Value</mat-label>--> |
|||
<!-- <input type="number" step="1" matInput formControlName="number">--> |
|||
<!-- <mat-error *ngIf="editEntityFormGroup.get('attributes').get('number').hasError('pattern')">--> |
|||
<!-- Invalid integer value.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex>--> |
|||
<!-- <label class="checkbox-label">Boolean Value</label>--> |
|||
<!-- <mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;">--> |
|||
<!-- {{ (editEntityFormGroup.get('attributes').get('booleanValue').value ? "value.true" : "value.false") | translate }}--> |
|||
<!-- </mat-checkbox>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list old-relations">--> |
|||
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>--> |
|||
<!-- <div class="body" ng-show="vm.relations.length">--> |
|||
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.relations track by $index">--> |
|||
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px; margin-bottom: 3px;">--> |
|||
<!-- <div flex layout="column">--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container class="md-block" style="min-width: 100px;">--> |
|||
<!-- <label>Direction</label>--> |
|||
<!-- <md-select ng-disabled="true" required ng-model="relation.direction">--> |
|||
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">--> |
|||
<!-- {{ ("relation.search-direction." + direction) | translate}}--> |
|||
<!-- </md-option>--> |
|||
<!-- </md-select>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <tb-relation-type-autocomplete ng-disabled="true" flex class="md-block"--> |
|||
<!-- the-form="editEntityForm"--> |
|||
<!-- ng-model="relation.relationType"--> |
|||
<!-- tb-required="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <tb-entity-select flex class="md-block"--> |
|||
<!-- the-form="editEntityForm"--> |
|||
<!-- ng-disabled="true"--> |
|||
<!-- tb-required="true"--> |
|||
<!-- ng-model="relation.relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="column" layout-align="center center">--> |
|||
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"--> |
|||
<!-- ng-click="vm.removeOldRelation($index,relation)" aria-label="Remove">--> |
|||
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>--> |
|||
<!-- <md-icon aria-label="Remove" class="material-icons">--> |
|||
<!-- close--> |
|||
<!-- </md-icon>--> |
|||
<!-- </md-button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list old-relations">--> |
|||
<!-- <div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>--> |
|||
<!-- <div class="body" [fxShow]="oldRelations().length">--> |
|||
<!-- <div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="oldRelations" --> |
|||
<!-- *ngFor="let relation of oldRelations().controls; let i = index;">--> |
|||
<!-- <div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">--> |
|||
<!-- <div fxFlex fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field class="mat-block" style="min-width: 100px;">--> |
|||
<!-- <mat-label>Direction</mat-label>--> |
|||
<!-- <mat-select formControlName="direction" name="direction">--> |
|||
<!-- <mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">--> |
|||
<!-- {{ ("relation.search-direction." + direction.value) | translate}}--> |
|||
<!-- </mat-option>--> |
|||
<!-- </mat-select>--> |
|||
<!-- <mat-error *ngIf="relation.get('direction').hasError('required')">--> |
|||
<!-- Relation direction is required.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <tb-relation-type-autocomplete--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- formControlName="relationType"--> |
|||
<!-- required="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayout.xs="column">--> |
|||
<!-- <tb-entity-select--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- required="true"--> |
|||
<!-- formControlName="relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="column" fxLayoutAlign="center center">--> |
|||
<!-- <button mat-icon-button color="primary"--> |
|||
<!-- aria-label="Remove"--> |
|||
<!-- type="button"--> |
|||
<!-- (click)="removeOldRelation(i)"--> |
|||
<!-- matTooltip="Remove relation"--> |
|||
<!-- matTooltipPosition="above">--> |
|||
<!-- <mat-icon>close</mat-icon>--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list">--> |
|||
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">New Relations</div>--> |
|||
<!-- <div class="body" ng-show="vm.newRelations.length">--> |
|||
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.newRelations track by $index">--> |
|||
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px; margin-bottom: 3px;">--> |
|||
<!-- <div flex layout="column">--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container class="md-block" style="min-width: 100px;">--> |
|||
<!-- <label>Direction</label>--> |
|||
<!-- <md-select name="direction" required ng-model="relation.direction">--> |
|||
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">--> |
|||
<!-- {{ ("relation.search-direction." + direction) | translate}}--> |
|||
<!-- </md-option>--> |
|||
<!-- </md-select>--> |
|||
<!-- <div ng-messages="editEntityForm.direction.$error">--> |
|||
<!-- <div ng-message="required">Relation direction is required.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <tb-relation-type-autocomplete flex class="md-block"--> |
|||
<!-- the-form="editEntityForm"--> |
|||
<!-- ng-model="relation.relationType"--> |
|||
<!-- tb-required="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <tb-entity-select flex class="md-block"--> |
|||
<!-- the-form="editEntityForm"--> |
|||
<!-- tb-required="true"--> |
|||
<!-- ng-model="relation.relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="column" layout-align="center center">--> |
|||
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"--> |
|||
<!-- ng-click="vm.removeRelation($index)" aria-label="Remove">--> |
|||
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>--> |
|||
<!-- <md-icon aria-label="Remove" class="material-icons">--> |
|||
<!-- close--> |
|||
<!-- </md-icon>--> |
|||
<!-- </md-button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list">--> |
|||
<!-- <div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">New Relations</div>--> |
|||
<!-- <div class="body" [fxShow]="relations().length">--> |
|||
<!-- <div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;">--> |
|||
<!-- <div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">--> |
|||
<!-- <div fxFlex fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field class="mat-block" style="min-width: 100px;">--> |
|||
<!-- <mat-label>Direction</mat-label>--> |
|||
<!-- <mat-select formControlName="direction" name="direction">--> |
|||
<!-- <mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">--> |
|||
<!-- {{ ("relation.search-direction." + direction.value) | translate}}--> |
|||
<!-- </mat-option>--> |
|||
<!-- </mat-select>--> |
|||
<!-- <mat-error *ngIf="relation.get('direction').hasError('required')">--> |
|||
<!-- Relation direction is required.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <tb-relation-type-autocomplete--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- formControlName="relationType"--> |
|||
<!-- [required]="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayout.xs="column">--> |
|||
<!-- <tb-entity-select--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- [required]="true"--> |
|||
<!-- formControlName="relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="column" fxLayoutAlign="center center">--> |
|||
<!-- <button mat-icon-button color="primary"--> |
|||
<!-- aria-label="Remove"--> |
|||
<!-- type="button"--> |
|||
<!-- (click)="removeRelation(i)"--> |
|||
<!-- matTooltip="Remove relation"--> |
|||
<!-- matTooltipPosition="above">--> |
|||
<!-- <mat-icon>close</mat-icon>--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div>--> |
|||
<!-- <md-button class="md-primary md-raised" ng-click="vm.addRelation()" aria-label="Add">--> |
|||
<!-- <md-tooltip md-direction="top">Add Relation</md-tooltip>--> |
|||
<!-- Add--> |
|||
<!-- </md-button>--> |
|||
<!-- </div> --> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-dialog-content>--> |
|||
<!-- <md-dialog-actions>--> |
|||
<!-- <md-button type="submit" ng-disabled="editEntityForm.$invalid || !editEntityForm.$dirty" class="md-raised md-primary">Save</md-button>--> |
|||
<!-- <md-button ng-click="vm.cancel()" class="md-primary">Cancel</md-button>--> |
|||
<!-- </md-dialog-actions>--> |
|||
<!-- </form>--> |
|||
<!--</md-dialog>--> |
|||
<!-- <div>--> |
|||
<!-- <button mat-raised-button color="primary"--> |
|||
<!-- type="button"--> |
|||
<!-- (click)="addRelation()"--> |
|||
<!-- matTooltip="Add Relation"--> |
|||
<!-- matTooltipPosition="above">--> |
|||
<!-- Add--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">--> |
|||
<!-- <button mat-button mat-raised-button color="primary"--> |
|||
<!-- type="submit"--> |
|||
<!-- [disabled]="(isLoading$ | async) || editEntityForm.invalid || !editEntityForm.dirty">--> |
|||
<!-- Save--> |
|||
<!-- </button>--> |
|||
<!-- <button mat-button color="primary"--> |
|||
<!-- style="margin-right: 20px;"--> |
|||
<!-- type="button"--> |
|||
<!-- [disabled]="(isLoading$ | async)"--> |
|||
<!-- (click)="cancel()" cdkFocusInitial>--> |
|||
<!-- Cancel--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!--</form>--> |
|||
<!----> |
|||
<!--========================================================================--> |
|||
<!--========================= Add entity example =========================--> |
|||
<!--========================================================================--> |
|||
<!----> |
|||
<!--<md-dialog aria-label="Add entity">--> |
|||
<!-- <form name="addEntityForm" class="add-entity-form" ng-submit="vm.save()">--> |
|||
<!-- <md-toolbar>--> |
|||
<!-- <div class="md-toolbar-tools">--> |
|||
<!-- <h2>Add entity</h2>--> |
|||
<!-- <span flex></span>--> |
|||
<!-- <md-button class="md-icon-button" ng-click="vm.cancel()">--> |
|||
<!-- <ng-md-icon icon="close" aria-label="Close"></ng-md-icon>--> |
|||
<!-- </md-button>--> |
|||
<!--<form #addEntityForm="ngForm" [formGroup]="addEntityFormGroup"--> |
|||
<!-- (ngSubmit)="save()" class="add-entity-form">--> |
|||
<!-- <mat-toolbar fxLayout="row" color="primary">--> |
|||
<!-- <h2>Add entity</h2>--> |
|||
<!-- <span fxFlex></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 fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Entity Name</mat-label>--> |
|||
<!-- <input matInput formControlName="entityName" required>--> |
|||
<!-- <mat-error *ngIf="addEntityFormGroup.get('entityName').hasError('required')">--> |
|||
<!-- Entity name is required.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Entity Label</mat-label>--> |
|||
<!-- <input matInput formControlName="entityLabel" >--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <tb-entity-type-select--> |
|||
<!-- class="mat-block"--> |
|||
<!-- formControlName="entityType"--> |
|||
<!-- [showLabel]="true"--> |
|||
<!-- [allowedEntityTypes]="allowedEntityTypes"--> |
|||
<!-- ></tb-entity-type-select>--> |
|||
<!-- <tb-entity-subtype-autocomplete--> |
|||
<!-- fxFlex *ngIf="addEntityFormGroup.get('entityType').value == 'ASSET'"--> |
|||
<!-- class="mat-block"--> |
|||
<!-- formControlName="type"--> |
|||
<!-- [required]="true"--> |
|||
<!-- [entityType]="'ASSET'"--> |
|||
<!-- ></tb-entity-subtype-autocomplete>--> |
|||
<!-- <tb-entity-subtype-autocomplete--> |
|||
<!-- fxFlex *ngIf="addEntityFormGroup.get('entityType').value != 'ASSET'"--> |
|||
<!-- class="mat-block"--> |
|||
<!-- formControlName="type"--> |
|||
<!-- [required]="true"--> |
|||
<!-- [entityType]="'DEVICE'"--> |
|||
<!-- ></tb-entity-subtype-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div formGroupName="attributes" fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Latitude</mat-label>--> |
|||
<!-- <input type="number" step="any" matInput formControlName="latitude">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Longitude</mat-label>--> |
|||
<!-- <input type="number" step="any" matInput formControlName="longitude">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- </md-toolbar>--> |
|||
<!-- <md-dialog-content>--> |
|||
<!-- <div class="md-dialog-content">--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Entity Name</label>--> |
|||
<!-- <input ng-model="vm.entityName" name=entityName required>--> |
|||
<!-- <div ng-messages="addEntityForm.entityName.$error">--> |
|||
<!-- <div ng-message="required">Entity name is required.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <tb-entity-type-select class="md-block" style="min-width: 100px; width: 100px;"--> |
|||
<!-- the-form="addEntityForm"--> |
|||
<!-- tb-required="true"--> |
|||
<!-- allowed-entity-types="vm.allowedEntityTypes"--> |
|||
<!-- ng-model="vm.entityType">--> |
|||
<!-- </tb-entity-type-select>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Entity Subtype</label>--> |
|||
<!-- <input ng-model="vm.type" name=type required>--> |
|||
<!-- <div ng-messages="addEntityForm.type.$error">--> |
|||
<!-- <div ng-message="required">Entity subtype is required.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Latitude</label>--> |
|||
<!-- <input name="latitude" type="number" step="any" ng-model="vm.attributes.latitude">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Longitude</label>--> |
|||
<!-- <input name="longitude" type="number" step="any" ng-model="vm.attributes.longitude">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Address</label>--> |
|||
<!-- <input ng-model="vm.attributes.address">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Owner</label>--> |
|||
<!-- <input ng-model="vm.attributes.owner">--> |
|||
<!-- </md-input-container>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container flex class="md-block">--> |
|||
<!-- <label>Integer Value</label>--> |
|||
<!-- <input name="integerNumber" type="number" step="1" ng-pattern="/^-?[0-9]+$/" ng-model="vm.attributes.number">--> |
|||
<!-- <div ng-messages="addEntityForm.integerNumber.$error">--> |
|||
<!-- <div ng-message="pattern">Invalid integer value.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <div class="boolean-value-input" layout="column" layout-align="center start" flex>--> |
|||
<!-- <label class="checkbox-label">Boolean Value</label>--> |
|||
<!-- <md-checkbox ng-model="vm.attributes.booleanValue" style="margin-bottom: 40px;">--> |
|||
<!-- {{ (vm.attributes.booleanValue ? "value.true" : "value.false") | translate }}--> |
|||
<!-- </md-checkbox>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Address</mat-label>--> |
|||
<!-- <input matInput formControlName="address">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Owner</mat-label>--> |
|||
<!-- <input matInput formControlName="owner">--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field fxFlex class="mat-block">--> |
|||
<!-- <mat-label>Integer Value</mat-label>--> |
|||
<!-- <input type="number" step="1" matInput formControlName="number">--> |
|||
<!-- <mat-error *ngIf="addEntityFormGroup.get('attributes').get('number').hasError('pattern')">--> |
|||
<!-- Invalid integer value.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex>--> |
|||
<!-- <label class="checkbox-label">Boolean Value</label>--> |
|||
<!-- <mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;">--> |
|||
<!-- {{ (addEntityFormGroup.get('attributes').get('booleanValue').value ? "value.true" : "value.false") | translate }}--> |
|||
<!-- </mat-checkbox>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list">--> |
|||
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>--> |
|||
<!-- <div class="body" ng-show="vm.relations.length">--> |
|||
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.relations track by $index">--> |
|||
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px;">--> |
|||
<!-- <div flex layout="column">--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <md-input-container class="md-block" style="min-width: 100px;">--> |
|||
<!-- <label>Direction</label>--> |
|||
<!-- <md-select name="direction" required ng-model="relation.direction">--> |
|||
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">--> |
|||
<!-- {{ ("relation.search-direction." + direction) | translate}}--> |
|||
<!-- </md-option>--> |
|||
<!-- </md-select>--> |
|||
<!-- <div ng-messages="addEntityForm.direction.$error">--> |
|||
<!-- <div ng-message="required">Relation direction is required.</div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-input-container>--> |
|||
<!-- <tb-relation-type-autocomplete flex class="md-block"--> |
|||
<!-- the-form="addEntityForm"--> |
|||
<!-- ng-model="relation.relationType"--> |
|||
<!-- tb-required="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="row">--> |
|||
<!-- <tb-entity-select flex class="md-block"--> |
|||
<!-- the-form="addEntityForm"--> |
|||
<!-- tb-required="true"--> |
|||
<!-- ng-model="relation.relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div layout="column" layout-align="center center">--> |
|||
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"--> |
|||
<!-- ng-click="vm.removeRelation($index)" aria-label="Remove">--> |
|||
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>--> |
|||
<!-- <md-icon aria-label="Remove" class="material-icons">--> |
|||
<!-- close--> |
|||
<!-- </md-icon>--> |
|||
<!-- </md-button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div class="relations-list">--> |
|||
<!-- <div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>--> |
|||
<!-- <div class="body" [fxShow]="relations().length">--> |
|||
<!-- <div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;">--> |
|||
<!-- <div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;">--> |
|||
<!-- <div fxFlex fxLayout="column">--> |
|||
<!-- <div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0">--> |
|||
<!-- <mat-form-field class="mat-block" style="min-width: 100px;">--> |
|||
<!-- <mat-label>Direction</mat-label>--> |
|||
<!-- <mat-select formControlName="direction" name="direction">--> |
|||
<!-- <mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value">--> |
|||
<!-- {{ ("relation.search-direction." + direction.value) | translate}}--> |
|||
<!-- </mat-option>--> |
|||
<!-- </mat-select>--> |
|||
<!-- <mat-error *ngIf="relation.get('direction').hasError('required')">--> |
|||
<!-- Relation direction is required.--> |
|||
<!-- </mat-error>--> |
|||
<!-- </mat-form-field>--> |
|||
<!-- <tb-relation-type-autocomplete--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- formControlName="relationType"--> |
|||
<!-- [required]="true">--> |
|||
<!-- </tb-relation-type-autocomplete>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="row" fxLayout.xs="column">--> |
|||
<!-- <tb-entity-select--> |
|||
<!-- fxFlex class="mat-block"--> |
|||
<!-- [required]="true"--> |
|||
<!-- formControlName="relatedEntity">--> |
|||
<!-- </tb-entity-select>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div fxLayout="column" fxLayoutAlign="center center">--> |
|||
<!-- <button mat-icon-button color="primary"--> |
|||
<!-- aria-label="Remove"--> |
|||
<!-- type="button"--> |
|||
<!-- (click)="removeRelation(i)"--> |
|||
<!-- matTooltip="Remove relation"--> |
|||
<!-- matTooltipPosition="above">--> |
|||
<!-- <mat-icon>close</mat-icon>--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div>--> |
|||
<!-- <md-button class="md-primary md-raised" ng-click="vm.addRelation()" aria-label="Add">--> |
|||
<!-- <md-tooltip md-direction="top">Add Relation</md-tooltip>--> |
|||
<!-- Add--> |
|||
<!-- </md-button>--> |
|||
<!-- </div> --> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </md-dialog-content>--> |
|||
<!-- <md-dialog-actions>--> |
|||
<!-- <md-button type="submit" ng-disabled="addEntityForm.$invalid || !addEntityForm.$dirty" class="md-raised md-primary">Create</md-button>--> |
|||
<!-- <md-button ng-click="vm.cancel()" class="md-primary">Cancel</md-button>--> |
|||
<!-- </md-dialog-actions>--> |
|||
<!-- </form>--> |
|||
<!--</md-dialog>--> |
|||
<!-- <div>--> |
|||
<!-- <button mat-raised-button color="primary"--> |
|||
<!-- type="button"--> |
|||
<!-- (click)="addRelation()"--> |
|||
<!-- matTooltip="Add Relation"--> |
|||
<!-- matTooltipPosition="above">--> |
|||
<!-- Add--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- </div>--> |
|||
<!-- <div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center">--> |
|||
<!-- <button mat-button mat-raised-button color="primary"--> |
|||
<!-- type="submit"--> |
|||
<!-- [disabled]="(isLoading$ | async) || addEntityForm.invalid || !addEntityForm.dirty">--> |
|||
<!-- Create--> |
|||
<!-- </button>--> |
|||
<!-- <button mat-button color="primary"--> |
|||
<!-- style="margin-right: 20px;"--> |
|||
<!-- type="button"--> |
|||
<!-- [disabled]="(isLoading$ | async)"--> |
|||
<!-- (click)="cancel()" cdkFocusInitial>--> |
|||
<!-- Cancel--> |
|||
<!-- </button>--> |
|||
<!-- </div>--> |
|||
<!--</form>--> |
|||
|
|||
@ -0,0 +1,262 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2020 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 #formContainer class="gateway-form" |
|||
[formGroup]="gatewayConfigurationGroup" |
|||
tb-toast toastTarget="{{ toastTargetId }}" |
|||
(ngSubmit)="save()"> |
|||
<mat-accordion multi="true" class="mat-body-1"> |
|||
<mat-expansion-panel> |
|||
<mat-expansion-panel-header> |
|||
<mat-panel-title> |
|||
<div class="tb-panel-title">{{ 'gateway.thingsboard' | translate | uppercase }}</div> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<tb-entity-gateway-select |
|||
formControlName="gateway" |
|||
[newGatewayType]="gatewayType" |
|||
(gatewayNameExist)="gatewayExist()" |
|||
required |
|||
> |
|||
</tb-entity-gateway-select> |
|||
<div fxLayout="column"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{'gateway.security-type' | translate }}</mat-label> |
|||
<mat-select formControlName="securityType" > |
|||
<mat-option *ngFor="let securityType of securityTypes | keyvalue" [value]="securityType.key"> |
|||
{{ securityType.value.toString() | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.thingsboard-host' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="host"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('host').hasError('required')" translate> |
|||
gateway.thingsboard-host-required |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.thingsboard-port' | translate }}</mat-label> |
|||
<input matInput type="number" formControlName="port"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('port').hasError('required')" translate> |
|||
gateway.thingsboard-port-required |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('port').hasError('min')" translate> |
|||
gateway.thingsboard-port-min |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('port').hasError('max')" translate> |
|||
gateway.thingsboard-port-max |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('port').hasError('pattern')" translate> |
|||
gateway.thingsboard-port-pattern |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
|
|||
<div *ngIf="gatewayConfigurationGroup.get('securityType').value == 'tls'" fxLayout="column"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.tls-path-ca-certificate' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="caCertPath"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.tls-path-private-key' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="privateKeyPath"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.tls-path-client-certificate' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="certPath"> |
|||
</mat-form-field> |
|||
</div> |
|||
|
|||
<mat-checkbox formControlName="remoteConfiguration">{{ 'gateway.remote' | translate }}</mat-checkbox> |
|||
|
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{'gateway.remote-logging-level' | translate }}</mat-label> |
|||
<mat-select formControlName="remoteLoggingLevel"> |
|||
<mat-option *ngFor="let logLevel of gatewayLogLevels" [value]="logLevel"> |
|||
{{ logLevel }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
|
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.path-logs' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="remoteLoggingPathToLogs"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('remoteLoggingPathToLogs').hasError('required')" translate> |
|||
gateway.path-logs-required |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
|
|||
</mat-expansion-panel> |
|||
|
|||
<mat-expansion-panel> |
|||
<mat-expansion-panel-header> |
|||
<mat-panel-title> |
|||
<div class="tb-panel-title">{{ 'gateway.storage' | translate | uppercase }}</div> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
|
|||
<div fxLayout="column"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{'gateway.storage-type' | translate }}</mat-label> |
|||
<mat-select formControlName="storageType"> |
|||
<mat-option *ngFor="let storageType of storageTypes | keyvalue" [value]="storageType.key"> |
|||
{{ storageType.value.toString() | translate}} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
|
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.storage-pack-size' | translate }}</mat-label> |
|||
<input matInput type="number" formControlName="readRecordsCount"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('readRecordsCount').hasError('required')" translate> |
|||
gateway.storage-pack-size-required |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('readRecordsCount').hasError('min')" translate> |
|||
gateway.storage-pack-size-min |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('readRecordsCount').hasError('pattern')" translate> |
|||
gateway.storage-pack-size-pattern |
|||
</mat-error> |
|||
</mat-form-field> |
|||
|
|||
<mat-form-field fxFlex> |
|||
<mat-label > |
|||
{{ (gatewayConfigurationGroup.get('storageType').value !== 'file' ? 'gateway.storage-max-records' : 'gateway.storage-max-file-records') | translate}} |
|||
</mat-label> |
|||
<input matInput type="number" formControlName="maxRecordsCount"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxRecordsCount').hasError('required')" translate> |
|||
gateway.storage-max-records-required |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxRecordsCount').hasError('min')" translate> |
|||
gateway.storage-max-records-min |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxRecordsCount').hasError('pattern')" translate> |
|||
gateway.storage-max-records-pattern |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
|
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap" *ngIf="gatewayConfigurationGroup.get('storageType').value == 'file'"> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.storage-max-files' | translate }}</mat-label> |
|||
<input matInput type="number" formControlName="maxFilesCount"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxFilesCount').hasError('required')" translate> |
|||
gateway.storage-max-files-required |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxFilesCount').hasError('min')" translate> |
|||
gateway.storage-max-files-min |
|||
</mat-error> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('maxFilesCount').hasError('pattern')" translate> |
|||
gateway.storage-max-files-pattern |
|||
</mat-error> |
|||
</mat-form-field> |
|||
|
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.storage-path' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="dataFolderPath"> |
|||
<mat-error *ngIf="gatewayConfigurationGroup.get('dataFolderPath').hasError('required')" translate> |
|||
gateway.storage-path-required |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</mat-expansion-panel> |
|||
|
|||
<mat-expansion-panel> |
|||
<mat-expansion-panel-header> |
|||
<mat-panel-title> |
|||
<div class="tb-panel-title">{{ 'gateway.connectors' | translate | uppercase }}</div> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
|
|||
<div fxLayout="column" class="gateway-config"> |
|||
<section formArrayName="connectors" *ngFor="let connector of connectors.controls; let i = index;"> |
|||
<div [formGroupName]="i" fxLayout="row" fxLayoutAlign="space-between stretch" fxLayoutGap="8px"> |
|||
<div fxLayout="column" fxLayoutAlign="center start"> |
|||
<mat-slide-toggle formControlName="enabled"></mat-slide-toggle> |
|||
</div> |
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap" fxFlex> |
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{'gateway.connector-type' | translate }}</mat-label> |
|||
<mat-select formControlName="configType" (selectionChange)="changeConnectorType(connector)"> |
|||
<mat-option *ngFor="let connectorType of connectorTypes" [value]="connectorType"> |
|||
{{ connectorType }} |
|||
</mat-option> |
|||
</mat-select> |
|||
<mat-error *ngIf="connector.get('configType').hasError('required')" translate> |
|||
gateway.connector-type-required |
|||
</mat-error> |
|||
</mat-form-field> |
|||
|
|||
<mat-form-field fxFlex> |
|||
<mat-label>{{ 'gateway.connector-name' | translate }}</mat-label> |
|||
<input matInput type="text" formControlName="name" (blur)="changeConnectorName(connector, i)"> |
|||
<mat-error *ngIf="connector.get('name').hasError('required')" translate> |
|||
gateway.connector-name-required |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
<div [fxLayout]="alignment" [fxLayoutGap]="layoutGap" |
|||
fxLayoutAlign="{{alignment == 'row' ? 'end center' : 'space-evenly center'}}" class="action-buttons"> |
|||
<button mat-icon-button (click)="openConfigDialog($event, i, connector.get('config').value, connector.get('name').value)" |
|||
matTooltip="{{ 'gateway.update-config' | translate }}" |
|||
matTooltipPosition="above" |
|||
[ngClass]="{'mat-warn': connector.get('config').invalid}"> |
|||
<mat-icon>more_horiz</mat-icon> |
|||
</button> |
|||
<button mat-icon-button (click)="removeConnector(i)" |
|||
matTooltip="{{ 'gateway.delete' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
<span [fxShow]="!connectors.length" fxLayoutAlign="center center" class="no-data-found">{{'gateway.no-connectors' | translate}}</span> |
|||
<div> |
|||
<button mat-raised-button type="button" (click)="addNewConnector()" |
|||
matTooltip="{{ 'gateway.connector-add' | translate }}" |
|||
matTooltipPosition="above"> |
|||
{{ 'action.add' | translate }} |
|||
</button> |
|||
</div> |
|||
</div > |
|||
</mat-expansion-panel> |
|||
</mat-accordion> |
|||
<section fxLayout="row" fxLayoutAlign="end center" class="form-action-buttons"> |
|||
<button mat-raised-button color="primary" type="button" |
|||
(click)="exportConfig()" |
|||
*ngIf="!gatewayConfigurationGroup.get('remoteConfiguration').value" |
|||
[disabled]="!gatewayConfigurationGroup.dirty || gatewayConfigurationGroup.invalid" |
|||
matTooltip="{{'gateway.download-tip' | translate }}"> |
|||
{{'action.download' | translate }} |
|||
</button> |
|||
|
|||
<button mat-raised-button color="primary" type="submit" |
|||
*ngIf="gatewayConfigurationGroup.get('remoteConfiguration').value" |
|||
[disabled]="!gatewayConfigurationGroup.dirty || gatewayConfigurationGroup.invalid" |
|||
matTooltip="{{'gateway.save-tip' | translate }}"> |
|||
{{'action.save' | translate }} |
|||
</button> |
|||
</section> |
|||
</form> |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host{ |
|||
.gateway-form { |
|||
height: 100%; |
|||
padding: 5px; |
|||
background-color: transparent; |
|||
overflow-y: auto; |
|||
overflow-x: hidden; |
|||
|
|||
.form-action-buttons{ |
|||
padding-top: 8px; |
|||
} |
|||
|
|||
.gateway-config { |
|||
.no-data-found { |
|||
position: relative; |
|||
display: flex; |
|||
height: 40px; |
|||
text-transform: uppercase; |
|||
} |
|||
} |
|||
} |
|||
|
|||
::ng-deep .mat-form-field-infix { |
|||
width: auto !important; |
|||
} |
|||
} |
|||
@ -0,0 +1,408 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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, ElementRef, Inject, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { AbstractControl, FormArray, FormBuilder, FormGroup, NgForm, Validators } from '@angular/forms'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { |
|||
CONFIGURATION_ATTRIBUTE, |
|||
CONFIGURATION_DRAFT_ATTRIBUTE, |
|||
ConnectorType, |
|||
createFormConfig, |
|||
CURRENT_CONFIGURATION_ATTRIBUTE, |
|||
DEFAULT_CONNECTOR, |
|||
gatewayConfigJSON, |
|||
GatewayFormConnectorModel, |
|||
GatewayFormModels, |
|||
GatewayLogLevel, |
|||
generateConnectorConfigFiles, |
|||
generateLogConfigFile, |
|||
generateYAMLConfigFile, |
|||
getDraftConnectorsJSON, |
|||
getEntityId, |
|||
REMOTE_LOGGING_LEVEL_ATTRIBUTE, |
|||
SecurityType, |
|||
SecurityTypeTranslationMap, |
|||
StorageType, |
|||
StorageTypeTranslationMap, |
|||
ValidateJSON, |
|||
WidgetSetting |
|||
} from './gateway-form.models'; |
|||
import { WINDOW } from '@core/services/window.service'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { |
|||
JsonObjectEditDialogComponent, |
|||
JsonObjectEditDialogData |
|||
} from '@shared/components/dialog/json-object-edit-dialog.component'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { DeviceService } from '@core/http/device.service'; |
|||
import { AttributeService } from '@core/http/attribute.service'; |
|||
import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
|||
import { forkJoin, Observable } from 'rxjs'; |
|||
import { tap } from 'rxjs/operators'; |
|||
import { ImportExportService } from '@home/components/import-export/import-export.service'; |
|||
|
|||
// @dynamic
|
|||
@Component({ |
|||
selector: 'tb-gateway-form', |
|||
templateUrl: './gateway-form.component.html', |
|||
styleUrls: ['./gateway-form.component.scss'] |
|||
}) |
|||
|
|||
export class GatewayFormComponent extends PageComponent implements OnInit, OnDestroy { |
|||
constructor( |
|||
protected store: Store<AppState>, |
|||
private elementRef: ElementRef, |
|||
private utils: UtilsService, |
|||
private ngZone: NgZone, |
|||
private fb: FormBuilder, |
|||
@Inject(WINDOW) private window: Window, |
|||
private dialog: MatDialog, |
|||
private translate: TranslateService, |
|||
private deviceService: DeviceService, |
|||
private attributeService: AttributeService, |
|||
private importExport: ImportExportService |
|||
) { |
|||
super(store); |
|||
} |
|||
|
|||
get connectors(): FormArray { |
|||
return this.gatewayConfigurationGroup.get('connectors') as FormArray; |
|||
} |
|||
|
|||
@ViewChild('formContainer', {static: true}) formContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('gatewayConfigurationForm', {static: true}) multipleInputForm: NgForm; |
|||
|
|||
private successfulSaved: string; |
|||
private gatewayNameExists: string; |
|||
private archiveFileName: string; |
|||
private formResizeListener: any; |
|||
private subscribeStorageType$: any; |
|||
private subscribeGateway$: any; |
|||
|
|||
alignment = 'row'; |
|||
layoutGap = '5px'; |
|||
gatewayType: string; |
|||
gatewayConfigurationGroup: FormGroup; |
|||
securityTypes = SecurityTypeTranslationMap; |
|||
gatewayLogLevels = Object.values(GatewayLogLevel); |
|||
connectorTypes = Object.keys(ConnectorType); |
|||
storageTypes = StorageTypeTranslationMap; |
|||
|
|||
toastTargetId = 'gateway-configuration-widget' + this.utils.guid(); |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
ngOnInit(): void { |
|||
this.initWidgetSettings(this.ctx.settings); |
|||
|
|||
this.buildForm(); |
|||
this.ctx.updateWidgetParams(); |
|||
this.formResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.formResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); |
|||
} |
|||
this.subscribeGateway$.unsubscribe(); |
|||
this.subscribeStorageType$.unsubscribe(); |
|||
} |
|||
|
|||
private initWidgetSettings(settings: WidgetSetting): void { |
|||
let widgetTitle; |
|||
if (settings.widgetTitle && settings.widgetTitle.length) { |
|||
widgetTitle = this.utils.customTranslation(settings.widgetTitle, settings.widgetTitle); |
|||
} else { |
|||
widgetTitle = this.translate.instant('gateway.gateway'); |
|||
} |
|||
this.ctx.widgetTitle = widgetTitle; |
|||
|
|||
this.archiveFileName = settings.archiveFileName?.length ? settings.archiveFileName : 'gatewayConfiguration'; |
|||
this.gatewayType = settings.gatewayType?.length ? settings.gatewayType : 'Gateway'; |
|||
this.gatewayNameExists = this.utils.customTranslation(settings.gatewayNameExists, settings.gatewayNameExists) || this.translate.instant('gateway.gateway-exists'); |
|||
this.successfulSaved = this.utils.customTranslation(settings.successfulSave, settings.successfulSave) || this.translate.instant('gateway.gateway-saved'); |
|||
|
|||
this.updateWidgetDisplaying(); |
|||
} |
|||
|
|||
private resize(): void { |
|||
this.ngZone.run(() => { |
|||
this.updateWidgetDisplaying(); |
|||
this.ctx.detectChanges(); |
|||
}); |
|||
} |
|||
|
|||
private updateWidgetDisplaying(): void { |
|||
if(this.ctx.$container && this.ctx.$container[0].offsetWidth <= 425){ |
|||
this.layoutGap = '0'; |
|||
this.alignment = 'column'; |
|||
} else { |
|||
this.layoutGap = '5px'; |
|||
this.alignment = 'row'; |
|||
} |
|||
} |
|||
|
|||
private saveAttribute(attributeName: string, attributeValue: string, attributeScope: AttributeScope): Observable<any> { |
|||
const gatewayId = this.gatewayConfigurationGroup.get('gateway').value; |
|||
const attributes: AttributeData = { |
|||
key: attributeName, |
|||
value: attributeValue |
|||
}; |
|||
return this.attributeService.saveEntityAttributes(getEntityId(gatewayId), attributeScope, [attributes]); |
|||
} |
|||
|
|||
private createConnector(setting: GatewayFormConnectorModel = DEFAULT_CONNECTOR): void { |
|||
this.connectors.push(this.fb.group({ |
|||
enabled: [setting.enabled], |
|||
configType: [setting.configType, [Validators.required]], |
|||
name: [setting.name, [Validators.required]], |
|||
config: [setting.config, [Validators.nullValidator, ValidateJSON]] |
|||
})); |
|||
} |
|||
|
|||
private getFormField(name: string): AbstractControl { |
|||
return this.gatewayConfigurationGroup.get(name); |
|||
} |
|||
|
|||
private buildForm(): void { |
|||
this.gatewayConfigurationGroup = this.fb.group({ |
|||
gateway: [null], |
|||
accessToken: [null, [Validators.required]], |
|||
securityType: [SecurityType.accessToken], |
|||
host: [this.window.location.hostname, [Validators.required]], |
|||
port: [1883, [Validators.required, Validators.min(1), Validators.max(65535), Validators.pattern(/^-?[0-9]+$/)]], |
|||
remoteConfiguration: [true], |
|||
caCertPath: ['/etc/thingsboard-gateway/ca.pem'], |
|||
privateKeyPath: ['/etc/thingsboard-gateway/privateKey.pem'], |
|||
certPath: ['/etc/thingsboard-gateway/certificate.pem'], |
|||
remoteLoggingLevel: [GatewayLogLevel.debug], |
|||
remoteLoggingPathToLogs: ['./logs/', [Validators.required]], |
|||
storageType: [StorageType.memory], |
|||
readRecordsCount: [100, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], |
|||
maxRecordsCount: [10000, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], |
|||
maxFilesCount: [5, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], |
|||
dataFolderPath: ['./data/', [Validators.required]], |
|||
connectors: this.fb.array([]) |
|||
}); |
|||
|
|||
this.subscribeStorageType$ = this.getFormField('storageType').valueChanges.subscribe((value: StorageType) => { |
|||
if (value === StorageType.memory) { |
|||
this.getFormField('maxFilesCount').disable(); |
|||
this.getFormField('dataFolderPath').disable(); |
|||
} else { |
|||
this.getFormField('maxFilesCount').enable(); |
|||
this.getFormField('dataFolderPath').enable(); |
|||
} |
|||
}); |
|||
|
|||
this.subscribeGateway$ = this.getFormField('gateway').valueChanges.subscribe((gatewayId: string) => { |
|||
if (gatewayId !== null) { |
|||
forkJoin([ |
|||
this.deviceService.getDeviceCredentials(gatewayId).pipe(tap(deviceCredential => { |
|||
this.getFormField('accessToken').patchValue(deviceCredential.credentialsId); |
|||
})), |
|||
...this.getAttributes(gatewayId)]).subscribe(() => { |
|||
this.gatewayConfigurationGroup.markAsPristine(); |
|||
this.ctx.detectChanges(); |
|||
}); |
|||
} else { |
|||
this.getFormField('accessToken').patchValue(''); |
|||
} |
|||
}) |
|||
} |
|||
|
|||
gatewayExist(): void { |
|||
this.ctx.showErrorToast(this.gatewayNameExists, 'top', 'left', this.toastTargetId); |
|||
} |
|||
|
|||
exportConfig(): void { |
|||
const gatewayConfiguration: GatewayFormModels = this.gatewayConfigurationGroup.value; |
|||
const filesZip: any = {}; |
|||
filesZip['tb_gateway.yaml'] = generateYAMLConfigFile(gatewayConfiguration); |
|||
generateConnectorConfigFiles(filesZip, gatewayConfiguration.connectors); |
|||
generateLogConfigFile(filesZip, gatewayConfiguration.remoteLoggingLevel, gatewayConfiguration.remoteLoggingPathToLogs); |
|||
this.importExport.exportJSZip(filesZip, this.archiveFileName); |
|||
this.saveAttribute( |
|||
REMOTE_LOGGING_LEVEL_ATTRIBUTE, |
|||
this.gatewayConfigurationGroup.value.remoteLoggingLevel.toUpperCase(), |
|||
AttributeScope.SHARED_SCOPE); |
|||
} |
|||
|
|||
addNewConnector(): void { |
|||
this.createConnector(); |
|||
} |
|||
|
|||
removeConnector(index: number): void { |
|||
if (index > -1) { |
|||
this.connectors.removeAt(index); |
|||
this.connectors.markAsDirty(); |
|||
} |
|||
} |
|||
|
|||
openConfigDialog($event: Event, index: number, config: object, type: string): void { |
|||
if ($event) { |
|||
$event.stopPropagation(); |
|||
$event.preventDefault(); |
|||
} |
|||
this.dialog.open<JsonObjectEditDialogComponent, JsonObjectEditDialogData, object>(JsonObjectEditDialogComponent, { |
|||
disableClose: true, |
|||
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
|||
data: { |
|||
jsonValue: config, |
|||
title: this.translate.instant('gateway.title-connectors-json', {typeName: type}) |
|||
} |
|||
}).afterClosed().subscribe( |
|||
(res) => { |
|||
if (res) { |
|||
this.connectors.at(index).get('config').patchValue(res); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
private createConnectorName(connectors: GatewayFormConnectorModel[], name: string, index: number = 0): string { |
|||
const newKeyName = index ? name + index : name; |
|||
const indexRes = connectors.findIndex((element) => element.name === newKeyName); |
|||
return indexRes === -1 ? newKeyName : this.createConnectorName(connectors, name, ++index); |
|||
} |
|||
|
|||
private validateConnectorName(connectors: GatewayFormConnectorModel[], name: string, connectorIndex: number, index = 0): string { |
|||
for (let i = 0; i < connectors.length; i++) { |
|||
const nameEq = (index === 0) ? name : name + index; |
|||
if (i !== connectorIndex && connectors[i].name === nameEq) { |
|||
this.validateConnectorName(connectors, name, connectorIndex, ++index); |
|||
} |
|||
} |
|||
return (index === 0) ? name : name + index; |
|||
} |
|||
|
|||
changeConnectorType(connector: AbstractControl): void { |
|||
if (!connector.get('name').value) { |
|||
const typeConnector = connector.get('configType').value; |
|||
const connectors = this.gatewayConfigurationGroup.value.connectors; |
|||
connector.get('name').patchValue(this.createConnectorName(connectors, ConnectorType[typeConnector])); |
|||
} |
|||
} |
|||
|
|||
changeConnectorName(connector: AbstractControl, index: number): void { |
|||
const connectors = this.gatewayConfigurationGroup.value.connectors; |
|||
connector.get('name').patchValue(this.validateConnectorName(connectors, connector.get('name').value, index)); |
|||
} |
|||
|
|||
save(): void { |
|||
const gatewayConfiguration: GatewayFormModels = this.gatewayConfigurationGroup.value; |
|||
forkJoin([ |
|||
this.saveAttribute( |
|||
CONFIGURATION_ATTRIBUTE, |
|||
window.btoa(JSON.stringify(gatewayConfigJSON(gatewayConfiguration))), |
|||
AttributeScope.SHARED_SCOPE), |
|||
this.saveAttribute( |
|||
CONFIGURATION_DRAFT_ATTRIBUTE, |
|||
window.btoa(JSON.stringify(getDraftConnectorsJSON(gatewayConfiguration.connectors))), |
|||
AttributeScope.SERVER_SCOPE), |
|||
this.saveAttribute( |
|||
REMOTE_LOGGING_LEVEL_ATTRIBUTE, |
|||
this.gatewayConfigurationGroup.value.remoteLoggingLevel.toUpperCase(), |
|||
AttributeScope.SHARED_SCOPE) |
|||
]).subscribe(() =>{ |
|||
this.ctx.showSuccessToast(this.successfulSaved, |
|||
2000, 'top', 'left', this.toastTargetId); |
|||
this.gatewayConfigurationGroup.markAsPristine(); |
|||
}) |
|||
} |
|||
|
|||
private getAttributes(gatewayId: string): Array<Observable<Array<AttributeData>>> { |
|||
const tasks = []; |
|||
tasks.push(forkJoin([this.getAttribute(CURRENT_CONFIGURATION_ATTRIBUTE, AttributeScope.CLIENT_SCOPE, gatewayId), |
|||
this.getAttribute(CONFIGURATION_DRAFT_ATTRIBUTE, AttributeScope.SERVER_SCOPE, gatewayId)]).pipe( |
|||
tap(([currentConfig, draftConfig]) => { |
|||
this.setFormGatewaySettings(currentConfig); |
|||
this.setFormConnectorsDraft(draftConfig); |
|||
}) |
|||
) |
|||
); |
|||
tasks.push(this.getAttribute(REMOTE_LOGGING_LEVEL_ATTRIBUTE, AttributeScope.SHARED_SCOPE, gatewayId).pipe( |
|||
tap(logsLevel => this.processLoggingLevel(logsLevel)) |
|||
)); |
|||
return tasks; |
|||
} |
|||
|
|||
private getAttribute(attributeName: string, attributeScope: AttributeScope, gatewayId: string): Observable<Array<AttributeData>> { |
|||
return this.attributeService.getEntityAttributes(getEntityId(gatewayId), attributeScope, [attributeName]); |
|||
} |
|||
|
|||
private setFormGatewaySettings(response: Array<AttributeData>): void { |
|||
this.connectors.clear(); |
|||
if (response.length > 0) { |
|||
const attribute = JSON.parse(window.atob(response[0].value)); |
|||
for (const attributeKey of Object.keys(attribute)) { |
|||
const keyValue = attribute[attributeKey]; |
|||
if (attributeKey === 'thingsboard') { |
|||
if (keyValue !== null && Object.keys(keyValue).length > 0) { |
|||
this.gatewayConfigurationGroup.patchValue(createFormConfig(keyValue)); |
|||
} |
|||
} else { |
|||
for (const connector of Object.keys(keyValue)) { |
|||
let name = 'No name'; |
|||
if (Object.prototype.hasOwnProperty.call(keyValue[connector], 'name')) { |
|||
name = keyValue[connector].name; |
|||
} |
|||
const newConnector: GatewayFormConnectorModel = { |
|||
enabled: true, |
|||
configType: (attributeKey as ConnectorType), |
|||
config: keyValue[connector].config, |
|||
name |
|||
}; |
|||
this.createConnector(newConnector); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private setFormConnectorsDraft(response: Array<AttributeData>): void { |
|||
if (response.length > 0) { |
|||
const attribute = JSON.parse(window.atob(response[0].value)); |
|||
for (const connectorName of Object.keys(attribute)) { |
|||
const newConnector: GatewayFormConnectorModel = { |
|||
enabled: false, |
|||
configType: (attribute[connectorName].connector as ConnectorType), |
|||
config: attribute[connectorName].config, |
|||
name: connectorName |
|||
}; |
|||
this.createConnector(newConnector); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private processLoggingLevel(value: Array<AttributeData>): void { |
|||
let logsLevel = GatewayLogLevel.debug; |
|||
if (value.length > 0 && GatewayLogLevel[value[0].value.toLowerCase()]) { |
|||
logsLevel = GatewayLogLevel[value[0].value.toLowerCase()]; |
|||
} |
|||
this.getFormField('remoteLoggingLevel').patchValue(logsLevel); |
|||
} |
|||
} |
|||
@ -0,0 +1,374 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 { AbstractControl, ValidationErrors } from '@angular/forms'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
|
|||
export enum SecurityType { |
|||
tls = 'tls', |
|||
accessToken = 'accessToken' |
|||
} |
|||
|
|||
export interface WidgetSetting { |
|||
widgetTitle: string; |
|||
archiveFileName: string; |
|||
gatewayType: string; |
|||
successfulSave: string; |
|||
gatewayNameExists: string; |
|||
} |
|||
|
|||
export const CURRENT_CONFIGURATION_ATTRIBUTE = 'current_configuration'; |
|||
export const CONFIGURATION_DRAFT_ATTRIBUTE = 'configuration_drafts'; |
|||
export const CONFIGURATION_ATTRIBUTE = 'configuration'; |
|||
export const REMOTE_LOGGING_LEVEL_ATTRIBUTE = 'RemoteLoggingLevel'; |
|||
|
|||
export const SecurityTypeTranslationMap = new Map<SecurityType, string>( |
|||
[ |
|||
[SecurityType.tls, 'gateway.security-types.tls'], |
|||
[SecurityType.accessToken, 'gateway.security-types.access-token'] |
|||
] |
|||
); |
|||
|
|||
export enum GatewayLogLevel { |
|||
none = 'NONE', |
|||
critical = 'CRITICAL', |
|||
error = 'ERROR', |
|||
warning = 'WARNING', |
|||
info = 'INFO', |
|||
debug = 'DEBUG' |
|||
} |
|||
|
|||
export enum StorageType { |
|||
memory = 'memory', |
|||
file = 'file' |
|||
} |
|||
|
|||
export const StorageTypeTranslationMap = new Map<StorageType, string>( |
|||
[ |
|||
[StorageType.memory, 'gateway.storage-types.memory-storage'], |
|||
[StorageType.file, 'gateway.storage-types.file-storage'] |
|||
] |
|||
); |
|||
|
|||
export enum ConnectorType { |
|||
mqtt= 'MQTT', |
|||
modbus = 'Modbus', |
|||
opc_ua = 'OPC-UA', |
|||
ble = 'BLE' |
|||
} |
|||
|
|||
export interface GatewayFormModels { |
|||
gateway?: string; |
|||
accessToken?: string; |
|||
securityType?: SecurityType; |
|||
host?: string; |
|||
port?: number; |
|||
remoteConfiguration?: boolean; |
|||
caCertPath?: string; |
|||
privateKeyPath?: string; |
|||
certPath?: string; |
|||
remoteLoggingLevel?: GatewayLogLevel; |
|||
remoteLoggingPathToLogs?:string; |
|||
storageType?: StorageType; |
|||
readRecordsCount?: number; |
|||
maxRecordsCount?: number; |
|||
maxFilesCount?: number; |
|||
dataFolderPath?: number; |
|||
connectors?: Array<GatewayFormConnectorModel>; |
|||
} |
|||
|
|||
export interface GatewayFormConnectorModel { |
|||
config: object; |
|||
name: string; |
|||
configType: ConnectorType; |
|||
enabled: boolean; |
|||
} |
|||
|
|||
export const DEFAULT_CONNECTOR: GatewayFormConnectorModel = { |
|||
config: {}, |
|||
name: '', |
|||
configType: null, |
|||
enabled: false |
|||
}; |
|||
|
|||
type Connector = { |
|||
[key in ConnectorType]?: Array<ConnectorConfig>; |
|||
} |
|||
|
|||
interface GatewaySetting extends Connector{ |
|||
thingsboard: GatewayMainSetting; |
|||
} |
|||
|
|||
interface ConnectorConfig { |
|||
name: string; |
|||
config: object; |
|||
} |
|||
|
|||
interface GatewayMainSetting { |
|||
thingsboard: GatewayMainThingsboardSetting; |
|||
connectors: Array<GatewayMainConnector>, |
|||
logs: string, |
|||
storage: GatewayStorage |
|||
} |
|||
|
|||
interface GatewayMainThingsboardSetting { |
|||
host: string, |
|||
remoteConfiguration: boolean, |
|||
port: number, |
|||
security: GatewaySecurity |
|||
} |
|||
|
|||
type GatewaySecurity = SecurityToken | SecurityCertificate; |
|||
|
|||
interface SecurityToken { |
|||
accessToken: string; |
|||
} |
|||
|
|||
interface SecurityCertificate { |
|||
caCert: string; |
|||
privateKey: string; |
|||
cert: string; |
|||
} |
|||
|
|||
type GatewayStorage = GatewayStorageMemory | GatewayStorageFile; |
|||
|
|||
interface GatewayStorageMemory { |
|||
type: string; |
|||
max_records_count: number; |
|||
read_records_count: number; |
|||
} |
|||
|
|||
interface GatewayStorageFile { |
|||
type: string; |
|||
data_folder_path: number; |
|||
max_file_count: number; |
|||
max_read_records_count: number; |
|||
max_records_per_file: number; |
|||
} |
|||
|
|||
interface GatewayMainConnector { |
|||
configuration: string; |
|||
name: string; |
|||
type: ConnectorType; |
|||
} |
|||
|
|||
export function ValidateJSON(control: AbstractControl): ValidationErrors | null { |
|||
if (JSON.stringify(control.value) === JSON.stringify({})) { |
|||
return { validJSON: true }; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
const TEMPLATE_LOGS_CONFIG = '[loggers]}}keys=root, service, connector, converter, tb_connection, storage, extension}}[handlers]}}keys=consoleHandler, serviceHandler, connectorHandler, converterHandler, tb_connectionHandler, storageHandler, extensionHandler}}[formatters]}}keys=LogFormatter}}[logger_root]}}level=ERROR}}handlers=consoleHandler}}[logger_connector]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=connector}}[logger_storage]}}level={ERROR}}}handlers=storageHandler}}formatter=LogFormatter}}qualname=storage}}[logger_tb_connection]}}level={ERROR}}}handlers=tb_connectionHandler}}formatter=LogFormatter}}qualname=tb_connection}}[logger_service]}}level={ERROR}}}handlers=serviceHandler}}formatter=LogFormatter}}qualname=service}}[logger_converter]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=converter}}[logger_extension]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=extension}}[handler_consoleHandler]}}class=StreamHandler}}level={ERROR}}}formatter=LogFormatter}}args=(sys.stdout,)}}[handler_connectorHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}connector.log", "d", 1, 7,)}}[handler_storageHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}storage.log", "d", 1, 7,)}}[handler_serviceHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}service.log", "d", 1, 7,)}}[handler_converterHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}converter.log", "d", 1, 3,)}}[handler_extensionHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}extension.log", "d", 1, 3,)}}[handler_tb_connectionHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}tb_connection.log", "d", 1, 3,)}}[formatter_LogFormatter]}}format="%(asctime)s - %(levelname)s - [%(filename)s] - %(module)s - %(lineno)d - %(message)s" }}datefmt="%Y-%m-%d %H:%M:%S"'; |
|||
|
|||
export function generateYAMLConfigFile(gatewaySetting: GatewayFormModels): string { |
|||
let config; |
|||
config = 'thingsboard:\n'; |
|||
config += ' host: ' + gatewaySetting.host + '\n'; |
|||
config += ' remoteConfiguration: ' + gatewaySetting.remoteConfiguration + '\n'; |
|||
config += ' port: ' + gatewaySetting.port + '\n'; |
|||
config += ' security:\n'; |
|||
if (gatewaySetting.securityType === SecurityType.accessToken) { |
|||
config += ' access-token: ' + gatewaySetting.accessToken + '\n'; |
|||
} else { |
|||
config += ' ca_cert: ' + gatewaySetting.caCertPath + '\n'; |
|||
config += ' privateKey: ' + gatewaySetting.privateKeyPath + '\n'; |
|||
config += ' cert: ' + gatewaySetting.certPath + '\n'; |
|||
} |
|||
config += 'storage:\n'; |
|||
if (gatewaySetting.storageType === StorageType.memory) { |
|||
config += ' type: memory\n'; |
|||
config += ' read_records_count: ' + gatewaySetting.readRecordsCount + '\n'; |
|||
config += ' max_records_count: ' + gatewaySetting.maxRecordsCount + '\n'; |
|||
} else { |
|||
config += ' type: file\n'; |
|||
config += ' data_folder_path: ' + gatewaySetting.dataFolderPath + '\n'; |
|||
config += ' max_file_count: ' + gatewaySetting.maxFilesCount + '\n'; |
|||
config += ' max_read_records_count: ' + gatewaySetting.readRecordsCount + '\n'; |
|||
config += ' max_records_per_file: ' + gatewaySetting.maxRecordsCount + '\n'; |
|||
} |
|||
config += 'connectors:\n'; |
|||
for(const connector of gatewaySetting.connectors){ |
|||
if (connector.enabled) { |
|||
config += ' -\n'; |
|||
config += ' name: ' + connector.name + '\n'; |
|||
config += ' type: ' + connector.configType + '\n'; |
|||
config += ' configuration: ' + generateFileName(connector.name) + '\n'; |
|||
} |
|||
} |
|||
return config; |
|||
} |
|||
|
|||
export function generateConnectorConfigFiles(fileZipAdd: object, connectors: Array<GatewayFormConnectorModel>): void { |
|||
for(const connector of connectors) { |
|||
if (connector.enabled) { |
|||
fileZipAdd[generateFileName(connector.name)] = JSON.stringify(connector.config); |
|||
} |
|||
} |
|||
} |
|||
|
|||
function generateFileName(fileName): string { |
|||
return fileName.replace('_', '') |
|||
.replace('-', '') |
|||
.replace(/^\s+|\s+/g, '') |
|||
.toLowerCase() + '.json'; |
|||
} |
|||
|
|||
export function generateLogConfigFile(fileZipAdd: object, logsLevel: string, logsPath: string): void { |
|||
fileZipAdd['logs.conf'] = getLogsConfig(logsLevel, logsPath); |
|||
} |
|||
|
|||
function getLogsConfig(logsLevel: string, logsPath: string): string { |
|||
return TEMPLATE_LOGS_CONFIG |
|||
.replace(/{ERROR}/g, logsLevel) |
|||
.replace(/{.\/logs\/}/g, logsPath); |
|||
} |
|||
|
|||
export function getEntityId(gatewayId: string): EntityId { |
|||
return { |
|||
id: gatewayId, |
|||
entityType: EntityType.DEVICE |
|||
} |
|||
} |
|||
|
|||
export function createFormConfig(keyValue: GatewayMainSetting): GatewayFormModels { |
|||
const formSetting: GatewayFormModels = {}; |
|||
if (Object.prototype.hasOwnProperty.call(keyValue, 'thingsboard')) { |
|||
formSetting.host = keyValue.thingsboard.host; |
|||
formSetting.port = keyValue.thingsboard.port; |
|||
formSetting.remoteConfiguration = keyValue.thingsboard.remoteConfiguration; |
|||
if (Object.prototype.hasOwnProperty.call(keyValue.thingsboard.security, SecurityType.accessToken)) { |
|||
formSetting.securityType = SecurityType.accessToken; |
|||
formSetting.accessToken = (keyValue.thingsboard.security as SecurityToken).accessToken; |
|||
} else { |
|||
formSetting.securityType = SecurityType.tls; |
|||
formSetting.caCertPath = (keyValue.thingsboard.security as SecurityCertificate).caCert; |
|||
formSetting.privateKeyPath = (keyValue.thingsboard.security as SecurityCertificate).privateKey; |
|||
formSetting.certPath = (keyValue.thingsboard.security as SecurityCertificate).cert; |
|||
} |
|||
} |
|||
|
|||
if (Object.prototype.hasOwnProperty.call(keyValue, 'storage') && Object.prototype.hasOwnProperty.call(keyValue.storage, 'type')) { |
|||
if (keyValue.storage.type === StorageType.memory) { |
|||
formSetting.storageType = StorageType.memory; |
|||
formSetting.readRecordsCount = (keyValue.storage as GatewayStorageMemory).read_records_count; |
|||
formSetting.maxRecordsCount = (keyValue.storage as GatewayStorageMemory).max_records_count; |
|||
} else if (keyValue.storage.type === StorageType.file) { |
|||
formSetting.storageType = StorageType.file; |
|||
formSetting.dataFolderPath = (keyValue.storage as GatewayStorageFile).data_folder_path; |
|||
formSetting.maxFilesCount = (keyValue.storage as GatewayStorageFile).max_file_count; |
|||
formSetting.readRecordsCount = (keyValue.storage as GatewayStorageMemory).read_records_count; |
|||
formSetting.maxRecordsCount = (keyValue.storage as GatewayStorageMemory).max_records_count; |
|||
} |
|||
} |
|||
return formSetting; |
|||
} |
|||
|
|||
export function getDraftConnectorsJSON(currentConnectors: Array<GatewayFormConnectorModel>) { |
|||
const draftConnectors = {}; |
|||
for(const connector of currentConnectors){ |
|||
if (!connector.enabled) { |
|||
draftConnectors[connector.name] = { |
|||
connector: connector.configType, |
|||
config: connector.config |
|||
}; |
|||
} |
|||
} |
|||
return draftConnectors; |
|||
} |
|||
|
|||
export function gatewayConfigJSON(gatewayConfiguration: GatewayFormModels): GatewaySetting { |
|||
const gatewayConfig = { |
|||
thingsboard: gatewayMainConfigJSON(gatewayConfiguration) |
|||
}; |
|||
gatewayConnectorJSON(gatewayConfig, gatewayConfiguration.connectors); |
|||
return gatewayConfig; |
|||
} |
|||
|
|||
function gatewayMainConfigJSON(gatewayConfiguration: GatewayFormModels): GatewayMainSetting { |
|||
let security: GatewaySecurity; |
|||
if (gatewayConfiguration.securityType === SecurityType.accessToken) { |
|||
security = { |
|||
accessToken: gatewayConfiguration.accessToken |
|||
} |
|||
} else { |
|||
security = { |
|||
caCert: gatewayConfiguration.caCertPath, |
|||
privateKey: gatewayConfiguration.privateKeyPath, |
|||
cert: gatewayConfiguration.certPath |
|||
} |
|||
} |
|||
const thingsboard: GatewayMainThingsboardSetting = { |
|||
host: gatewayConfiguration.host, |
|||
remoteConfiguration: gatewayConfiguration.remoteConfiguration, |
|||
port: gatewayConfiguration.port, |
|||
security |
|||
}; |
|||
|
|||
let storage: GatewayStorage; |
|||
if (gatewayConfiguration.storageType === StorageType.memory) { |
|||
storage = { |
|||
type: StorageType.memory, |
|||
read_records_count: gatewayConfiguration.readRecordsCount, |
|||
max_records_count: gatewayConfiguration.maxRecordsCount |
|||
}; |
|||
} else { |
|||
storage = { |
|||
type: StorageType.file, |
|||
data_folder_path: gatewayConfiguration.dataFolderPath, |
|||
max_file_count: gatewayConfiguration.maxFilesCount, |
|||
max_read_records_count: gatewayConfiguration.readRecordsCount, |
|||
max_records_per_file: gatewayConfiguration.maxRecordsCount |
|||
}; |
|||
} |
|||
|
|||
const connectors: Array<GatewayMainConnector> = []; |
|||
for (const connector of gatewayConfiguration.connectors) { |
|||
if (connector.enabled) { |
|||
const connectorConfig: GatewayMainConnector = { |
|||
configuration: generateFileName(connector.name), |
|||
name: connector.name, |
|||
type: connector.configType |
|||
}; |
|||
connectors.push(connectorConfig); |
|||
} |
|||
} |
|||
|
|||
return { |
|||
thingsboard, |
|||
connectors, |
|||
storage, |
|||
logs: window.btoa(getLogsConfig(gatewayConfiguration.remoteLoggingLevel, gatewayConfiguration.remoteLoggingPathToLogs)) |
|||
} |
|||
} |
|||
|
|||
function gatewayConnectorJSON(gatewayConfiguration, currentConnectors: Array<GatewayFormConnectorModel>): void { |
|||
for (const connector of currentConnectors) { |
|||
if (connector.enabled) { |
|||
const typeConnector = connector.configType; |
|||
if (!Array.isArray(gatewayConfiguration[typeConnector])) { |
|||
gatewayConfiguration[typeConnector] = []; |
|||
} |
|||
|
|||
const connectorConfig: ConnectorConfig = { |
|||
name: connector.name, |
|||
config: connector.config |
|||
}; |
|||
gatewayConfiguration[typeConnector].push(connectorConfig); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 { Type } from '@angular/core'; |
|||
import { DeviceService } from '@core/http/device.service'; |
|||
import { AssetService } from '@core/http/asset.service'; |
|||
import { AttributeService } from '@core/http/attribute.service'; |
|||
import { EntityRelationService } from '@core/http/entity-relation.service'; |
|||
import { EntityService } from '@core/http/entity.service'; |
|||
import { DialogService } from '@core/services/dialog.service'; |
|||
import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service'; |
|||
import { DatePipe } from '@angular/common'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
|
|||
export const ServicesMap = new Map<string, Type<any>>( |
|||
[ |
|||
['deviceService', DeviceService], |
|||
['assetService', AssetService], |
|||
['attributeService', AttributeService], |
|||
['entityRelationService', EntityRelationService], |
|||
['entityService', EntityService], |
|||
['dialogs', DialogService], |
|||
['customDialog', CustomDialogService], |
|||
['date', DatePipe], |
|||
['utils', UtilsService], |
|||
['translate', TranslateService], |
|||
['http', HttpClient] |
|||
] |
|||
); |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue