UI: New control widget Persistent table

This commit is contained in:
ArtemDzhereleiko 2021-11-26 17:00:51 +02:00
parent 77b9a8c1af
commit 5d4579ce28
32 changed files with 1805 additions and 24 deletions

File diff suppressed because one or more lines are too long

View File

@ -177,8 +177,8 @@ public class RpcV2Controller extends AbstractRpcController {
@RequestParam int pageSize,
@ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true)
@RequestParam int page,
@ApiParam(value = "Status of the RPC", required = true, allowableValues = RPC_STATUS_ALLOWABLE_VALUES)
@RequestParam RpcStatus rpcStatus,
@ApiParam(value = "Status of the RPC", allowableValues = RPC_STATUS_ALLOWABLE_VALUES)
@RequestParam(required = false) RpcStatus rpcStatus,
@ApiParam(value = RPC_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch,
@ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RPC_SORT_PROPERTY_ALLOWABLE_VALUES)
@ -194,7 +194,12 @@ public class RpcV2Controller extends AbstractRpcController {
accessValidator.validate(getCurrentUser(), Operation.RPC_CALL, deviceId, new HttpValidationCallback(response, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable DeferredResult<ResponseEntity> result) {
PageData<Rpc> rpcCalls = rpcService.findAllByDeviceIdAndStatus(tenantId, deviceId, rpcStatus, pageLink);
PageData<Rpc> rpcCalls;
if (rpcStatus != null) {
rpcCalls = rpcService.findAllByDeviceIdAndStatus(tenantId, deviceId, rpcStatus, pageLink);
} else {
rpcCalls = rpcService.findAllByDeviceId(tenantId, deviceId, pageLink);
}
response.setResult(new ResponseEntity<>(rpcCalls, HttpStatus.OK));
}

View File

@ -35,5 +35,7 @@ public interface RpcService {
ListenableFuture<Rpc> findRpcByIdAsync(TenantId tenantId, RpcId id);
PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink);
PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink);
}

View File

@ -82,7 +82,15 @@ public class BaseRpcService implements RpcService {
log.trace("Executing findAllByDeviceIdAndStatus, tenantId [{}], deviceId [{}], rpcStatus [{}], pageLink [{}]", tenantId, deviceId, rpcStatus, pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
validatePageLink(pageLink);
return rpcDao.findAllByDeviceId(tenantId, deviceId, rpcStatus, pageLink);
return rpcDao.findAllByDeviceIdAndStatus(tenantId, deviceId, rpcStatus, pageLink);
}
@Override
public PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink) {
log.trace("Executing findAllByDeviceIdAndStatus, tenantId [{}], deviceId [{}], pageLink [{}]", tenantId, deviceId, pageLink);
validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
validatePageLink(pageLink);
return rpcDao.findAllByDeviceId(tenantId, deviceId, pageLink);
}
private PaginatedRemover<TenantId, Rpc> tenantRpcRemover =

View File

@ -24,7 +24,9 @@ import org.thingsboard.server.common.data.rpc.RpcStatus;
import org.thingsboard.server.dao.Dao;
public interface RpcDao extends Dao<Rpc> {
PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink);
PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink);
PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink);
PageData<Rpc> findAllRpcByTenantId(TenantId tenantId, PageLink pageLink);

View File

@ -50,7 +50,12 @@ public class JpaRpcDao extends JpaAbstractDao<RpcEntity, Rpc> implements RpcDao
}
@Override
public PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink) {
public PageData<Rpc> findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink) {
return DaoUtil.toPageData(rpcRepository.findAllByTenantIdAndDeviceId(tenantId.getId(), deviceId.getId(), DaoUtil.toPageable(pageLink)));
}
@Override
public PageData<Rpc> findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink) {
return DaoUtil.toPageData(rpcRepository.findAllByTenantIdAndDeviceIdAndStatus(tenantId.getId(), deviceId.getId(), rpcStatus, DaoUtil.toPageable(pageLink)));
}

View File

@ -26,6 +26,8 @@ import org.thingsboard.server.dao.model.sql.RpcEntity;
import java.util.UUID;
public interface RpcRepository extends CrudRepository<RpcEntity, UUID> {
Page<RpcEntity> findAllByTenantIdAndDeviceId(UUID tenantId, UUID deviceId, Pageable pageable);
Page<RpcEntity> findAllByTenantIdAndDeviceIdAndStatus(UUID tenantId, UUID deviceId, RpcStatus status, Pageable pageable);
Page<RpcEntity> findAllByTenantId(UUID tenantId, Pageable pageable);

View File

@ -55,6 +55,8 @@ import { TranslateService } from '@ngx-translate/core';
import { AlarmDataService } from '@core/api/alarm-data.service';
import { IDashboardController } from '@home/components/dashboard-page/dashboard-page.models';
import { PopoverPlacement } from '@shared/components/popover.models';
import { PageLink } from '@shared/models/page/page-link';
import { PersistentRpc, RpcStatus } from '@shared/models/rpc.models';
export interface TimewindowFunctions {
onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void;
@ -71,9 +73,9 @@ export interface WidgetSubscriptionApi {
export interface RpcApi {
sendOneWayCommand: (method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string) => Observable<any>;
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string) => Observable<any>;
sendTwoWayCommand: (method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string) => Observable<any>;
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string) => Observable<any>;
completedCommand: () => void;
}
@ -287,6 +289,8 @@ export interface IWidgetSubscription {
comparisonEnabled?: boolean;
comparisonTimeWindow?: WidgetTimewindow;
persistentRequests?: PageData<PersistentRpc>;
alarms?: PageData<AlarmData>;
alarmSource?: Datasource;
@ -313,11 +317,13 @@ export interface IWidgetSubscription {
updateTimewindowConfig(newTimewindow: Timewindow): void;
sendOneWayCommand(method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string): Observable<any>;
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string): Observable<any>;
sendTwoWayCommand(method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string): Observable<any>;
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string): Observable<any>;
clearRpcError(): void;
subscribeForPersistentRequests(pageLink: PageLink, keyFileter: RpcStatus): Observable<any>;
subscribe(): void;
subscribeAllForPaginatedData(pageLink: EntityDataPageLink,

View File

@ -70,6 +70,7 @@ import {
import { distinct, filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { AlarmDataListener } from '@core/api/alarm-data.service';
import { RpcStatus } from '@shared/models/rpc.models';
import { PageLink } from '@shared/models/page/page-link';
const moment = moment_;
@ -656,13 +657,13 @@ export class WidgetSubscription implements IWidgetSubscription {
}
sendOneWayCommand(method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string): Observable<any> {
return this.sendCommand(true, method, params, timeout, persistent, persistentPollingInterval, requestUUID);
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string): Observable<any> {
return this.sendCommand(true, method, params, timeout, persistent, persistentPollingInterval, retries, additionalInfo, requestUUID);
}
sendTwoWayCommand(method: string, params?: any, timeout?: number, persistent?: boolean,
persistentPollingInterval?: number, requestUUID?: string): Observable<any> {
return this.sendCommand(false, method, params, timeout, persistent, persistentPollingInterval, requestUUID);
persistentPollingInterval?: number, retries?: number, additionalInfo?: any, requestUUID?: string): Observable<any> {
return this.sendCommand(false, method, params, timeout, persistent, persistentPollingInterval, retries, additionalInfo, requestUUID);
}
clearRpcError(): void {
@ -679,7 +680,8 @@ export class WidgetSubscription implements IWidgetSubscription {
}
sendCommand(oneWayElseTwoWay: boolean, method: string, params?: any, timeout?: number,
persistent?: boolean, persistentPollingInterval?: number, requestUUID?: string): Observable<any> {
persistent?: boolean, persistentPollingInterval?: number, retries?: number,
additionalInfo?: any, requestUUID?: string): Observable<any> {
if (!this.rpcEnabled) {
return throwError(new Error('Rpc disabled!'));
} else {
@ -692,6 +694,8 @@ export class WidgetSubscription implements IWidgetSubscription {
method,
params,
persistent,
retries,
additionalInfo,
requestUUID
};
if (timeout && timeout > 0) {
@ -777,6 +781,15 @@ export class WidgetSubscription implements IWidgetSubscription {
}
}
subscribeForPersistentRequests(pageLink: PageLink, keyFilter: RpcStatus): Observable<any> {
if (!this.rpcEnabled) {
return throwError(new Error('Rpc disabled!'));
} else if (!this.targetDeviceId) {
return throwError(new Error('Target device is not set!'));
}
return this.ctx.deviceService.getPersistedRpcRequests(this.targetDeviceId, pageLink, keyFilter);
}
private extractRejectionErrorText(rejection: HttpErrorResponse) {
let error = null;
if (rejection.error) {

View File

@ -31,7 +31,7 @@ import {
import { EntitySubtype } from '@app/shared/models/entity-type.models';
import { AuthService } from '@core/auth/auth.service';
import { BulkImportRequest, BulkImportResult } from '@home/components/import-export/import-export.models';
import { PersistentRpc } from '@shared/models/rpc.models';
import { PersistentRpc, RpcStatus } from '@shared/models/rpc.models';
@Injectable({
providedIn: 'root'
@ -143,6 +143,17 @@ export class DeviceService {
return this.http.get<PersistentRpc>(`/api/rpc/persistent/${rpcId}`, defaultHttpOptionsFromConfig(config));
}
public deletePersistedRpc(rpcId: string, config?: RequestConfig) {
return this.http.delete<PersistentRpc>(`/api/rpc/persistent/${rpcId}`, defaultHttpOptionsFromConfig(config));
}
public getPersistedRpcRequests(deviceId: string, pageLink: PageLink,
keyFilter: RpcStatus, config?: RequestConfig): Observable<PageData<PersistentRpc>> {
const rpcStatus = keyFilter ? '&rpcStatus=' + keyFilter : '';
return this.http.get<PageData<PersistentRpc>>(`/api/rpc/persistent/device/${deviceId}${pageLink.toQuery()}${rpcStatus}`,
defaultHttpOptionsFromConfig(config));
}
public findByQuery(query: DeviceSearchQuery,
config?: RequestConfig): Observable<Array<Device>> {
return this.http.post<Array<Device>>('/api/devices', query, defaultHttpOptionsFromConfig(config));

View File

@ -0,0 +1,92 @@
<!--
Copyright © 2016-2021 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<form [formGroup]="persistentFormGroup" (ngSubmit)="save()" style="min-width: 480px; max-width: 600px;">
<mat-toolbar color="primary">
<h2>{{ 'widgets.persistent-table.add-title' | translate }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="close()"
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 class="add-dialog">
<fieldset [disabled]="isLoading$ | async">
<div fxLayout="row" fxLayoutGap="6px">
<mat-slide-toggle fxFlex class="mat-block" formControlName="oneWayElseTwoWay">
{{ 'widgets.persistent-table.message-types.' + persistentFormGroup.get('oneWayElseTwoWay').value | translate }}
</mat-slide-toggle>
</div>
<div fxLayout="row wrap" fxLayout.xs="column" fxLayoutGap="6px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.method</mat-label>
<input matInput formControlName="method" required>
<mat-error *ngIf="this.persistentFormGroup.get('method').hasError('required')">
{{'widgets.persistent-table.method-error' | translate}}
</mat-error>
<mat-error *ngIf="this.persistentFormGroup.get('method').hasError('pattern')">
{{'widgets.persistent-table.white-space-error' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.retries</mat-label>
<input matInput type="number" formControlName="retries">
</mat-form-field>
</div>
<div class="params-json-editor">
<tb-json-object-edit formControlName="params"
[editorStyle]="{minHeight: '130px'}"
label="{{ 'widgets.persistent-table.params' | translate }}">
</tb-json-object-edit>
</div>
<mat-expansion-panel class="additional-json-editor">
<mat-expansion-panel-header>
<mat-panel-title translate>
widgets.persistent-table.additional-info
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-json-object-edit formControlName="additionalInfo"
[editorStyle]="{minHeight: '130px'}">
</tb-json-object-edit>
</ng-template>
</mat-expansion-panel>
</fieldset>
</div>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-actions fxLayout="row">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="close()">
{{ 'action.close' | translate }}
</button>
<span fxFlex></span>
<div fxLayout="row" fxLayoutGap="8px">
<button mat-raised-button
color="primary"
type="submit"
[disabled]="(isLoading$ | async) || persistentFormGroup.invalid || !persistentFormGroup.dirty">
{{ 'widgets.persistent-table.send-request' | translate }}
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,30 @@
/**
* Copyright © 2016-2021 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 {
.add-dialog ::ng-deep {
.params-json-editor,
.additional-json-editor {
.tb-json-object-panel {
margin: 0 0 16px;
}
.mat-expansion-panel-body {
padding-bottom: 0 !important;
}
}
}
}

View File

@ -0,0 +1,77 @@
///
/// Copyright © 2016-2021 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, OnInit } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RequestData } from '@shared/models/rpc.models';
@Component({
selector: 'tb-persistent-add-dialog',
templateUrl: './persistent-add-dialog.component.html',
styleUrls: ['./persistent-add-dialog.component.scss']
})
export class PersistentAddDialogComponent extends DialogComponent<PersistentAddDialogComponent, RequestData> implements OnInit {
public persistentFormGroup: FormGroup;
private requestData: RequestData = {
persistentUpdated: false
};
constructor(protected store: Store<AppState>,
protected router: Router,
public dialogRef: MatDialogRef<PersistentAddDialogComponent, RequestData>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.persistentFormGroup = this.fb.group(
{
method: ['', [Validators.required, Validators.pattern(/^\S+$/)]],
oneWayElseTwoWay: [false],
retries: [null, [Validators.pattern(/^-?[0-9]+$/), Validators.min(0)]],
params: [{}],
additionalInfo: [{}]
}
);
}
save() {
if (this.persistentFormGroup.valid) {
this.requestData = {
persistentUpdated: true,
method: this.persistentFormGroup.get('method').value,
oneWayElseTwoWay: this.persistentFormGroup.get('oneWayElseTwoWay').value,
params: this.persistentFormGroup.get('params').value,
additionalInfo: this.persistentFormGroup.get('additionalInfo').value,
retries: this.persistentFormGroup.get('retries').value
};
this.close();
}
}
ngOnInit(): void {
}
close(): void {
this.dialogRef.close(this.requestData);
}
}

View File

@ -0,0 +1,118 @@
<!--
Copyright © 2016-2021 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<form [formGroup]="persistentFormGroup" style="min-width: 480px;">
<mat-toolbar color="primary">
<h2>{{ persistentFormGroup.get('rpcId').value }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="close()"
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>
<fieldset [disabled]="isLoading$ | async">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap="6px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.created-time</mat-label>
<input matInput formControlName="createdTime" readonly>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.expiration-time</mat-label>
<input matInput formControlName="expirationTime" readonly>
</mat-form-field>
</div>
<div fxLayout="row wrap" fxLayout.xs="column" fxLayoutGap="6px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.message-type</mat-label>
<input matInput formControlName="messageType" readonly>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.status</mat-label>
<input matInput formControlName="status" readonly
[ngStyle]="{fontWeight: 'bold', color: rpcStatusColorsMap.get((data.persistentRequest.status))}">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widgets.persistent-table.method</mat-label>
<input matInput formControlName="method" readonly>
</mat-form-field>
<mat-form-field fxFlex class="mat-block"
*ngIf="persistentFormGroup.get('retries').value">
<mat-label translate>widgets.persistent-table.retries</mat-label>
<input matInput formControlName="retries" readonly>
</mat-form-field>
</div>
<mat-accordion class="rpc-dialog" multi>
<mat-expansion-panel expanded>
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widgets.persistent-table.response' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<tb-json-object-view formControlName="response" autoHeight></tb-json-object-view>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widgets.persistent-table.params' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-json-object-view formControlName="params" autoHeight></tb-json-object-view>
</ng-template>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widgets.persistent-table.additional-info' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-json-object-view
formControlName="additionalInfo"
autoHeight>
</tb-json-object-view>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</fieldset>
</div>
<div mat-dialog-actions fxLayout="row">
<button mat-raised-button
*ngIf="allowDelete"
color="primary"
type="button"
(click)="deleteRpcRequest()"
[disabled]="(isLoading$ | async)">
{{ 'widgets.persistent-table.delete' | translate }}
</button>
<span fxFlex></span>
<div fxLayout="row" fxLayoutGap="8px">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="close()" cdkFocusInitial>
{{ 'action.close' | translate }}
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2021 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 {
.rpc-dialog ::ng-deep {
.mat-expansion-panel-body {
padding-bottom: 0 !important;
}
.tb-json-object-panel {
margin: 0 0 16px 0;
}
}
.tb-audit-log-response-data {
width: 100%;
min-width: 400px;
height: 100%;
min-height: 100px;
border: 1px solid #c0c0c0;
}
}

View File

@ -0,0 +1,151 @@
///
/// Copyright © 2016-2021 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, OnInit, ViewChild } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { DatePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup } from '@angular/forms';
import { DeviceService } from '@core/http/device.service';
import {
PersistentRpc,
rpcStatusColors,
RpcStatus,
rpcStatusTranslation
} from '@shared/models/rpc.models';
import { isDefinedAndNotNull } from '@core/utils';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DialogService } from '@core/services/dialog.service';
export interface PersistentDetailsDialogData {
persistentRequest: PersistentRpc;
allowDelete: boolean;
}
@Component({
selector: 'tb-persistent-details-dialog',
templateUrl: './persistent-details-dialog.component.html',
styleUrls: ['./persistent-details-dialog.component.scss']
})
export class PersistentDetailsDialogComponent extends DialogComponent<PersistentDetailsDialogComponent, boolean> implements OnInit {
@ViewChild('responseDataEditor', {static: true})
responseDataEditorElmRef: ElementRef;
public persistentFormGroup: FormGroup;
public rpcStatusColorsMap = rpcStatusColors;
public rpcStatus = RpcStatus;
public allowDelete: boolean;
private persistentUpdated = false;
private responseData: string;
constructor(protected store: Store<AppState>,
protected router: Router,
private datePipe: DatePipe,
private translate: TranslateService,
@Inject(MAT_DIALOG_DATA) public data: PersistentDetailsDialogData,
public dialogRef: MatDialogRef<PersistentDetailsDialogComponent, boolean>,
private dialogService: DialogService,
private deviceService: DeviceService,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.allowDelete = data.allowDelete;
this.persistentFormGroup = this.fb.group(
{
rpcId: [''],
createdTime: [''],
expirationTime: [''],
messageType: [''],
status: [''],
method: [''],
params: [''],
retries: [''],
response: [''],
additionalInfo: [null]
}
);
this.loadPersistentFields(data.persistentRequest);
this.responseData = JSON.stringify(data.persistentRequest.response, null, 2);
}
loadPersistentFields(request: PersistentRpc) {
this.persistentFormGroup.get('rpcId')
.patchValue(this.translate.instant('widgets.persistent-table.details-title') + request.id.id);
this.persistentFormGroup.get('createdTime')
.patchValue(this.datePipe.transform(request.createdTime, 'yyyy-MM-dd HH:mm:ss'));
this.persistentFormGroup.get('expirationTime')
.patchValue(this.datePipe.transform(request.expirationTime, 'yyyy-MM-dd HH:mm:ss'));
this.persistentFormGroup.get('messageType')
.patchValue(this.translate.instant('widgets.persistent-table.message-types.' + request.request.oneway)
);
this.persistentFormGroup.get('status')
.patchValue(this.translate.instant(rpcStatusTranslation.get(request.status)));
this.persistentFormGroup.get('method')
.patchValue(request.request.body.method);
if (isDefinedAndNotNull(request.request.retries)) {
this.persistentFormGroup.get('retries')
.patchValue(request.request.retries);
}
if (isDefinedAndNotNull(request.response)) {
this.persistentFormGroup.get('response')
.patchValue(request.response);
}
if (isDefinedAndNotNull(request.request.body.params)) {
this.persistentFormGroup.get('params')
.patchValue(JSON.parse(request.request.body.params));
}
if (isDefinedAndNotNull(request.additionalInfo)) {
this.persistentFormGroup.get('additionalInfo')
.patchValue(request.additionalInfo);
}
}
ngOnInit(): void {
}
close(): void {
this.dialogRef.close(this.persistentUpdated);
}
deleteRpcRequest() {
const persistentRpc = this.data.persistentRequest;
if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) {
this.dialogService.confirm(
this.translate.instant('widgets.persistent-table.delete-request-title'),
this.translate.instant('widgets.persistent-table.delete-request-text'),
this.translate.instant('action.no'),
this.translate.instant('action.yes')
).subscribe((res) => {
if (res) {
if (res) {
this.deviceService.deletePersistedRpc(persistentRpc.id.id).subscribe(() => {
this.persistentUpdated = true;
this.close();
});
}
}
});
}
}
}

View File

@ -0,0 +1,44 @@
<!--
Copyright © 2016-2021 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 fxLayout="column" class="mat-content mat-padding" [formGroup]="persistentFilterFormGroup" (ngSubmit)="update()">
<mat-form-field fxFlex class="mat-block" floatLabel="always">
<mat-label translate>widgets.persistent-table.rpc-status-list</mat-label>
<mat-select formControlName="rpcStatus"
placeholder="{{ !persistentFilterFormGroup.get('rpcStatus').value?.length ? ('widgets.persistent-table.any-status' | translate) : '' }}">
<mat-option [value]="null">
{{ 'widgets.persistent-table.rpc-search-status-all' | translate }}
</mat-option>
<mat-option *ngFor="let searchStatus of persistentSearchStatuses" [value]="searchStatus">
{{ rpcSearchStatusTranslationMap.get(searchStatus) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<div fxLayout="row" class="tb-panel-actions" fxLayoutAlign="end center">
<button type="button"
mat-button
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button type="submit"
mat-raised-button
color="primary"
[disabled]="persistentFilterFormGroup.invalid || !persistentFilterFormGroup.dirty">
{{ 'action.update' | translate }}
</button>
</div>
</form>

View File

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2021 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 {
width: 100%;
height: 100%;
min-width: 300px;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow:
0 7px 8px -4px rgba(0, 0, 0, .2),
0 13px 19px 2px rgba(0, 0, 0, .14),
0 5px 24px 4px rgba(0, 0, 0, .12);
.mat-content {
overflow: hidden;
background-color: #fff;
}
.mat-padding {
padding: 16px;
}
}

View File

@ -0,0 +1,71 @@
///
/// Copyright © 2016-2021 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject, InjectionToken } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { OverlayRef } from '@angular/cdk/overlay';
import { RpcStatus, rpcStatusTranslation } from '@shared/models/rpc.models';
export const PERSISTENT_FILTER_PANEL_DATA = new InjectionToken<any>('AlarmFilterPanelData');
export interface PersistentFilterPanelData {
rpcStatus: RpcStatus;
}
@Component({
selector: 'tb-persistent-filter-panel',
templateUrl: './persistent-filter-panel.component.html',
styleUrls: ['./persistent-filter-panel.component.scss']
})
export class PersistentFilterPanelComponent {
public persistentFilterFormGroup: FormGroup;
public result: PersistentFilterPanelData;
public rpcSearchStatusTranslationMap = rpcStatusTranslation;
public persistentSearchStatuses = [
RpcStatus.QUEUED,
RpcStatus.SENT,
RpcStatus.DELIVERED,
RpcStatus.SUCCESSFUL,
RpcStatus.TIMEOUT,
RpcStatus.EXPIRED,
RpcStatus.FAILED
];
constructor(@Inject(PERSISTENT_FILTER_PANEL_DATA)
public data: PersistentFilterPanelData,
public overlayRef: OverlayRef,
private fb: FormBuilder) {
this.persistentFilterFormGroup = this.fb.group(
{
rpcStatus: this.data.rpcStatus
}
);
}
update() {
this.result = {
rpcStatus: this.persistentFilterFormGroup.get('rpcStatus').value
};
this.overlayRef.dispose();
}
cancel() {
this.overlayRef.dispose();
}
}

View File

@ -0,0 +1,125 @@
<!--
Copyright © 2016-2021 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-table-widget tb-absolute-fill">
<div fxFlex fxLayout="column" class="tb-absolute-fill">
<div fxFlex class="table-container">
<table mat-table [dataSource]="persistentDatasource"
matSort [matSortActive]="pageLink.sortOrder.property"
[matSortDirection]="pageLink.sortDirection()" matSortDisableClear>
<ng-container matColumnDef="rpcId">
<mat-header-cell *matHeaderCellDef class="column-id">
{{ 'widgets.persistent-table.rpc-id' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column">
{{ column.id.id }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="createdTime">
<mat-header-cell *matHeaderCellDef class="column-time" mat-sort-header>
{{ 'widgets.persistent-table.created-time' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column">
{{ column.createdTime | date:'yyyy-MM-dd HH:mm:ss' }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="expirationTime">
<mat-header-cell *matHeaderCellDef class="column-time" mat-sort-header>
{{ 'widgets.persistent-table.expiration-time' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column">
{{ column.expirationTime | date:'yyyy-MM-dd HH:mm:ss' }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="status">
<mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'widgets.persistent-table.status' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column"
[ngStyle]="{fontWeight: 'bold', color: rpcStatusColor.get((column.status))}">
{{ rpcStatusTranslation.get(column.status) | translate }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="messageType">
<mat-header-cell *matHeaderCellDef>
{{ 'widgets.persistent-table.message-type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column">
{{ 'widgets.persistent-table.message-types.' + column.request.oneway | translate }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="method">
<mat-header-cell *matHeaderCellDef>
{{ 'widgets.persistent-table.method' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let column">
{{ column.request.body.method }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" [stickyEnd]="enableStickyAction">
<mat-header-cell *matHeaderCellDef>
</mat-header-cell>
<mat-cell *matCellDef="let column">
<div fxHide fxShow.gt-md fxLayout="row" fxLayoutAlign="end">
<ng-container *ngFor="let actionDescriptor of actionCellButtonAction">
<button mat-button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ actionDescriptor.displayName }}"
matTooltipPosition="above"
(click)="onActionButtonClick($event, column, actionDescriptor)">
<mat-icon>{{ actionDescriptor.icon }}</mat-icon>
</button>
</ng-container>
</div>
<div fxHide fxShow.lt-lg *ngIf="actionCellButtonAction.length">
<button mat-button mat-icon-button
(click)="$event.stopPropagation(); ctx.detectChanges();"
[matMenuTriggerFor]="cellActionsMenu">
<mat-icon class="material-icons">more_vert</mat-icon>
</button>
<mat-menu #cellActionsMenu="matMenu" xPosition="before">
<ng-container *ngFor="let actionDescriptor of actionCellButtonAction">
<button mat-menu-item *ngIf="actionDescriptor.icon"
[disabled]="(isLoading$ | async)"
(click)="onActionButtonClick($event, column, actionDescriptor)">
<mat-icon>{{actionDescriptor.icon}}</mat-icon>
<span>{{ actionDescriptor.displayName }}</span>
</button>
</ng-container>
</mat-menu>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: enableStickyHeader"></mat-header-row>
<mat-row *matRowDef="let column; columns: displayedColumns"
[fxShow]="!persistentDatasource.dataLoading"></mat-row>
</table>
<span [fxShow]="(persistentDatasource.isEmpty() | async) && !persistentDatasource.dataLoading"
fxLayoutAlign="center center"
class="no-data-found">{{ noDataDisplayMessageText }}</span>
<span [fxShow]="persistentDatasource.dataLoading"
fxLayoutAlign="center center"
class="no-data-found">{{ 'common.loading' | translate }}</span>
</div>
<mat-divider *ngIf="displayPagination"></mat-divider>
<mat-paginator *ngIf="displayPagination"
[length]="persistentDatasource.total() | async"
[pageIndex]="pageLink.page"
[pageSize]="pageLink.pageSize"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons></mat-paginator>
</div>
</div>

View File

@ -0,0 +1,52 @@
/**
* Copyright © 2016-2021 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 {
width: 100%;
height: 100%;
.tb-table-widget {
.table-container {
position: relative;
}
.mat-table {
.mat-row {
&.invisible {
visibility: hidden;
}
}
}
span.no-data-found {
position: absolute;
top: 60px;
bottom: 0;
left: 0;
right: 0;
}
.column-id {
min-width: 250px;
max-width: 250px;
width: 250px;
}
.column-time {
min-width: 120px;
max-width: 120px;
width: 120px;
}
}
}

View File

@ -0,0 +1,476 @@
///
/// Copyright © 2016-2021 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,
Injector,
Input,
OnInit,
StaticProvider,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetContext } from '@home/models/widget-component.models';
import { WidgetConfig } from '@shared/models/widget.models';
import { IWidgetSubscription } from '@core/api/widget-api.models';
import { BehaviorSubject, merge, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import {
constructTableCssString, noDataMessage,
TableCellButtonActionDescriptor,
TableWidgetSettings
} from '@home/components/widget/lib/table-widget.models';
import cssjs from '@core/css/css';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { hashCode, isDefined, isNumber } from '@core/utils';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import {
PersistentRpc,
PersistentRpcData, RequestData,
RpcStatus,
rpcStatusColors, rpcStatusTranslation
} from '@shared/models/rpc.models';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DialogService } from '@core/services/dialog.service';
import { DeviceService } from '@core/http/device.service';
import { MatDialog } from '@angular/material/dialog';
import {
PersistentDetailsDialogComponent,
PersistentDetailsDialogData
} from '@home/components/widget/lib/rpc/persistent-details-dialog.component';
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
PERSISTENT_FILTER_PANEL_DATA, PersistentFilterPanelComponent, PersistentFilterPanelData
} from '@home/components/widget/lib/rpc/persistent-filter-panel.component';
import { PersistentAddDialogComponent } from '@home/components/widget/lib/rpc/persistent-add-dialog.component';
interface PersistentTableWidgetSettings extends TableWidgetSettings {
defaultSortOrder: string;
defaultPageSize: number;
displayPagination: boolean;
enableStickyAction: boolean;
enableStickyHeader: boolean;
enableFilter: boolean;
displayColumns: string[];
displayDetails: boolean;
allowDelete: boolean;
allowSendRequest: boolean;
}
interface PersistentTableWidgetActionDescriptor extends TableCellButtonActionDescriptor {
details?: boolean;
delete?: boolean;
}
@Component({
selector: 'tb-persistent-table-widget',
templateUrl: './persistent-table.component.html',
styleUrls: ['./persistent-table.component.scss' , '../table-widget.scss']
})
export class PersistentTableComponent extends PageComponent implements OnInit {
@Input()
ctx: WidgetContext;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
private settings: PersistentTableWidgetSettings;
private widgetConfig: WidgetConfig;
private subscription: IWidgetSubscription;
private enableFilterAction = true;
private allowSendRequest = true;
private defaultPageSize = 10;
private defaultSortOrder = '-createdTime';
private rpcStatusFilter: RpcStatus | null = null;
private displayDetails = true;
private allowDelete = true;
private displayTableColumns: string[];
public persistentDatasource: PersistentDatasource;
public noDataDisplayMessageText: string;
public rpcStatusColor = rpcStatusColors;
public rpcStatusTranslation = rpcStatusTranslation;
public displayPagination = true;
public enableStickyHeader = true;
public enableStickyAction = true;
public pageLink: PageLink;
public pageSizeOptions;
public actionCellButtonAction: PersistentTableWidgetActionDescriptor[] = [];
public displayedColumns: string[];
constructor(protected store: Store<AppState>,
private elementRef: ElementRef,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef,
private utils: UtilsService,
private translate: TranslateService,
private dialogService: DialogService,
private deviceService: DeviceService,
private dialog: MatDialog) {
super(store);
}
ngOnInit() {
this.ctx.$scope.persistentTableWidget = this;
this.settings = this.ctx.settings;
this.widgetConfig = this.ctx.widgetConfig;
this.subscription = this.ctx.defaultSubscription;
this.initializeConfig();
this.ctx.updateWidgetParams();
}
ngAfterViewInit(): void {
if (this.displayPagination) {
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);
}
((this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) as Observable<any>)
.pipe(
tap(() => this.updateData())
)
.subscribe();
this.updateData();
}
private initializeConfig() {
this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true;
this.enableStickyHeader = isDefined(this.settings.enableStickyHeader) ? this.settings.enableStickyHeader : true;
this.displayTableColumns = isDefined(this.settings.displayColumns) ? this.settings.displayColumns : [];
this.enableStickyAction = isDefined(this.settings.enableStickyAction) ? this.settings.enableStickyAction : true;
this.enableFilterAction = isDefined(this.settings.enableFilter) ? this.settings.enableFilter : true;
this.displayDetails = isDefined(this.settings.displayDetails) ? this.settings.displayDetails : true;
this.allowDelete = isDefined(this.settings.allowDelete) ? this.settings.allowDelete : true;
this.allowSendRequest = isDefined(this.settings.allowSendRequest) ? this.settings.allowSendRequest : true;
this.noDataDisplayMessageText =
noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widgets.persistent-table.no-request-prompt', this.utils, this.translate);
this.displayedColumns = [...this.displayTableColumns];
const pageSize = this.settings.defaultPageSize;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}
this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3];
if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) {
this.defaultSortOrder = this.settings.defaultSortOrder;
}
const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder);
this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder);
this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024;
this.ctx.widgetActions = [
{
name: 'widgets.persistent-table.add',
show: this.allowSendRequest,
icon: 'add',
onAction: $event => this.addPersistentRpcRequest($event)
},
{
name: 'widgets.persistent-table.refresh',
show: true,
icon: 'refresh',
onAction: () => this.reloadPersistentRequests()
},
{
name: 'widgets.persistent-table.filter',
show: this.enableFilterAction,
icon: 'filter_list',
onAction: $event => this.editFilter($event)
}
];
if (this.settings.displayDetails) {
this.actionCellButtonAction.push(
{
displayName: this.translate.instant('widgets.persistent-table.details'),
icon: 'more_horiz',
details: true
} as PersistentTableWidgetActionDescriptor
);
}
if (this.settings.allowDelete) {
this.actionCellButtonAction.push(
{
displayName: this.translate.instant('widgets.persistent-table.delete'),
icon: 'delete',
delete: true
} as PersistentTableWidgetActionDescriptor
);
}
if (this.actionCellButtonAction.length) {
this.displayedColumns.push('actions');
}
this.persistentDatasource = new PersistentDatasource(this.translate, this.subscription);
const cssString = constructTableCssString(this.widgetConfig);
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'persistent-table-' + hashCode(cssString);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, cssString);
$(this.elementRef.nativeElement).addClass(namespace);
}
private updateData() {
if (this.displayPagination) {
this.pageLink.page = this.paginator.pageIndex;
this.pageLink.pageSize = this.paginator.pageSize;
} else {
this.pageLink.page = 0;
}
if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) {
this.defaultSortOrder = this.utils.customTranslation(this.settings.defaultSortOrder, this.settings.defaultSortOrder);
}
this.pageLink.sortOrder.property = this.sort.active;
this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()];
this.persistentDatasource.loadPersistent(this.pageLink, this.rpcStatusFilter);
this.ctx.detectChanges();
}
public onDataUpdated() {
this.ctx.detectChanges();
}
reloadPersistentRequests() {
if (this.displayPagination) {
this.paginator.pageIndex = 0;
}
this.updateData();
}
deleteRpcRequest($event: Event, persistentRpc: PersistentRpc) {
if ($event) {
$event.stopPropagation();
}
if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) {
this.dialogService.confirm(
this.translate.instant('widgets.persistent-table.delete-request-title'),
this.translate.instant('widgets.persistent-table.delete-request-text'),
this.translate.instant('action.no'),
this.translate.instant('action.yes')
).subscribe((res) => {
if (res) {
if (res) {
this.deviceService.deletePersistedRpc(persistentRpc.id.id).subscribe(() => {
this.reloadPersistentRequests();
});
}
}
});
}
}
openRequestDetails($event: Event, persistentRpc: PersistentRpc) {
if ($event) {
$event.stopPropagation();
}
if (persistentRpc && persistentRpc.id && persistentRpc.id.id !== NULL_UUID) {
this.dialog.open<PersistentDetailsDialogComponent, PersistentDetailsDialogData, boolean>
(PersistentDetailsDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
persistentRequest: persistentRpc,
allowDelete: this.allowDelete
}
}).afterClosed().subscribe(
(res) => {
if (res) {
this.reloadPersistentRequests();
}
}
);
}
}
addPersistentRpcRequest($event: Event){
if ($event) {
$event.stopPropagation();
}
this.dialog.open<PersistentAddDialogComponent, RequestData>
(PersistentAddDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog']
}).afterClosed().subscribe(
(requestData) => {
if (requestData.persistentUpdated) {
this.sendRequests(requestData);
}
}
);
}
private sendRequests(requestData: RequestData) {
let commandPromise;
if (requestData.oneWayElseTwoWay) {
commandPromise = this.ctx.controlApi.sendOneWayCommand(
requestData.method,
requestData.params, null,
true, null,
requestData.retries,
requestData.additionalInfo
);
} else {
commandPromise = this.ctx.controlApi.sendTwoWayCommand(
requestData.method,
requestData.params,
null,
true, null,
requestData.retries,
requestData.additionalInfo
);
}
commandPromise.subscribe(
() => {
this.reloadPersistentRequests();
}
);
}
public onActionButtonClick($event: Event, persistentRpc: PersistentRpc, actionDescriptor: PersistentTableWidgetActionDescriptor) {
if (actionDescriptor.details) {
this.openRequestDetails($event, persistentRpc);
}
if (actionDescriptor.delete) {
this.deleteRpcRequest($event, persistentRpc);
}
}
private editFilter($event: Event) {
if ($event) {
$event.stopPropagation();
}
const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig();
config.backdropClass = 'cdk-overlay-transparent-backdrop';
config.hasBackdrop = true;
const connectedPosition: ConnectedPosition = {
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top'
};
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement)
.withPositions([connectedPosition]);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
overlayRef.dispose();
});
const providers: StaticProvider[] = [
{
provide: PERSISTENT_FILTER_PANEL_DATA,
useValue: {
rpcStatus: this.rpcStatusFilter
} as PersistentFilterPanelData
},
{
provide: OverlayRef,
useValue: overlayRef
}
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
const componentRef = overlayRef.attach(new ComponentPortal(PersistentFilterPanelComponent,
this.viewContainerRef, injector));
componentRef.onDestroy(() => {
if (componentRef.instance.result) {
const result = componentRef.instance.result;
this.rpcStatusFilter = result.rpcStatus;
this.reloadPersistentRequests();
}
});
this.ctx.detectChanges();
}
}
class PersistentDatasource implements DataSource<PersistentRpcData> {
private persistentSubject = new BehaviorSubject<PersistentRpcData[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<PersistentRpcData>>(emptyPageData<PersistentRpcData>());
public dataLoading = true;
public pageData$ = this.pageDataSubject.asObservable();
constructor(private translate: TranslateService,
private subscription: IWidgetSubscription) {
}
connect(collectionViewer: CollectionViewer): Observable<PersistentRpcData[] | ReadonlyArray<PersistentRpcData>> {
return this.persistentSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.persistentSubject.complete();
this.pageDataSubject.complete();
}
reset() {
const pageData = emptyPageData<PersistentRpcData>();
this.persistentSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
}
loadPersistent(pageLink: PageLink, keyFilter: RpcStatus) {
this.dataLoading = true;
const result = new ReplaySubject<PageData<PersistentRpcData>>();
this.fetchEntities(pageLink, keyFilter).pipe(
catchError(() => of(emptyPageData<PersistentRpcData>())),
).subscribe(
(pageData) => {
this.persistentSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
result.next(pageData);
this.dataLoading = false;
}
);
return result;
}
fetchEntities(pageLink: PageLink, keyFilter: RpcStatus): Observable<PageData<PersistentRpcData>> {
return this.subscription.subscribeForPersistentRequests(pageLink, keyFilter);
}
isEmpty(): Observable<boolean> {
return this.persistentSubject.pipe(
map((requests) => !requests.length)
);
}
total(): Observable<number> {
return this.pageDataSubject.pipe(
map((pageData) => pageData.totalElements)
);
}
}

View File

@ -21,6 +21,10 @@ import { LedIndicatorComponent } from '@home/components/widget/lib/rpc/led-indic
import { RoundSwitchComponent } from '@home/components/widget/lib/rpc/round-switch.component';
import { SwitchComponent } from '@home/components/widget/lib/rpc/switch.component';
import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component';
import { PersistentTableComponent } from '@home/components/widget/lib/rpc/persistent-table.component';
import { PersistentDetailsDialogComponent } from '@home/components/widget/lib/rpc/persistent-details-dialog.component';
import { PersistentFilterPanelComponent } from '@home/components/widget/lib/rpc/persistent-filter-panel.component';
import { PersistentAddDialogComponent } from '@home/components/widget/lib/rpc/persistent-add-dialog.component';
@NgModule({
declarations:
@ -28,7 +32,11 @@ import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component';
LedIndicatorComponent,
RoundSwitchComponent,
SwitchComponent,
KnobComponent
KnobComponent,
PersistentTableComponent,
PersistentDetailsDialogComponent,
PersistentAddDialogComponent,
PersistentFilterPanelComponent
],
imports: [
CommonModule,
@ -38,7 +46,10 @@ import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component';
LedIndicatorComponent,
RoundSwitchComponent,
SwitchComponent,
KnobComponent
KnobComponent,
PersistentTableComponent,
PersistentDetailsDialogComponent,
PersistentAddDialogComponent
]
})
export class RpcWidgetsModule { }

View File

@ -196,16 +196,18 @@ export class WidgetContext {
};
controlApi: RpcApi = {
sendOneWayCommand: (method, params, timeout, persistent, requestUUID) => {
sendOneWayCommand: (method, params, timeout, persistent,
retries, additionalInfo, requestUUID) => {
if (this.defaultSubscription) {
return this.defaultSubscription.sendOneWayCommand(method, params, timeout, persistent, requestUUID);
return this.defaultSubscription.sendOneWayCommand(method, params, timeout, persistent, retries, additionalInfo, requestUUID);
} else {
return of(null);
}
},
sendTwoWayCommand: (method, params, timeout, persistent, requestUUID) => {
sendTwoWayCommand: (method, params, timeout, persistent,
retries, additionalInfo, requestUUID) => {
if (this.defaultSubscription) {
return this.defaultSubscription.sendTwoWayCommand(method, params, timeout, persistent, requestUUID);
return this.defaultSubscription.sendTwoWayCommand(method, params, timeout, persistent, retries, additionalInfo, requestUUID);
} else {
return of(null);
}

View File

@ -0,0 +1,22 @@
<!--
Copyright © 2016-2021 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div style="background: #fff;" [ngClass]="{'fill-height': fillHeight}">
<label class="tb-title no-padding" *ngIf="label">{{ label }}</label>
<span fxFlex></span>
<div #jsonViewer id="tb-json-view" [ngClass]="{'fill-height': fillHeight}"></div>
</div>

View File

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2021 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 {
#tb-json-view {
width: 100%;
height: 100%;
margin-bottom: 16px;
border: 1px solid #c0c0c0;
&:not(.fill-height) {
min-height: 100px;
}
}
}

View File

@ -0,0 +1,166 @@
///
/// Copyright © 2016-2021 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, forwardRef, Input, OnInit, Renderer2, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { Ace } from 'ace-builds';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { RafService } from '@core/services/raf.service';
import { isDefinedAndNotNull, isUndefined } from '@core/utils';
import { getAce } from '@shared/models/ace/ace.models';
@Component({
selector: 'tb-json-object-view',
templateUrl: './json-object-view.component.html',
styleUrls: ['./json-object-view.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => JsonObjectViewComponent),
multi: true
}
]
})
export class JsonObjectViewComponent implements OnInit {
@ViewChild('jsonViewer', {static: true})
jsonViewerElmRef: ElementRef;
private jsonViewer: Ace.Editor;
private viewerElement: Ace.Editor;
private propagateChange = null;
private modelValue: any;
private contentValue: string;
@Input() label: string;
@Input() fillHeight: boolean;
@Input() editorStyle: { [klass: string]: any };
@Input() sort: (key: string, value: any) => any;
private widthValue: boolean;
get autoWidth(): boolean {
return this.widthValue;
}
@Input()
set autoWidth(value: boolean) {
this.widthValue = coerceBooleanProperty(value);
}
private heigthValue: boolean;
get autoHeight(): boolean {
return this.heigthValue;
}
@Input()
set autoHeight(value: boolean) {
this.heigthValue = coerceBooleanProperty(value);
}
constructor(public elementRef: ElementRef,
protected store: Store<AppState>,
private raf: RafService,
private renderer: Renderer2) {
}
ngOnInit(): void {
this.viewerElement = this.jsonViewerElmRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: 'ace/mode/java',
theme: 'ace/theme/github',
showGutter: false,
showPrintMargin: false,
readOnly: true
};
const advancedOptions = {
enableSnippets: false,
enableBasicAutocompletion: false,
enableLiveAutocompletion: false
};
editorOptions = {...editorOptions, ...advancedOptions};
getAce().subscribe(
(ace) => {
this.jsonViewer = ace.edit(this.viewerElement, editorOptions);
this.jsonViewer.session.setUseWrapMode(false);
this.jsonViewer.setValue(this.contentValue ? this.contentValue : '', -1);
if (this.contentValue && (this.widthValue || this.heigthValue)) {
this.updateEditorSize(this.viewerElement, this.contentValue, this.jsonViewer);
}
}
);
}
updateEditorSize(editorElement: any, content: string, editor: Ace.Editor) {
let newHeight = 200;
let newWidth = 600;
if (content && content.length > 0) {
const lines = content.split('\n');
newHeight = 17 * lines.length + 17;
let maxLineLength = 0;
lines.forEach((row) => {
const line = row.replace(/\t/g, ' ').replace(/\n/g, '');
const lineLength = line.length;
maxLineLength = Math.max(maxLineLength, lineLength);
});
newWidth = 8 * maxLineLength + 16;
}
if (this.heigthValue) {
// this.renderer.setStyle(editorElement, 'minHeight', newHeight.toString() + 'px');
this.renderer.setStyle(editorElement, 'height', newHeight.toString() + 'px');
}
if (this.widthValue) {
this.renderer.setStyle(editorElement, 'width', newWidth.toString() + 'px');
}
editor.resize();
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
writeValue(value: any): void {
this.modelValue = value;
this.contentValue = '';
try {
if (isDefinedAndNotNull(this.modelValue)) {
this.contentValue = JSON.stringify(this.modelValue, isUndefined(this.sort) ? undefined :
(key, objectValue) => {
return this.sort(key, objectValue);
}, 2);
}
} catch (e) {
//
}
if (this.jsonViewer) {
this.jsonViewer.setValue(this.contentValue ? this.contentValue : '', -1);
if (this.contentValue && (this.widthValue || this.heigthValue)) {
this.updateEditorSize(this.viewerElement, this.contentValue, this.jsonViewer);
}
}
}
}

View File

@ -17,15 +17,42 @@
import { TenantId } from '@shared/models/id/tenant-id';
import { RpcId } from '@shared/models/id/rpc-id';
import { DeviceId } from '@shared/models/id/device-id';
import { TableCellButtonActionDescriptor } from '@home/components/widget/lib/table-widget.models';
export enum RpcStatus {
QUEUED = 'QUEUED',
DELIVERED = 'DELIVERED',
SUCCESSFUL = 'SUCCESSFUL',
TIMEOUT = 'TIMEOUT',
FAILED = 'FAILED'
FAILED = 'FAILED',
SENT = 'SENT',
EXPIRED = 'EXPIRED'
}
export const rpcStatusColors = new Map<RpcStatus, string>(
[
[RpcStatus.QUEUED, 'black'],
[RpcStatus.DELIVERED, 'green'],
[RpcStatus.SUCCESSFUL, 'green'],
[RpcStatus.TIMEOUT, 'orange'],
[RpcStatus.FAILED, 'red'],
[RpcStatus.SENT, 'green'],
[RpcStatus.EXPIRED, 'red']
]
);
export const rpcStatusTranslation = new Map<RpcStatus, string>(
[
[RpcStatus.QUEUED, 'widgets.persistent-table.rpc-status.QUEUED'],
[RpcStatus.DELIVERED, 'widgets.persistent-table.rpc-status.DELIVERED'],
[RpcStatus.SUCCESSFUL, 'widgets.persistent-table.rpc-status.SUCCESSFUL'],
[RpcStatus.TIMEOUT, 'widgets.persistent-table.rpc-status.TIMEOUT'],
[RpcStatus.FAILED, 'widgets.persistent-table.rpc-status.FAILED'],
[RpcStatus.SENT, 'widgets.persistent-table.rpc-status.SENT'],
[RpcStatus.EXPIRED, 'widgets.persistent-table.rpc-status.EXPIRED']
]
);
export interface PersistentRpc {
id: RpcId;
createdTime: number;
@ -34,7 +61,29 @@ export interface PersistentRpc {
response: any;
request: {
id: string;
oneway: boolean;
body: {
method: string;
params: string;
};
retries: null | number;
};
deviceId: DeviceId;
tenantId: TenantId;
additionalInfo?: string;
}
export interface PersistentRpcData extends PersistentRpc {
actionCellButtons?: TableCellButtonActionDescriptor[];
hasActions?: boolean;
}
export interface RequestData {
persistentUpdated: boolean;
method?: string;
oneWayElseTwoWay?: boolean;
persistentPollingInterval?: number;
retries?: number;
params?: object;
additionalInfo?: object;
}

View File

@ -94,6 +94,7 @@ import { SocialSharePanelComponent } from '@shared/components/socialshare-panel.
import { RelationTypeAutocompleteComponent } from '@shared/components/relation/relation-type-autocomplete.component';
import { EntityListSelectComponent } from '@shared/components/entity/entity-list-select.component';
import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component';
import { JsonObjectViewComponent, } from '@shared/components/json-object-view.component';
import { FooterFabButtonsComponent } from '@shared/components/footer-fab-buttons.component';
import { CircularProgressDirective } from '@shared/components/circular-progress.directive';
import {
@ -231,6 +232,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
RelationTypeAutocompleteComponent,
SocialSharePanelComponent,
JsonObjectEditComponent,
JsonObjectViewComponent,
JsonContentComponent,
JsFuncComponent,
FabTriggerDirective,
@ -376,6 +378,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
RelationTypeAutocompleteComponent,
SocialSharePanelComponent,
JsonObjectEditComponent,
JsonObjectViewComponent,
JsonContentComponent,
JsFuncComponent,
FabTriggerDirective,

View File

@ -3238,7 +3238,49 @@
"update-timeseries": "Update timeseries",
"value": "Value"
},
"invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type"
"invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type",
"persistent-table": {
"rpc-id": "RPC ID",
"message-type": "Message type",
"method": "Method",
"params": "Params",
"created-time": "Created time",
"expiration-time": "Expiration time",
"retries": "Retries",
"status": "Status",
"filter": "Filter",
"refresh": "Refresh",
"add": "Add RPC request",
"details": "Details",
"delete": "Delete",
"delete-request-title": "Delete Persistent RPC request",
"delete-request-text": "Are you sure you want to delete request?",
"details-title": "Details RPC ID: ",
"additional-info": "Additional info",
"response": "Response",
"any-status": "Any status",
"rpc-status-list": "RPC status list",
"no-request-prompt": "No request to display",
"send-request": "Send request",
"add-title": "Create Persistent RPC request",
"method-error": "Method is required.",
"timeout-error": "Min timeout value is 5000 (5 seconds).",
"white-space-error": "White space is not allowed.",
"rpc-status": {
"QUEUED": "QUEUED",
"SENT": "SENT",
"DELIVERED": "DELIVERED",
"SUCCESSFUL": "SUCCESSFUL",
"TIMEOUT": "TIMEOUT",
"EXPIRED": "EXPIRED",
"FAILED": "FAILED"
},
"rpc-search-status-all": "ALL",
"message-types": {
"false": "Two-way",
"true": "One-way"
}
}
},
"icon": {
"icon": "Icon",

View File

@ -1787,6 +1787,47 @@
"update-attribute": "Обновить атрибут",
"update-timeseries": "Обновить телеметрию",
"value": "Значение"
},
"persistent-table": {
"rpc-id": "RPC ID",
"message-type": "Тип сообщения",
"method": "Метод",
"params": "Параметры",
"created-time": "Время создания",
"expiration-time": "Время жизни",
"retries": "Повторные попытки",
"status": "Статус",
"filter": "Фильтр",
"refresh": "Обновить",
"add": "Добавить RPC запрос",
"details": "Детали",
"delete": "Удалить",
"delete-request-title": "Удалить RPC запрос",
"delete-request-text": "Вы точно хотите удалить RPC запрос?",
"details-title": "Детали RPC ID: ",
"additional-info": "Дополнительная информация",
"response": "Ответ",
"any-status": "Любой статус",
"rpc-status-list": "Список RPC статусов",
"no-request-prompt": "Запросы не найдены",
"send-request": "Отправить запрос",
"add-title": "Добавить новый RPC запрос",
"method-error": "Метод обязателен.",
"white-space-error": "Пробелы не допускаются.",
"rpc-status": {
"QUEUED": "В ОЧЕРЕДИ",
"SENT": "ОТПРАВЛЕННО",
"DELIVERED": "ДОСТАВЛЕННО",
"SUCCESSFUL": "УСПЕШНО",
"TIMEOUT": "ВРЕМЯ ИСТЕКЛО",
"EXPIRED": "ПРОСРОЧЕНО",
"FAILED": "НЕУДАЧНО"
},
"rpc-search-status-all": "ВСЕ",
"message-types": {
"false": "Двусторонний",
"true": "Односторонний"
}
}
},
"icon": {

View File

@ -2359,6 +2359,47 @@
"update-attribute": "Оновити атрибут",
"update-timeseries": "Оновити телеметрію",
"value": "Значення"
},
"persistent-table": {
"rpc-id": "RPC ID",
"message-type": "Тип повідомлення",
"method": "Метод",
"params": "Параметри",
"created-time": "Час створення",
"expiration-time": "Час життя",
"retries": "Повторні спроби",
"status": "Статус",
"filter": "Фільтр",
"refresh": "Оновити",
"add": "Додати RPC запит",
"details": "Деталі",
"delete": "Видалити",
"delete-request-title": "Видалити RPC запит",
"delete-request-text": "Ви впевнені, що хочете видалити RPC запит?",
"details-title": "Деталі RPC ID: ",
"additional-info": "Додаткова інформація",
"response": "Відповідь",
"any-status": "Будь-який статус",
"rpc-status-list": "Список RPC статусів",
"no-request-prompt": "Запитів не знайдено",
"send-request": "Відправити запит",
"add-title": "Додати новий RPC запит",
"method-error": "Необхідно вказати метод.",
"white-space-error": "Пробіли не допускаються.",
"rpc-status": {
"QUEUED": "В ЧЕРЗІ",
"SENT": "ВІДПРАВЛЕНО",
"DELIVERED": "ДОСТАВЛЕННО",
"SUCCESSFUL": "УСПІШНО",
"TIMEOUT": "ЧАС МИНУВ",
"EXPIRED": "ПРОСРОЧЕНО",
"FAILED": "НЕ ВДАЛО"
},
"rpc-search-status-all": "ВСІ",
"message-types": {
"false": "Двусторонній",
"true": "Односторонній"
}
}
},
"white-labeling": {