Implemented Timeseries table widget.

This commit is contained in:
Igor Kulikov 2020-01-30 13:03:53 +02:00
parent bd8af1111e
commit d47371d8fd
12 changed files with 765 additions and 41 deletions

View File

@ -109,12 +109,12 @@
"sizeX": 8,
"sizeY": 6.5,
"resources": [],
"templateHtml": "<tb-timeseries-table-widget \n table-id=\"tableId\"\n ctx=\"ctx\">\n</tb-timeseries-table-widget>",
"templateHtml": "<tb-timeseries-table-widget \n [ctx]=\"ctx\">\n</tb-timeseries-table-widget>",
"templateCss": "",
"controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.tableId = \"table-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.$broadcast('timeseries-table-data-updated', self.ctx.$scope.tableId);\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"hideEmptyLines\": {\n \"title\": \"Hide empty lines\",\n \"type\": \"boolean\",\n \"default\": false\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"hideEmptyLines\"\n ]\n}",
"dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}"
"controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"showMilliseconds\": {\n \"title\": \"Display timestamp milliseconds\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"hideEmptyLines\": {\n \"title\": \"Hide empty lines\",\n \"type\": \"boolean\",\n \"default\": false\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\",\n \"showMilliseconds\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"hideEmptyLines\"\n ]\n}",
"dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, ctx)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\"}"
}
},
{

View File

@ -48,6 +48,16 @@
}
}
tb-widget.tb-widget {
position: relative;
height: 100%;
margin: 0;
overflow: hidden;
outline: none;
transition: all .2s ease-in-out;
}
div.tb-widget {
position: relative;
height: 100%;

View File

@ -135,7 +135,7 @@
</div>
</mat-toolbar>
<div fxFlex class="table-container">
<mat-table [dataSource]="dataSource"
<mat-table [dataSource]="dataSource" [trackBy]="trackByEntityId"
matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="(pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear>
<ng-container matColumnDef="select" sticky>
<mat-header-cell *matHeaderCellDef>

View File

@ -16,13 +16,13 @@
import {
AfterViewInit,
Component, ComponentFactoryResolver,
ChangeDetectionStrategy,
Component,
ComponentFactoryResolver,
ElementRef,
Input,
OnInit,
Type,
ViewChild,
ChangeDetectionStrategy
ViewChild
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
@ -35,28 +35,24 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { forkJoin, fromEvent, merge, Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { BaseData, HasId } from '@shared/models/base-data';
import { EntityId } from '@shared/models/id/entity-id';
import { ActivatedRoute } from '@angular/router';
import {
CellActionDescriptor,
EntityActionTableColumn,
EntityColumn,
EntityTableColumn,
EntityTableConfig,
GroupActionDescriptor,
HeaderActionDescriptor,
EntityColumn, EntityActionTableColumn
HeaderActionDescriptor
} from '@home/models/entity/entities-table-config.models';
import { EntityTypeTranslation } from '@shared/models/entity-type.models';
import { DialogService } from '@core/services/dialog.service';
import { AddEntityDialogComponent } from './add-entity-dialog.component';
import {
AddEntityDialogData,
EntityAction
} from '@home/models/entity/entity-component.models';
import { Timewindow, historyInterval } from '@shared/models/time/time.models';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
import { AddEntityDialogData, EntityAction } from '@home/models/entity/entity-component.models';
import { historyInterval, Timewindow } from '@shared/models/time/time.models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
import { instanceOf } from 'prop-types';
import { isDefined, isDefinedAndNotNull, isUndefined } from '@core/utils';
import { isDefined, isUndefined } from '@core/utils';
@Component({
selector: 'tb-entities-table',
@ -458,4 +454,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn
return column.key;
}
trackByEntityId(index: number, entity: BaseData<HasId>) {
return entity.id.id;
}
}

View File

@ -507,26 +507,22 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
}
public cellContent(alarm: AlarmInfo, key: EntityColumn): SafeHtml {
let strContent = '';
if (alarm && key) {
const contentInfo = this.contentsInfo[key.def];
const value = getAlarmValue(alarm, key);
let content = '';
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
if (isDefined(value)) {
strContent = '' + value;
}
var content = strContent;
try {
content = contentInfo.cellContentFunction(value, alarm, this.ctx);
} catch (e) {
content = strContent;
content = '' + value;
}
} else {
content = this.defaultContent(key, value);
}
return this.domSanitizer.bypassSecurityTrustHtml(content);
return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
} else {
return strContent;
return '';
}
}

View File

@ -433,28 +433,24 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
}
public cellContent(entity: EntityData, key: EntityColumn): SafeHtml {
let strContent = '';
if (entity && key) {
const contentInfo = this.contentsInfo[key.def];
const value = getEntityValue(entity, key);
let content = '';
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
if (isDefined(value)) {
strContent = '' + value;
}
var content = strContent;
try {
content = contentInfo.cellContentFunction(value, entity, this.ctx);
} catch (e) {
content = strContent;
content = '' + value;
}
} else {
const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals;
const units = contentInfo.units || this.ctx.widgetConfig.units;
content = this.ctx.utils.formatValue(value, decimals, units, true);
}
return this.domSanitizer.bypassSecurityTrustHtml(content);
return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
} else {
return strContent;
return '';
}
}

View File

@ -31,7 +31,7 @@ export interface TableWidgetSettings {
}
export interface TableWidgetDataKeySettings {
columnWidth: string;
columnWidth?: string;
useCellStyleFunction: boolean;
cellStyleFunction: string;
useCellContentFunction: boolean;
@ -168,6 +168,7 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
const mdDark = defaultColor.setAlpha(0.87).toRgbString();
const mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString();
const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString();
const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString();
const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString();
const cssString =
@ -189,6 +190,15 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
'mat-toolbar.mat-table-toolbar:not([color="primary"]) button.mat-icon-button mat-icon {\n'+
'color: ' + mdDarkSecondary + ';\n'+
'}\n'+
'.mat-tab-label {\n'+
'color: ' + mdDark + ';\n'+
'}\n'+
'.mat-tab-header-pagination-chevron {\n'+
'border-color: ' + mdDark + ';\n'+
'}\n'+
'.mat-tab-header-pagination-disabled .mat-tab-header-pagination-chevron {\n'+
'border-color: ' + mdDarkDisabled2 + ';\n'+
'}\n'+
'.mat-table .mat-header-row {\n'+
'background-color: ' + origBackgroundColor + ';\n'+
'}\n'+

View File

@ -0,0 +1,107 @@
<!--
Copyright © 2016-2019 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">
<mat-toolbar class="mat-table-toolbar" [fxShow]="textSearchMode">
<div class="mat-toolbar-tools">
<button mat-button mat-icon-button
matTooltip="{{ 'action.search' | translate }}"
matTooltipPosition="above">
<mat-icon>search</mat-icon>
</button>
<mat-form-field fxFlex>
<mat-label>&nbsp;</mat-label>
<input #searchInput matInput
[(ngModel)]="textSearch"
placeholder="{{ 'widget.search-data' | translate }}"/>
</mat-form-field>
<button mat-button mat-icon-button (click)="exitFilterMode()"
matTooltip="{{ 'action.close' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</mat-toolbar>
<mat-tab-group [ngClass]="{'tb-headless': sources.length === 1}" fxFlex
[(selectedIndex)]="sourceIndex" (selectedIndexChange)="onSourceIndexChanged()">
<mat-tab *ngFor="let source of sources" label="{{ source.datasource.name }}">
<div fxFlex class="table-container">
<mat-table [dataSource]="source.timeseriesDatasource" [trackBy]="trackByRowIndex"
matSort [matSortActive]="source.pageLink.sortOrder.property" [matSortDirection]="(source.pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear>
<ng-container *ngIf="showTimestamp" [matColumnDef]="'0'">
<mat-header-cell *matHeaderCellDef mat-sort-header>Timestamp</mat-header-cell>
<mat-cell *matCellDef="let row;"
[innerHTML]="cellContent(source, 0, row, row[0])"
[ngStyle]="cellStyle(source, 0, row[0])">
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="h.index + ''" *ngFor="let h of source.header; trackBy: trackByColumnIndex;">
<mat-header-cell *matHeaderCellDef mat-sort-header> {{ h.dataKey.label }} </mat-header-cell>
<mat-cell *matCellDef="let row;"
[innerHTML]="cellContent(source, h.index, row, row[h.index])"
[ngStyle]="cellStyle(source, h.index, row[h.index])">
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: (actionCellDescriptors.length * 36) + 'px', maxWidth: (actionCellDescriptors.length * 36) + 'px' }">
</mat-header-cell>
<mat-cell *matCellDef="let row" [ngStyle.gt-md]="{ minWidth: (actionCellDescriptors.length * 36) + 'px', maxWidth: (actionCellDescriptors.length * 36) + 'px' }">
<div fxHide fxShow.gt-md fxFlex fxLayout="row" fxLayoutAlign="end">
<button mat-button mat-icon-button [disabled]="isLoading$ | async"
*ngFor="let actionDescriptor of actionCellDescriptors"
matTooltip="{{ actionDescriptor.displayName }}"
matTooltipPosition="above"
(click)="onActionButtonClick($event, row, actionDescriptor)">
<mat-icon>{{actionDescriptor.icon}}</mat-icon>
</button>
</div>
<div fxHide fxShow.lt-lg>
<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">
<button mat-menu-item *ngFor="let actionDescriptor of actionCellDescriptors"
[disabled]="isLoading$ | async"
(click)="onActionButtonClick($event, row, actionDescriptor)">
<mat-icon>{{actionDescriptor.icon}}</mat-icon>
<span>{{ actionDescriptor.displayName }}</span>
</button>
</mat-menu>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="source.displayedColumns; sticky: true"></mat-header-row>
<mat-row *matRowDef="let row; columns: source.displayedColumns;"
(click)="onRowClick($event, row)"></mat-row>
</mat-table>
<span [fxShow]="source.timeseriesDatasource.isEmpty() | async"
fxLayoutAlign="center center"
class="no-data-found" translate>widget.no-data-found</span>
</div>
<mat-divider *ngIf="displayPagination"></mat-divider>
<mat-paginator *ngIf="displayPagination"
[length]="source.timeseriesDatasource.total() | async"
[pageIndex]="source.pageLink.page"
[pageSize]="source.pageLink.pageSize"
[pageSizeOptions]="pageSizeOptions"></mat-paginator>
</mat-tab>
</mat-tab-group>
</div>
</div>

View File

@ -0,0 +1,48 @@
/**
* Copyright © 2016-2019 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 {
mat-footer-row, mat-row {
min-height: 38px;
}
mat-header-row {
min-height: 40px;
}
mat-toolbar {
z-index: 10;
}
span.no-data-found {
height: calc(100% - 44px);
}
}
}
:host ::ng-deep {
.tb-table-widget {
.mat-tab-group {
height: 100%;
}
.mat-tab-body-wrapper {
height: 100%;
}
.mat-tab-body-content {
display: flex;
flex-direction: column;
}
}
}

View File

@ -0,0 +1,554 @@
///
/// Copyright © 2016-2019 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 {
AfterViewInit,
Component,
ElementRef,
Input,
NgZone,
OnInit,
QueryList,
ViewChild,
ViewChildren,
ViewContainerRef
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetAction, WidgetContext } from '@home/models/widget-component.models';
import {
DataKey,
Datasource,
DatasourceData,
DatasourceType,
WidgetActionDescriptor,
WidgetConfig
} from '@shared/models/widget.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { isDefined, isNumber } from '@core/utils';
import cssjs from '@core/css/css';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order';
import { DataSource } from '@angular/cdk/typings/collections';
import { CollectionViewer } from '@angular/cdk/collections';
import { BehaviorSubject, fromEvent, merge, Observable, of } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { catchError, debounceTime, distinctUntilChanged, map, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import {
CellContentInfo,
CellStyleInfo,
constructTableCssString,
getCellContentInfo,
getCellStyleInfo,
TableWidgetDataKeySettings
} from '@home/components/widget/lib/table-widget.models';
import { Overlay } from '@angular/cdk/overlay';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
import { DatePipe } from '@angular/common';
interface TimeseriesTableWidgetSettings {
showTimestamp: boolean;
showMilliseconds: boolean;
displayPagination: boolean;
defaultPageSize: number;
hideEmptyLines: boolean;
}
interface TimeseriesTableDataKeySettings extends TableWidgetDataKeySettings {
}
interface TimeseriesRow {
[col: number]: any;
formattedTs: string;
}
interface TimeseriesHeader {
index: number;
dataKey: DataKey;
}
interface TimeseriesTableSource {
keyStartIndex: number;
keyEndIndex: number;
datasource: Datasource;
rawData: Array<DatasourceData>;
data: TimeseriesRow[];
pageLink: PageLink;
displayedColumns: string[];
timeseriesDatasource: TimeseriesDatasource;
header: TimeseriesHeader[],
stylesInfo: CellStyleInfo[],
contentsInfo: CellContentInfo[],
rowDataTemplate: {[key: string]: any}
}
@Component({
selector: 'tb-timeseries-table-widget',
templateUrl: './timeseries-table-widget.component.html',
styleUrls: ['./timeseries-table-widget.component.scss', './table-widget.scss']
})
export class TimeseriesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit {
@Input()
ctx: WidgetContext;
@ViewChild('searchInput', {static: false}) searchInputField: ElementRef;
@ViewChildren(MatPaginator) paginators: QueryList<MatPaginator>;
@ViewChildren(MatSort) sorts: QueryList<MatSort>;
public displayPagination = true;
public pageSizeOptions;
public textSearchMode = false;
public textSearch: string = null;
public actionCellDescriptors: WidgetActionDescriptor[];
public sources: TimeseriesTableSource[];
public sourceIndex: number;
private settings: TimeseriesTableWidgetSettings;
private widgetConfig: WidgetConfig;
private data: Array<DatasourceData>;
private datasources: Array<Datasource>;
private defaultPageSize = 10;
private defaultSortOrder = '-0';
private hideEmptyLines = false;
private showTimestamp = true;
private dateFormatFilter: string;
private searchAction: WidgetAction = {
name: 'action.search',
show: true,
icon: 'search',
onAction: () => {
this.enterFilterMode();
}
};
constructor(protected store: Store<AppState>,
private elementRef: ElementRef,
private ngZone: NgZone,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef,
private utils: UtilsService,
private translate: TranslateService,
private domSanitizer: DomSanitizer,
private datePipe: DatePipe) {
super(store);
}
ngOnInit(): void {
this.ctx.$scope.timeseriesTableWidget = this;
this.settings = this.ctx.settings;
this.widgetConfig = this.ctx.widgetConfig;
this.data = this.ctx.data;
this.datasources = this.ctx.datasources;
this.initialize();
this.ctx.updateWidgetParams();
}
ngAfterViewInit(): void {
fromEvent(this.searchInputField.nativeElement, 'keyup')
.pipe(
debounceTime(150),
distinctUntilChanged(),
tap(() => {
if (this.displayPagination) {
this.paginators.forEach((paginator) => {
paginator.pageIndex = 0;
});
}
this.sources.forEach((source) => {
source.pageLink.textSearch = this.textSearch;
});
this.updateAllData();
})
)
.subscribe();
if (this.displayPagination) {
this.sorts.forEach((sort, index) => {
sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0);
});
}
this.sorts.forEach((sort, index) => {
const paginator = this.displayPagination ? this.paginators.toArray()[index] : null;
sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0);
(this.displayPagination ? merge(sort.sortChange, paginator.page) : sort.sortChange)
.pipe(
tap(() => this.updateData(sort, paginator, index))
)
.subscribe();
});
this.updateAllData();
}
public onDataUpdated() {
this.ngZone.run(() => {
this.sources.forEach((source) => {
source.timeseriesDatasource.dataUpdated(this.data);
});
this.ctx.detectChanges();
});
}
private initialize() {
this.ctx.widgetActions = [this.searchAction ];
this.actionCellDescriptors = this.ctx.actionsApi.getActionDescriptors('actionCellButton');
this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true;
this.hideEmptyLines = isDefined(this.settings.hideEmptyLines) ? this.settings.hideEmptyLines : false;
this.showTimestamp = this.settings.showTimestamp !== false;
this.dateFormatFilter = (this.settings.showMilliseconds !== true) ? 'yyyy-MM-dd HH:mm:ss' : 'yyyy-MM-dd HH:mm:ss.sss';
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];
let cssString = constructTableCssString(this.widgetConfig);
const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)';
cssString += '.tb-table-widget mat-toolbar.mat-table-toolbar:not([color=primary]) {\n'+
'background-color: ' + origBackgroundColor + ' !important;\n'+
'}\n';
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'ts-table-' + this.utils.hashCode(cssString);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, cssString);
$(this.elementRef.nativeElement).addClass(namespace);
this.updateDatasources();
}
private updateDatasources() {
this.sources = [];
this.sourceIndex = 0;
let keyOffset = 0;
const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY;
if (this.datasources) {
for (const datasource of this.datasources) {
const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder);
const source = {} as TimeseriesTableSource;
source.keyStartIndex = keyOffset;
keyOffset += datasource.dataKeys.length;
source.keyEndIndex = keyOffset;
source.datasource = datasource;
source.data = [];
source.rawData = [];
source.displayedColumns = [];
source.pageLink = new PageLink(pageSize, 0, null, sortOrder);
source.header = [];
source.stylesInfo = [];
source.contentsInfo = [];
source.rowDataTemplate = {};
source.rowDataTemplate['Timestamp'] = null;
if (this.showTimestamp) {
source.displayedColumns.push('0');
}
for (let a = 0; a < datasource.dataKeys.length; a++ ) {
const dataKey = datasource.dataKeys[a];
const keySettings: TimeseriesTableDataKeySettings = dataKey.settings;
const index = a + 1;
source.header.push({
index,
dataKey
});
source.displayedColumns.push(index + '');
source.rowDataTemplate[dataKey.label] = null;
source.stylesInfo.push(getCellStyleInfo(keySettings));
const cellContentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx');
cellContentInfo.units = dataKey.units;
cellContentInfo.decimals = dataKey.decimals;
source.contentsInfo.push(cellContentInfo);
}
source.displayedColumns.push('actions');
const tsDatasource = new TimeseriesDatasource(source, this.hideEmptyLines, this.dateFormatFilter, this.datePipe);
tsDatasource.dataUpdated(this.data);
this.sources.push(source);
}
}
this.updateActiveEntityInfo();
}
private updateActiveEntityInfo() {
const source = this.sources[this.sourceIndex];
let activeEntityInfo: SubscriptionEntityInfo = null;
if (source) {
const datasource = source.datasource;
if (datasource.type === DatasourceType.entity &&
datasource.entityType && datasource.entityId) {
activeEntityInfo = {
entityId: {
entityType: datasource.entityType,
id: datasource.entityId
},
entityName: datasource.entityName
};
}
}
this.ctx.activeEntityInfo = activeEntityInfo;
}
onSourceIndexChanged() {
this.updateActiveEntityInfo();
}
private enterFilterMode() {
this.textSearchMode = true;
this.textSearch = '';
this.sources.forEach((source) => {
source.pageLink.textSearch = this.textSearch;
});
this.ctx.hideTitlePanel = true;
this.ctx.detectChanges(true);
setTimeout(() => {
this.searchInputField.nativeElement.focus();
this.searchInputField.nativeElement.setSelectionRange(0, 0);
}, 10);
}
exitFilterMode() {
this.textSearchMode = false;
this.textSearch = null;
this.sources.forEach((source, index) => {
source.pageLink.textSearch = this.textSearch;
const sort = this.sorts.toArray()[index];
let paginator = null;
if (this.displayPagination) {
paginator = this.paginators.toArray()[index];
paginator.pageIndex = 0;
}
this.updateData(sort, paginator, index);
});
this.ctx.hideTitlePanel = false;
this.ctx.detectChanges(true);
}
private updateAllData() {
this.sources.forEach((source, index) => {
const sort = this.sorts.toArray()[index];
const paginator = this.displayPagination ? this.paginators.toArray()[index] : null;
this.updateData(sort, paginator, index);
});
}
private updateData(sort: MatSort, paginator: MatPaginator, index: number) {
const source = this.sources[index];
if (this.displayPagination) {
source.pageLink.page = paginator.pageIndex;
source.pageLink.pageSize = paginator.pageSize;
} else {
source.pageLink.page = 0;
}
source.pageLink.sortOrder.property = sort.active;
source.pageLink.sortOrder.direction = Direction[sort.direction.toUpperCase()];
source.timeseriesDatasource.loadRows();
this.ctx.detectChanges();
}
public trackByColumnIndex(index, header: TimeseriesHeader) {
return header.index;
}
public trackByRowIndex(index: number, row: TimeseriesRow) {
return index;
}
public cellStyle(source: TimeseriesTableSource, index: number, value: any): any {
let style: any = {};
if (index > 0) {
const styleInfo = source.stylesInfo[index-1];
if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
try {
style = styleInfo.cellStyleFunction(value);
} catch (e) {
style = {};
}
}
}
return style;
}
public cellContent(source: TimeseriesTableSource, index: number, row: TimeseriesRow, value: any): SafeHtml {
if (index === 0) {
return row.formattedTs;
} else {
let content = '';
const contentInfo = source.contentsInfo[index-1];
if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
try {
const rowData = source.rowDataTemplate;
rowData['Timestamp'] = row[0];
for (let h=0; h < source.header.length; h++) {
const headerInfo = source.header[h];
rowData[headerInfo.dataKey.name] = row[headerInfo.index];
}
content = contentInfo.cellContentFunction(value, rowData, this.ctx);
} catch (e) {
content = '' + value;
}
} else {
const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals;
const units = contentInfo.units || this.ctx.widgetConfig.units;
content = this.ctx.utils.formatValue(value, decimals, units, true);
}
return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
}
}
public onRowClick($event: Event, row: TimeseriesRow) {
const descriptors = this.ctx.actionsApi.getActionDescriptors('rowClick');
if (descriptors.length) {
if ($event) {
$event.stopPropagation();
}
let entityId;
let entityName;
if (this.ctx.activeEntityInfo) {
entityId = this.ctx.activeEntityInfo.entityId;
entityName = this.ctx.activeEntityInfo.entityName;
}
this.ctx.actionsApi.handleWidgetAction($event, descriptors[0], entityId, entityName, row);
}
}
public onActionButtonClick($event: Event, row: TimeseriesRow, actionDescriptor: WidgetActionDescriptor) {
if ($event) {
$event.stopPropagation();
}
let entityId;
let entityName;
if (this.ctx.activeEntityInfo) {
entityId = this.ctx.activeEntityInfo.entityId;
entityName = this.ctx.activeEntityInfo.entityName;
}
this.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, row);
}
}
class TimeseriesDatasource implements DataSource<TimeseriesRow> {
private rowsSubject = new BehaviorSubject<TimeseriesRow[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<TimeseriesRow>>(emptyPageData<TimeseriesRow>());
private allRowsSubject = new BehaviorSubject<TimeseriesRow[]>([]);
private allRows$: Observable<Array<TimeseriesRow>> = this.allRowsSubject.asObservable();
constructor(
private source: TimeseriesTableSource,
private hideEmptyLines: boolean,
private dateFormatFilter: string,
private datePipe: DatePipe
) {
this.source.timeseriesDatasource = this;
}
connect(collectionViewer: CollectionViewer): Observable<TimeseriesRow[] | ReadonlyArray<TimeseriesRow>> {
return this.rowsSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.rowsSubject.complete();
this.pageDataSubject.complete();
}
loadRows() {
this.fetchRows(this.source.pageLink).pipe(
catchError(() => of(emptyPageData<TimeseriesRow>())),
).subscribe(
(pageData) => {
this.rowsSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
}
);
}
dataUpdated(data: DatasourceData[]) {
this.source.rawData = data.slice(this.source.keyStartIndex, this.source.keyEndIndex);
this.updateSourceData();
}
private updateSourceData() {
this.source.data = this.convertData(this.source.rawData);
this.allRowsSubject.next(this.source.data);
}
private convertData(data: DatasourceData[]): TimeseriesRow[] {
const rowsMap: {[timestamp: number]: TimeseriesRow} = {};
for (let d = 0; d < data.length; d++) {
const columnData = data[d].data;
for (let i = 0; i < columnData.length; i++) {
const cellData = columnData[i];
const timestamp = cellData[0];
let row = rowsMap[timestamp];
if (!row) {
row = {
formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter)
};
row[0] = timestamp;
for (let c = 0; c < data.length; c++) {
row[c+1] = undefined;
}
rowsMap[timestamp] = row;
}
row[d+1] = cellData[1];
}
}
const rows: TimeseriesRow[] = [];
for (const t of Object.keys(rowsMap)) {
if (this.hideEmptyLines) {
let hideLine = true;
for (let _c = 0; (_c < data.length) && hideLine; _c++) {
if (rowsMap[t][_c+1])
hideLine = false;
}
if (!hideLine) {
rows.push(rowsMap[t]);
}
} else {
rows.push(rowsMap[t]);
}
}
return rows;
}
isEmpty(): Observable<boolean> {
return this.rowsSubject.pipe(
map((rows) => !rows.length)
);
}
total(): Observable<number> {
return this.pageDataSubject.pipe(
map((pageData) => pageData.totalElements)
);
}
private fetchRows(pageLink: PageLink): Observable<PageData<TimeseriesRow>> {
return this.allRows$.pipe(
map((data) => pageLink.filterData(data))
);
}
}

View File

@ -22,6 +22,7 @@ import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/displa
import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarms-table-widget.component';
import { AlarmStatusFilterPanelComponent } from '@home/components/widget/lib/alarm-status-filter-panel.component';
import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module';
import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component';
@NgModule({
entryComponents: [
@ -33,7 +34,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone
DisplayColumnsPanelComponent,
AlarmStatusFilterPanelComponent,
EntitiesTableWidgetComponent,
AlarmsTableWidgetComponent
AlarmsTableWidgetComponent,
TimeseriesTableWidgetComponent
],
imports: [
CommonModule,
@ -42,7 +44,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone
],
exports: [
EntitiesTableWidgetComponent,
AlarmsTableWidgetComponent
AlarmsTableWidgetComponent,
TimeseriesTableWidgetComponent
]
})
export class WidgetComponentsModule { }

View File

@ -857,7 +857,7 @@ mat-label {
span.no-data-found {
position: relative;
display: flex;
height: calc(100% - 57px);
height: calc(100% - 60px);
text-transform: uppercase;
}