timewindow.preview
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts
index e0e4ee5615..85e7e5a4be 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts
@@ -63,7 +63,8 @@ export class TimewindowStylePanelComponent extends PageComponent implements OnIn
icon: [computedTimewindowStyle.icon, []],
iconPosition: [computedTimewindowStyle.iconPosition, []],
font: [computedTimewindowStyle.font, []],
- color: [computedTimewindowStyle.color, []]
+ color: [computedTimewindowStyle.color, []],
+ displayTypePrefix: [computedTimewindowStyle.displayTypePrefix, []]
}
);
this.updatePreviewStyle(this.timewindowStyle);
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
index 9bda9345dc..fb5dd252b6 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts
@@ -119,7 +119,7 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
return this.configForm().valid;
}
- protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[]) {
+ protected setupDefaultDatasource(configData: WidgetConfigComponentData, keys?: DataKey[], latestKeys?: DataKey[]) {
let datasources = configData.config.datasources;
if (!datasources || !datasources.length) {
datasources = [
@@ -135,23 +135,58 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement
dataKeys = [];
datasources[0].dataKeys = dataKeys;
}
+ let latestDataKeys = datasources[0].latestDataKeys;
+ if (!latestDataKeys) {
+ latestDataKeys = [];
+ datasources[0].latestDataKeys = latestDataKeys;
+ }
if (keys && keys.length) {
dataKeys.length = 0;
keys.forEach(key => {
- const dataKey =
- this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema);
- if (key.label) {
- dataKey.label = key.label;
- }
- if (key.units) {
- dataKey.units = key.units;
- }
- if (isDefinedAndNotNull(key.decimals)) {
- dataKey.decimals = key.decimals;
- }
+ const dataKey = this.constructDataKey(configData, key);
dataKeys.push(dataKey);
});
}
+ if (latestKeys && latestKeys.length) {
+ latestDataKeys.length = 0;
+ latestKeys.forEach(key => {
+ const dataKey = this.constructDataKey(configData, key);
+ latestDataKeys.push(dataKey);
+ });
+ }
+ }
+
+ protected constructDataKey(configData: WidgetConfigComponentData, key: DataKey): DataKey {
+ const dataKey =
+ this.widgetConfigComponent.widgetConfigCallbacks.generateDataKey(key.name, key.type, configData.dataKeySettingsSchema);
+ if (key.label) {
+ dataKey.label = key.label;
+ }
+ if (key.units) {
+ dataKey.units = key.units;
+ }
+ if (isDefinedAndNotNull(key.decimals)) {
+ dataKey.decimals = key.decimals;
+ }
+ if (isDefinedAndNotNull(key.settings)) {
+ dataKey.settings = key.settings;
+ }
+ if (isDefinedAndNotNull(key.aggregationType)) {
+ dataKey.aggregationType = key.aggregationType;
+ }
+ if (isDefinedAndNotNull(key.comparisonEnabled)) {
+ dataKey.comparisonEnabled = key.comparisonEnabled;
+ }
+ if (isDefinedAndNotNull(key.timeForComparison)) {
+ dataKey.timeForComparison = key.timeForComparison;
+ }
+ if (isDefinedAndNotNull(key.comparisonCustomIntervalValue)) {
+ dataKey.comparisonCustomIntervalValue = key.comparisonCustomIntervalValue;
+ }
+ if (isDefinedAndNotNull(key.comparisonResultType)) {
+ dataKey.comparisonResultType = key.comparisonResultType;
+ }
+ return dataKey;
}
protected abstract configForm(): UntypedFormGroup;
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html
new file mode 100644
index 0000000000..b20431fa64
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html
@@ -0,0 +1,90 @@
+
+
+
+
+ {{ subtitle$ | async }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{tickMax$ | async}}
+
{{tickMin$ | async}}
+
+
+
+
+
+
+
+
+
+
+
+ arrow_upward
+ arrow_downward
+
+
+ {{ value.value }}
+ {{ value.units }}
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss
new file mode 100644
index 0000000000..6dee828c36
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.scss
@@ -0,0 +1,156 @@
+/**
+ * Copyright © 2016-2023 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-aggregated-value-card-panel {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ padding: 20px 24px 24px;
+ > div:not(.tb-value-card-overlay) {
+ z-index: 1;
+ }
+ .tb-aggregated-value-card-overlay {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ bottom: 12px;
+ right: 12px;
+ }
+ > div.tb-aggregated-value-card-title-panel {
+ display: flex;
+ flex-direction: column;
+ .tb-aggregated-value-card-subtitle {
+ margin-left: 28px;
+ }
+ }
+ .tb-aggregated-value-card-values, .tb-aggregated-value-card-chart {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .tb-aggregated-value-card-values-container {
+ width: 100%;
+ height: 100%;
+ padding: 8px 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) fit-content(100%) minmax(0, 1fr);
+ .tb-aggregated-value-card-values-section {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ overflow: hidden;
+ &.left {
+ align-items: flex-start;
+ }
+ &.center {
+ align-items: center;
+ }
+ &.right {
+ align-items: flex-end;
+ }
+ }
+ }
+ .tb-aggregated-value-card-chart {
+ display: flex;
+ gap: 8px;
+ flex-direction: row;
+ .tb-aggregated-value-card-chart-ticks {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ place-content: flex-end space-between;
+ align-items: flex-end;
+ font-size: 11px;
+ line-height: 16px;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.38);
+ }
+ .tb-aggregated-value-card-chart-container {
+ position: relative;
+ flex: 1;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ .tb-aggregated-value-card-chart-element {
+ width: 100%;
+ height: 100%;
+ }
+ .tb-aggregated-value-card-chart-boundary {
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ &.top {
+ top: 0;
+ border-top: 2px solid rgba(0,0,0,0.38);
+ }
+ &.left {
+ left: 0;
+ border-left: 2px solid rgba(0,0,0,0.38);
+ }
+ &.right {
+ right: 0;
+ border-right: 2px solid rgba(0,0,0,0.38);
+ }
+ &.bottom {
+ bottom: 0;
+ border-bottom: 2px solid rgba(0,0,0,0.38);
+ }
+ }
+ }
+ }
+ .tb-aggregated-value-card-value {
+ white-space: nowrap;
+ min-height: 0;
+ display: flex;
+ flex-direction: row;
+ place-content: center;
+ align-items: center;
+ .value-arrow-container {
+ display: flex;
+ }
+ .value-text {
+ line-height: 1;
+ }
+ .value-arrow {
+ font-size: 1.1em;
+ height: 1.1em;
+ line-height: 1.1em;
+ min-width: 1em;
+ width: 1em;
+ }
+ .units {
+ font-size: 85%;
+ padding-left: 0.2em;
+ &.small {
+ font-size: 50%;
+ }
+ }
+ }
+ }
+}
+
+:host ::ng-deep {
+ .tb-aggregated-value-card-panel {
+ > div.tb-aggregated-value-card-title-panel {
+ .tb-widget-title {
+ padding: 0;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts
new file mode 100644
index 0000000000..fca9f31084
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts
@@ -0,0 +1,261 @@
+///
+/// Copyright © 2016-2023 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,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ Input,
+ OnInit,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+import {
+ aggregatedValueCardDefaultSettings,
+ AggregatedValueCardKeyPosition,
+ AggregatedValueCardValue,
+ AggregatedValueCardWidgetSettings,
+ computeAggregatedCardValue,
+ getTsValueByLatestDataKey
+} from '@home/components/widget/lib/cards/aggregated-value-card.models';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { Observable } from 'rxjs';
+import {
+ backgroundStyle,
+ ColorProcessor,
+ ComponentStyle,
+ DateFormatProcessor, getDataKey,
+ getLatestSingleTsValue,
+ overlayStyle,
+ textStyle
+} from '@shared/models/widget-settings.models';
+import { DatePipe } from '@angular/common';
+import { TbFlot } from '@home/components/widget/lib/flot-widget';
+import { TbFlotKeySettings, TbFlotSettings } from '@home/components/widget/lib/flot-widget.models';
+import { DataKey } from '@shared/models/widget.models';
+import { formatNumberValue, formatValue, isDefined, isNumeric } from '@core/utils';
+import { map } from 'rxjs/operators';
+
+@Component({
+ selector: 'tb-aggregated-value-card-widget',
+ templateUrl: './aggregated-value-card-widget.component.html',
+ styleUrls: ['./aggregated-value-card-widget.component.scss']
+})
+export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit {
+
+ @ViewChild('chartElement', {static: false}) chartElement: ElementRef;
+
+ aggregatedValueCardKeyPosition = AggregatedValueCardKeyPosition;
+
+ settings: AggregatedValueCardWidgetSettings;
+
+ @Input()
+ ctx: WidgetContext;
+
+ @Input()
+ widgetTitlePanel: TemplateRef
;
+
+ showSubtitle = true;
+ subtitle$: Observable;
+ subtitleStyle: ComponentStyle = {};
+ subtitleColor: ColorProcessor;
+
+ showValues = false;
+
+ values: {[key: string]: AggregatedValueCardValue} = {};
+
+ showChart = true;
+ chartColor: ColorProcessor;
+
+ showDate = true;
+ dateFormat: DateFormatProcessor;
+ dateStyle: ComponentStyle = {};
+ dateColor: ColorProcessor;
+
+ backgroundStyle: ComponentStyle = {};
+ overlayStyle: ComponentStyle = {};
+
+ private flot: TbFlot;
+ private flotDataKey: DataKey;
+
+ private lastUpdateTs: number;
+
+ tickMin$: Observable;
+ tickMax$: Observable;
+
+ constructor(private date: DatePipe,
+ private cd: ChangeDetectorRef) {
+ }
+
+ ngOnInit(): void {
+ this.ctx.$scope.aggregatedValueCardWidget = this;
+ this.settings = {...aggregatedValueCardDefaultSettings, ...this.ctx.settings};
+ this.showSubtitle = this.settings.showSubtitle;
+ const subtitle = this.settings.subtitle;
+ this.subtitle$ = this.ctx.registerLabelPattern(subtitle, this.subtitle$);
+ this.subtitleStyle = textStyle(this.settings.subtitleFont, '0.25px');
+ this.subtitleColor = ColorProcessor.fromSettings(this.settings.subtitleColor);
+
+ const dataKey = getDataKey(this.ctx.defaultSubscription.datasources);
+ if (dataKey?.name && this.ctx.defaultSubscription.firstDatasource?.latestDataKeys?.length) {
+ const dataKeys = this.ctx.defaultSubscription.firstDatasource?.latestDataKeys;
+ for (const position of Object.keys(AggregatedValueCardKeyPosition)) {
+ const value = computeAggregatedCardValue(dataKeys, dataKey?.name, AggregatedValueCardKeyPosition[position]);
+ if (value) {
+ this.values[position] = value;
+ }
+ }
+ this.showValues = !!Object.keys(this.values).length;
+ }
+
+ this.showChart = this.settings.showChart;
+ this.chartColor = ColorProcessor.fromSettings(this.settings.chartColor);
+ if (this.showChart) {
+ if (this.ctx.defaultSubscription.firstDatasource?.dataKeys?.length) {
+ this.flotDataKey = this.ctx.defaultSubscription.firstDatasource?.dataKeys[0];
+ this.flotDataKey.settings = {
+ fillLines: false,
+ showLines: true,
+ lineWidth: 2
+ } as TbFlotKeySettings;
+ this.flotDataKey.color = this.chartColor.color;
+ }
+ }
+
+ this.showDate = this.settings.showDate;
+ this.dateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.dateFormat);
+ this.dateStyle = textStyle(this.settings.dateFont, '0.25px');
+ this.dateColor = ColorProcessor.fromSettings(this.settings.dateColor);
+
+ this.backgroundStyle = backgroundStyle(this.settings.background);
+ this.overlayStyle = overlayStyle(this.settings.background.overlay);
+ }
+
+ ngAfterViewInit(): void {
+ if (this.showChart) {
+ const settings = {
+ shadowSize: 0,
+ enableSelection: false,
+ smoothLines: false,
+ grid: {
+ tickColor: 'rgba(0,0,0,0.12)',
+ horizontalLines: true,
+ verticalLines: false,
+ outlineWidth: 0,
+ minBorderMargin: 0,
+ margin: 0
+ },
+ yaxis: {
+ showLabels: false,
+ tickGenerator: 'return [(axis.max + axis.min) / 2];'
+ },
+ xaxis: {
+ showLabels: false
+ }
+ } as TbFlotSettings;
+ this.flot = new TbFlot(this.ctx, 'line', $(this.chartElement.nativeElement), settings);
+ this.tickMin$ = this.flot.yMin$.pipe(
+ map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals),
+ (this.flotDataKey?.units || this.ctx.units))
+ ));
+ this.tickMax$ = this.flot.yMax$.pipe(
+ map((value) => formatValue(value, (this.flotDataKey?.decimals || this.ctx.decimals),
+ (this.flotDataKey?.units || this.ctx.units))
+ ));
+ }
+ }
+
+ public onInit() {
+ const borderRadius = this.ctx.$widgetElement.css('borderRadius');
+ this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
+ this.cd.detectChanges();
+ }
+
+ public onDataUpdated() {
+ const tsValue = getLatestSingleTsValue(this.ctx.data);
+ let ts;
+ let value;
+ if (tsValue) {
+ ts = tsValue[0];
+ value = tsValue[1];
+ }
+ this.subtitleColor.update(value);
+ this.dateColor.update(value);
+
+ if (this.showChart) {
+ this.chartColor.update(value);
+ this.flot.updateSeriesColor(this.chartColor.color);
+ this.flot.update();
+ }
+
+ this.updateLastUpdateTs(ts);
+ this.cd.detectChanges();
+ }
+
+ public onLatestDataUpdated() {
+ if (this.showValues) {
+ for (const aggValue of Object.values(this.values)) {
+ const tsValue = getTsValueByLatestDataKey(this.ctx.latestData, aggValue.key);
+ let ts;
+ let value;
+ if (tsValue) {
+ ts = tsValue[0];
+ value = tsValue[1];
+ aggValue.value = formatValue(value, (aggValue.key.decimals || this.ctx.decimals), null, false);
+ } else {
+ aggValue.value = 'N/A';
+ }
+ const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals));
+ aggValue.color.update(numeric);
+ if (aggValue.showArrow && isDefined(numeric)) {
+ aggValue.upArrow = numeric > 0;
+ aggValue.downArrow = numeric < 0;
+ } else {
+ aggValue.upArrow = aggValue.downArrow = false;
+ }
+ this.updateLastUpdateTs(ts);
+ }
+ this.cd.detectChanges();
+ }
+ }
+
+ public onResize() {
+ if (this.showChart) {
+ this.flot.resize();
+ }
+ }
+
+ public onEditModeChanged() {
+ if (this.showChart) {
+ this.flot.checkMouseEvents();
+ }
+ }
+
+ public onDestroy() {
+ if (this.showChart) {
+ this.flot.destroy();
+ }
+ }
+
+ private updateLastUpdateTs(ts: number) {
+ if (ts && (!this.lastUpdateTs || ts > this.lastUpdateTs)) {
+ this.lastUpdateTs = ts;
+ this.dateFormat.update(ts);
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts
new file mode 100644
index 0000000000..273ca1edaa
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card.models.ts
@@ -0,0 +1,232 @@
+///
+/// Copyright © 2016-2023 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 {
+ BackgroundSettings,
+ BackgroundType,
+ ColorProcessor,
+ ColorSettings,
+ ColorType,
+ ComponentStyle,
+ constantColor,
+ DateFormatSettings,
+ Font,
+ iconStyle,
+ lastUpdateAgoDateFormat,
+ textStyle
+} from '@shared/models/widget-settings.models';
+import { ComparisonResultType, DataKey, DatasourceData } from '@shared/models/widget.models';
+import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
+import { AggregationType } from '@shared/models/time/time.models';
+
+export interface AggregatedValueCardWidgetSettings {
+ showSubtitle: boolean;
+ subtitle: string;
+ subtitleFont: Font;
+ subtitleColor: ColorSettings;
+ showDate: boolean;
+ dateFormat: DateFormatSettings;
+ dateFont: Font;
+ dateColor: ColorSettings;
+ showChart: boolean;
+ chartColor: ColorSettings;
+ background: BackgroundSettings;
+}
+
+export enum AggregatedValueCardKeyPosition {
+ center = 'center',
+ rightTop = 'rightTop',
+ rightBottom = 'rightBottom',
+ leftTop = 'leftTop',
+ leftBottom = 'leftBottom'
+}
+
+export const aggregatedValueCardKeyPositionTranslations = new Map(
+ [
+ [AggregatedValueCardKeyPosition.center, 'widgets.aggregated-value-card.position-center'],
+ [AggregatedValueCardKeyPosition.rightTop, 'widgets.aggregated-value-card.position-right-top'],
+ [AggregatedValueCardKeyPosition.rightBottom, 'widgets.aggregated-value-card.position-right-bottom'],
+ [AggregatedValueCardKeyPosition.leftTop, 'widgets.aggregated-value-card.position-left-top'],
+ [AggregatedValueCardKeyPosition.leftBottom, 'widgets.aggregated-value-card.position-left-bottom']
+ ]
+);
+
+export interface AggregatedValueCardKeySettings {
+ position: AggregatedValueCardKeyPosition;
+ font: Font;
+ color: ColorSettings;
+ showArrow: boolean;
+}
+
+export interface AggregatedValueCardValue {
+ key: DataKey;
+ value: string;
+ units: string;
+ style: ComponentStyle;
+ color: ColorProcessor;
+ center: boolean;
+ showArrow: boolean;
+ upArrow: boolean;
+ downArrow: boolean;
+}
+
+export const computeAggregatedCardValue = (dataKeys: DataKey[], keyName: string, position: AggregatedValueCardKeyPosition): AggregatedValueCardValue => {
+ const key = dataKeys.find(dataKey => ( dataKey.name === keyName && (dataKey.settings?.position === position ||
+ (!dataKey.settings?.position && position === AggregatedValueCardKeyPosition.center)) ));
+ if (key) {
+ const settings: AggregatedValueCardKeySettings = key.settings;
+ return {
+ key,
+ value: '',
+ units: key.units,
+ style: textStyle(settings.font, '0.25px'),
+ color: ColorProcessor.fromSettings(settings.color),
+ center: position === AggregatedValueCardKeyPosition.center,
+ showArrow: settings.showArrow,
+ upArrow: false,
+ downArrow: false
+ };
+ }
+};
+
+export const getTsValueByLatestDataKey = (latestData: Array, dataKey: DataKey): [number, any] => {
+ if (latestData?.length) {
+ const dsData = latestData.find(data => data.dataKey === dataKey);
+ if (dsData?.data?.length) {
+ return dsData.data[0];
+ }
+ }
+ return null;
+};
+
+export const aggregatedValueCardDefaultSettings: AggregatedValueCardWidgetSettings = {
+ showSubtitle: true,
+ subtitle: '${entityName}',
+ subtitleFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '16px'
+ },
+ subtitleColor: constantColor('rgba(0, 0, 0, 0.38)'),
+ showDate: true,
+ dateFormat: lastUpdateAgoDateFormat(),
+ dateFont: {
+ family: 'Roboto',
+ size: 12,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '16px'
+ },
+ dateColor: constantColor('rgba(0, 0, 0, 0.38)'),
+ showChart: true,
+ chartColor: constantColor('rgba(0, 0, 0, 0.87)'),
+ background: {
+ type: BackgroundType.color,
+ color: '#fff',
+ overlay: {
+ enabled: false,
+ color: 'rgba(255,255,255,0.72)',
+ blur: 3
+ }
+ }
+};
+
+export const aggregatedValueCardDefaultKeySettings: AggregatedValueCardKeySettings = {
+ position: AggregatedValueCardKeyPosition.center,
+ font: {
+ family: 'Roboto',
+ size: 14,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '500',
+ lineHeight: '1'
+ },
+ color: constantColor('rgba(0, 0, 0, 0.87)'),
+ showArrow: false
+};
+
+export const createDefaultAggregatedValueLatestDataKeys = (keyName: string, units): DataKey[] => [
+ {
+ name: keyName, label: keyName, type: DataKeyType.timeseries, units, decimals: 0,
+ aggregationType: AggregationType.NONE,
+ settings: {
+ position: AggregatedValueCardKeyPosition.center,
+ font: {
+ family: 'Roboto',
+ size: 52,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '500',
+ lineHeight: '1'
+ },
+ color: constantColor('rgba(0, 0, 0, 0.87)'),
+ showArrow: false
+ } as AggregatedValueCardKeySettings
+ },
+ {
+ name: keyName, label: 'Delta percent ' + keyName, type: DataKeyType.timeseries, units: '%', decimals: 0,
+ aggregationType: AggregationType.AVG,
+ comparisonEnabled: true,
+ timeForComparison: 'previousInterval',
+ comparisonResultType: ComparisonResultType.DELTA_PERCENT,
+ settings: {
+ position: AggregatedValueCardKeyPosition.rightTop,
+ font: {
+ family: 'Roboto',
+ size: 14,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '500',
+ lineHeight: '1'
+ },
+ color: {
+ color: 'rgba(0, 0, 0, 0.87)',
+ type: ColorType.range,
+ rangeList: [
+ {to: 0, color: '#198038'},
+ {from: 0, to: 0, color: 'rgba(0, 0, 0, 0.87)'},
+ {from: 0, color: '#D12730'}
+ ],
+ colorFunction: ''
+ },
+ showArrow: true
+ } as AggregatedValueCardKeySettings
+ },
+ {
+ name: keyName, label: 'Delta absolute ' + keyName, type: DataKeyType.timeseries, units, decimals: 1,
+ aggregationType: AggregationType.AVG,
+ comparisonEnabled: true,
+ timeForComparison: 'previousInterval',
+ comparisonResultType: ComparisonResultType.DELTA_ABSOLUTE,
+ settings: {
+ position: AggregatedValueCardKeyPosition.rightBottom,
+ font: {
+ family: 'Roboto',
+ size: 11,
+ sizeUnit: 'px',
+ style: 'normal',
+ weight: '400',
+ lineHeight: '1'
+ },
+ color: constantColor('rgba(0, 0, 0, 0.38)'),
+ showArrow: false
+ } as AggregatedValueCardKeySettings
+ }
+ ];
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html
index fee7dc5259..377e0a31f8 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html
@@ -66,7 +66,7 @@
{{ label$ | async }}
- {{ dateFormat.formatted }}
+
{{ valueText }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
index 64267a5637..907878368d 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
@@ -83,7 +83,7 @@ export function loadNodeCtxFunction any>(functionB
}
export function materialIconHtml(materialIcon: string): string {
- return '' + materialIcon + '';
+ return '' + materialIcon + '';
}
export function iconUrlHtml(iconUrl: string): string {
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts
index 844d7540ff..7faa9e9de3 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts
@@ -132,6 +132,7 @@ export interface TbFlotYAxisSettings {
ticksFormatter: string;
tickDecimals: number;
tickSize: number;
+ tickGenerator: string;
}
export interface TbFlotBaseSettings {
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts
index 1d20068e20..e38bb3b098 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts
@@ -18,7 +18,8 @@
import { WidgetContext } from '@home/models/widget-component.models';
import {
createLabelFromDatasource,
- deepClone, formattedDataFormDatasourceData,
+ deepClone,
+ formattedDataFormDatasourceData,
insertVariable,
isDefined,
isDefinedAndNotNull,
@@ -59,6 +60,7 @@ import { AggregationType } from '@shared/models/time/time.models';
import { CancelAnimationFrame } from '@core/services/raf.service';
import { UtilsService } from '@core/services/utils.service';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
+import { BehaviorSubject } from 'rxjs';
import Timeout = NodeJS.Timeout;
const moment = moment_;
@@ -130,9 +132,15 @@ export class TbFlot {
private pieAnimationLastTime: number;
private pieAnimationCaf: CancelAnimationFrame;
- constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery) {
+ private yMinSubject = new BehaviorSubject(-1);
+ private yMaxSubject = new BehaviorSubject(1);
+
+ yMin$ = this.yMinSubject.asObservable();
+ yMax$ = this.yMaxSubject.asObservable();
+
+ constructor(private ctx: WidgetContext, private readonly chartType: ChartType, private $flotElement?: JQuery, settings?: TbFlotSettings) {
this.chartType = this.chartType || 'line';
- this.settings = ctx.settings as TbFlotSettings;
+ this.settings = settings || (ctx.settings as TbFlotSettings);
this.utils = this.ctx.$injector.get(UtilsService);
this.enableSelection = isDefined(this.settings.enableSelection) ? this.settings.enableSelection : true;
this.selectionMode = this.enableSelection ? 'x' : null;
@@ -209,6 +217,12 @@ export class TbFlot {
} else {
this.yaxis.tickSize = null;
}
+ if (this.settings.yaxis.tickGenerator?.length) {
+ try {
+ this.yaxis.ticks = new Function('axis',
+ this.settings.yaxis.tickGenerator);
+ } catch (e) {}
+ }
if (isNumber(this.settings.yaxis.tickDecimals)) {
this.yaxis.tickDecimals = this.settings.yaxis.tickDecimals;
} else {
@@ -717,6 +731,15 @@ export class TbFlot {
}
}
+ public updateSeriesColor(color: string) {
+ if (this.subscription?.data?.length) {
+ const series = this.subscription.data[0] as TbFlotSeries;
+ series.dataKey.color = color;
+ series.color = color;
+ series.highlightColor = tinycolor(color).setAlpha(.75).toRgbString();
+ }
+ }
+
private latestDataByDataIndex(index: number): FormattedData {
if (this.latestData[index]) {
return this.latestData[index];
@@ -802,6 +825,8 @@ export class TbFlot {
clearTimeout(this.resizeTimeoutHandle);
this.resizeTimeoutHandle = null;
}
+ this.yMinSubject.complete();
+ this.yMaxSubject.complete();
}
private createPlot() {
@@ -818,6 +843,7 @@ export class TbFlot {
} else {
this.plot = $.plot(this.$element, this.subscription.data, this.options) as JQueryPlot;
}
+ this.updateYMinMax();
} else {
this.createPlotTimeoutHandle = setTimeout(this.createPlot.bind(this), 30);
}
@@ -830,6 +856,20 @@ export class TbFlot {
this.plot.setupGrid();
}
this.plot.draw();
+ this.updateYMinMax();
+ }
+
+ private updateYMinMax() {
+ if (this.plot?.getYAxes().length) {
+ const min = this.plot?.getYAxes()[0].min;
+ const max = this.plot?.getYAxes()[0].max;
+ if (this.yMinSubject.value !== min) {
+ this.yMinSubject.next(min);
+ }
+ if (this.yMaxSubject.value !== max) {
+ this.yMaxSubject.next(max);
+ }
+ }
}
private redrawPlot() {
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html
new file mode 100644
index 0000000000..32d48347ac
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html
@@ -0,0 +1,48 @@
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts
new file mode 100644
index 0000000000..e527c3b3d4
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts
@@ -0,0 +1,64 @@
+///
+/// Copyright © 2016-2023 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 } from '@angular/core';
+import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
+import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import {
+ aggregatedValueCardDefaultKeySettings,
+ AggregatedValueCardKeyPosition,
+ aggregatedValueCardKeyPositionTranslations
+} from '@home/components/widget/lib/cards/aggregated-value-card.models';
+import { constantColor } from '@shared/models/widget-settings.models';
+
+@Component({
+ selector: 'tb-aggregated-value-card-key-settings',
+ templateUrl: './aggregated-value-card-key-settings.component.html',
+ styleUrls: ['./../widget-settings.scss']
+})
+export class AggregatedValueCardKeySettingsComponent extends WidgetSettingsComponent {
+
+ aggregatedValueCardKeyPositions: AggregatedValueCardKeyPosition[] =
+ Object.keys(AggregatedValueCardKeyPosition).map(value => AggregatedValueCardKeyPosition[value]);
+
+ aggregatedValueCardKeyPositionTranslationMap = aggregatedValueCardKeyPositionTranslations;
+
+ aggregatedValueCardKeySettingsForm: UntypedFormGroup;
+
+ constructor(protected store: Store,
+ private fb: UntypedFormBuilder) {
+ super(store);
+ }
+
+ protected settingsForm(): UntypedFormGroup {
+ return this.aggregatedValueCardKeySettingsForm;
+ }
+
+ protected defaultSettings(): WidgetSettings {
+ return {...aggregatedValueCardDefaultKeySettings};
+ }
+
+ protected onSettingsSet(settings: WidgetSettings) {
+ this.aggregatedValueCardKeySettingsForm = this.fb.group({
+ position: [settings.position, []],
+ font: [settings.font, []],
+ color: [settings.color, []],
+ showArrow: [settings.showArrow, []]
+ });
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts
index 46454937f6..9241f60dda 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts
@@ -74,7 +74,8 @@ export const flotDefaultSettings = (chartType: ChartType): Partial
- mdi:function-variant
+ mdi:function-variant
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts
index 6d8ad1eefe..f92203459e 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts
@@ -99,7 +99,7 @@ export class ColorSettingsComponent implements OnInit, ControlValueAccessor {
}
private updateColorStyle() {
- if (!this.disabled) {
+ if (!this.disabled && this.modelValue) {
let colors: string[] = [this.modelValue.color];
if (this.modelValue.type === ColorType.range && this.modelValue.rangeList?.length) {
const rangeColors = this.modelValue.rangeList.slice(0, Math.min(2, this.modelValue.rangeList.length)).map(r => r.color);
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
index 1e4010faaa..0f467ba917 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
@@ -267,6 +267,9 @@ import {
ValueCardWidgetSettingsComponent
} from '@home/components/widget/lib/settings/cards/value-card-widget-settings.component';
import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings/common/widget-settings-common.module';
+import {
+ AggregatedValueCardKeySettingsComponent
+} from '@home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component';
@NgModule({
declarations: [
@@ -366,7 +369,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings
TripAnimationWidgetSettingsComponent,
DocLinksWidgetSettingsComponent,
QuickLinksWidgetSettingsComponent,
- ValueCardWidgetSettingsComponent
+ ValueCardWidgetSettingsComponent,
+ AggregatedValueCardKeySettingsComponent
],
imports: [
CommonModule,
@@ -471,7 +475,8 @@ import { WidgetSettingsCommonModule } from '@home/components/widget/lib/settings
TripAnimationWidgetSettingsComponent,
DocLinksWidgetSettingsComponent,
QuickLinksWidgetSettingsComponent,
- ValueCardWidgetSettingsComponent
+ ValueCardWidgetSettingsComponent,
+ AggregatedValueCardKeySettingsComponent
]
})
export class WidgetSettingsModule {
@@ -541,5 +546,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss
new file mode 100644
index 0000000000..ee37181a09
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.scss
@@ -0,0 +1,24 @@
+/**
+ * Copyright © 2016-2023 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 {
+ .notification-type {
+ font-size: 14px;
+
+ &-disabled {
+ color: rgba(0, 0, 0, 0.38)
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts
new file mode 100644
index 0000000000..76e9babf00
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts
@@ -0,0 +1,126 @@
+///
+/// Copyright © 2016-2023 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
+import { UtilsService } from '@core/services/utils.service';
+import { isDefinedAndNotNull } from '@core/utils';
+import { Subscription } from 'rxjs';
+import {
+ NotificationDeliveryMethod,
+ NotificationTemplateTypeTranslateMap,
+ NotificationUserSetting
+} from '@shared/models/notification.models';
+
+@Component({
+ selector: 'tb-notification-setting-form',
+ templateUrl: './notification-setting-form.component.html',
+ styleUrls: ['./notification-setting-form.component.scss'],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => NotificationSettingFormComponent),
+ multi: true
+ }
+ ]
+})
+export class NotificationSettingFormComponent implements ControlValueAccessor, OnInit, OnDestroy {
+
+ @Input()
+ disabled: boolean;
+
+ @Input()
+ deliveryMethods: NotificationDeliveryMethod[] = [];
+
+ @Input()
+ allowDeliveryMethods: NotificationDeliveryMethod[] = [];
+
+ notificationSettingsFormGroup: UntypedFormGroup;
+
+ notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap;
+
+ private propagateChange = null;
+
+ private valueChange$: Subscription = null;
+
+ constructor(private utils: UtilsService,
+ private fb: UntypedFormBuilder) {
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn: any): void {
+ }
+
+ ngOnInit() {
+ const deliveryMethod = {};
+ this.deliveryMethods.forEach(value => {
+ deliveryMethod[value] = true;
+ });
+ this.notificationSettingsFormGroup = this.fb.group(
+ {
+ name: [''],
+ enabled: [true],
+ enabledDeliveryMethods: this.fb.group({
+ ...deliveryMethod
+ })
+ });
+ this.valueChange$ = this.notificationSettingsFormGroup.valueChanges.subscribe(() => {
+ this.updateModel();
+ });
+ }
+
+ ngOnDestroy() {
+ if (this.valueChange$) {
+ this.valueChange$.unsubscribe();
+ this.valueChange$ = null;
+ }
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ if (this.disabled) {
+ this.notificationSettingsFormGroup.disable({emitEvent: false});
+ } else {
+ this.notificationSettingsFormGroup.enable({emitEvent: false});
+ }
+ }
+
+ toggleEnabled() {
+ this.notificationSettingsFormGroup.get('enabled').patchValue(!this.notificationSettingsFormGroup.get('enabled').value);
+ }
+
+ getChecked(deliveryMethod: NotificationDeliveryMethod): boolean {
+ return this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value;
+ }
+
+ toggleDeliviryMethod(deliveryMethod: NotificationDeliveryMethod) {
+ this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod)
+ .patchValue(!this.notificationSettingsFormGroup.get('enabledDeliveryMethods').get(deliveryMethod).value);
+ }
+
+ writeValue(value: NotificationUserSetting): void {
+ if (isDefinedAndNotNull(value)) {
+ this.notificationSettingsFormGroup.patchValue(value, {emitEvent: false});
+ }
+ }
+
+ private updateModel() {
+ this.propagateChange(this.notificationSettingsFormGroup.value);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts
new file mode 100644
index 0000000000..6a65e554bf
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings-routing.modules.ts
@@ -0,0 +1,41 @@
+///
+/// Copyright © 2016-2023 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 { Routes } from '@angular/router';
+import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
+import { Authority } from '@shared/models/authority.enum';
+import { inject, NgModule } from '@angular/core';
+import { NotificationSettingsComponent } from '@home/pages/notification/settings/notification-settings.component';
+import { NotificationService } from '@core/http/notification.service';
+
+export const notificationUserSettingsRoutes: Routes = [
+ {
+ path: 'notificationSettings',
+ component: NotificationSettingsComponent,
+ canDeactivate: [ConfirmOnExitGuard],
+ data: {
+ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],
+ title: 'account.notification-settings',
+ breadcrumb: {
+ label: 'account.notification-settings',
+ icon: 'settings'
+ }
+ },
+ resolve: {
+ userSettings: () => inject(NotificationService).getNotificationUserSettings()
+ }
+ }
+];
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html
new file mode 100644
index 0000000000..c531f10d78
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ notification.settings.notification-settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.scss b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.scss
new file mode 100644
index 0000000000..a91c57ed65
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.scss
@@ -0,0 +1,41 @@
+/**
+ * Copyright © 2016-2023 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 "../../../../../../scss/constants";
+
+:host {
+ .mat-mdc-card.settings-card {
+ margin: 8px;
+ @media #{$mat-gt-sm} {
+ width: 60%;
+ }
+ .mat-headline-5 {
+ margin: 0;
+ }
+ .notification-form {
+ height: 100%;
+ min-height: min-content;
+ max-height: min-content;
+ }
+ .notification-section {
+ height: 100%;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ overflow: scroll;
+ &-block {
+ min-width: 470px;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts
new file mode 100644
index 0000000000..c28e030eec
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts
@@ -0,0 +1,188 @@
+///
+/// Copyright © 2016-2023 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 { PageComponent } from '@shared/components/page.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
+import { TranslateService } from '@ngx-translate/core';
+import { ActivatedRoute } from '@angular/router';
+import { deepClone, isDefinedAndNotNull } from '@core/utils';
+import {
+ NotificationDeliveryMethod,
+ NotificationDeliveryMethodTranslateMap,
+ NotificationUserSettings
+} from '@shared/models/notification.models';
+import { NotificationService } from '@core/http/notification.service';
+import { DialogService } from '@core/services/dialog.service';
+
+@Component({
+ selector: 'tb-notification-settings',
+ templateUrl: './notification-settings.component.html',
+ styleUrls: ['./notification-settings.component.scss']
+})
+export class NotificationSettingsComponent extends PageComponent implements OnInit, HasConfirmForm {
+
+ notificationSettings: UntypedFormGroup;
+
+ notificationDeliveryMethods: NotificationDeliveryMethod[];
+ notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap;
+
+ allowNotificationDeliveryMethods: Array;
+
+ constructor(protected store: Store,
+ private route: ActivatedRoute,
+ private translate: TranslateService,
+ private dialogService: DialogService,
+ private notificationService: NotificationService,
+ private fb: UntypedFormBuilder,) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.notificationDeliveryMethods = this.getNotificationDeliveryMethods();
+
+ this.notificationService.getAvailableDeliveryMethods({ignoreLoading: true}).subscribe(allowMethods => {
+ this.allowNotificationDeliveryMethods = allowMethods;
+ });
+
+ this.buildNotificationSettingsForm();
+ this.patchNotificationSettings(this.route.snapshot.data.userSettings);
+ }
+
+ private getNotificationDeliveryMethods(): NotificationDeliveryMethod[] {
+ const deliveryMethods = new Set([
+ NotificationDeliveryMethod.SLACK
+ ]);
+ return Object.values(NotificationDeliveryMethod).filter(type => !deliveryMethods.has(type));
+ }
+
+ private buildNotificationSettingsForm() {
+ this.notificationSettings = this.fb.group({
+ prefs: this.fb.array([])
+ });
+ }
+
+ private patchNotificationSettings(settings: NotificationUserSettings) {
+ const notificationSettingsControls: Array = [];
+ let preparedSettings;
+ if (settings.prefs) {
+ preparedSettings = this.prepareNotificationSettings(settings.prefs);
+ preparedSettings.forEach((setting) => {
+ setting.enabledDeliveryMethods = Object.assign(
+ this.notificationDeliveryMethods.reduce((a, v) => ({ ...a, [v]: true}), {}),
+ setting.enabledDeliveryMethods
+ );
+ notificationSettingsControls.push(this.fb.control(setting, [Validators.required]));
+ });
+ }
+ this.notificationSettings.setControl('prefs', this.fb.array(notificationSettingsControls), {emitEvent: false});
+ }
+
+ private prepareNotificationSettings(prefs: any) {
+ return Object.entries(prefs).map((value: any) => {
+ value[1].name = value[0];
+ return value[1];
+ });
+ }
+
+ resetSettings() {
+ this.dialogService.confirm(
+ this.translate.instant('notification.settings.reset-all-title'),
+ this.translate.instant('notification.settings.reset-all-text'),
+ this.translate.instant('action.no'),
+ this.translate.instant('action.yes'),
+ true
+ ).subscribe(
+ result => {
+ if (result) {
+ const settings = this.prepareNotificationSettings(this.route.snapshot.data.userSettings.prefs);
+ const notificationSettingsControls: Array = [];
+ this.notificationSettings.reset({});
+ if (settings) {
+ settings.forEach((setting) => {
+ setting.enabled = true;
+ setting.enabledDeliveryMethods = this.notificationDeliveryMethods.reduce((a, v) => ({ ...a, [v]: true}), {});
+ notificationSettingsControls.push(this.fb.control(setting, [Validators.required]));
+ });
+ }
+ this.notificationSettings.setControl('prefs', this.fb.array(notificationSettingsControls), {emitEvent: false});
+ this.save();
+ }
+ }
+ );
+ }
+
+ getChecked = (method: NotificationDeliveryMethod = null): boolean => {
+ const type = this.notificationSettings.get('prefs').value;
+ if (isDefinedAndNotNull(method)) {
+ return isDefinedAndNotNull(type) && type.every(resource => resource.enabledDeliveryMethods[method]);
+ }
+ return isDefinedAndNotNull(type) && type.every(resource => resource.enabled);
+ };
+
+ getSomeChecked = () => {
+ const type = this.notificationSettings.get('prefs').value;
+ return isDefinedAndNotNull(type) && type.some(resource => resource.enabled);
+ };
+
+ getIndeterminate = (deliveryMethod: NotificationDeliveryMethod = null): boolean => {
+ const type = this.notificationSettings.get('prefs').value;
+ if (isDefinedAndNotNull(type)) {
+ const checkedResource = isDefinedAndNotNull(deliveryMethod) ?
+ type.filter(resource => resource.enabledDeliveryMethods[deliveryMethod]) :
+ type.filter(resource => resource.enabled);
+ return checkedResource.length !== 0 && checkedResource.length !== type.length;
+ }
+ return false;
+ };
+
+ changeInstanceTypeCheckBox = (value: boolean, deliveryMethod: NotificationDeliveryMethod = null): void => {
+ const type = deepClone(this.notificationSettings.get('prefs').value);
+ if (isDefinedAndNotNull(deliveryMethod)) {
+ type.forEach(notificationType => notificationType.enabledDeliveryMethods[deliveryMethod] = value);
+ } else {
+ type.forEach(notificationType => notificationType.enabled = value);
+ }
+ this.notificationSettings.get('prefs').patchValue(type);
+ this.notificationSettings.markAsDirty();
+ };
+
+ get notificationSettingsFormArray(): UntypedFormArray {
+ return this.notificationSettings.get('prefs') as UntypedFormArray;
+ }
+
+ save(): void {
+ const settings = {prefs: {}};
+ this.notificationSettings.getRawValue().prefs.forEach(value => {
+ const key = value.name;
+ delete value.name;
+ settings.prefs[key] = value;
+ });
+ this.notificationService.saveNotificationUserSettings(settings).subscribe(
+ (userSettings) => {
+ this.notificationSettings.get('prefs').reset({});
+ this.patchNotificationSettings(userSettings);
+ }
+ );
+ }
+
+ confirmForm(): UntypedFormGroup {
+ return this.notificationSettings;
+ }
+}
diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts
index ba8b4ba114..6205667245 100644
--- a/ui-ngx/src/app/shared/components/time/timewindow.component.ts
+++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts
@@ -89,6 +89,11 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan
return this.historyOnlyValue;
}
+ get displayTypePrefix(): boolean {
+ return isDefinedAndNotNull(this.computedTimewindowStyle?.displayTypePrefix)
+ ? this.computedTimewindowStyle?.displayTypePrefix : true;
+ }
+
@HostBinding('class.no-margin')
@Input()
@coerceBoolean()
@@ -198,6 +203,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'timewindowStyle') {
this.updateTimewindowStyle();
+ this.updateDisplayValue();
}
}
}
@@ -316,7 +322,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan
updateDisplayValue() {
if (this.innerValue.selectedTab === TimewindowType.REALTIME && !this.historyOnly) {
- this.innerValue.displayValue = this.translate.instant('timewindow.realtime') + ' - ';
+ this.innerValue.displayValue = this.displayTypePrefix ? (this.translate.instant('timewindow.realtime') + ' - ') : '';
if (this.innerValue.realtime.realtimeType === RealtimeWindowType.INTERVAL) {
this.innerValue.displayValue += this.translate.instant(QuickTimeIntervalTranslationMap.get(this.innerValue.realtime.quickInterval));
} else {
@@ -324,7 +330,7 @@ export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChan
this.millisecondsToTimeStringPipe.transform(this.innerValue.realtime.timewindowMs);
}
} else {
- this.innerValue.displayValue = (!this.historyOnly || this.alwaysDisplayTypePrefix) ?
+ this.innerValue.displayValue = this.displayTypePrefix && (!this.historyOnly || this.alwaysDisplayTypePrefix) ?
(this.translate.instant('timewindow.history') + ' - ') : '';
if (this.innerValue.history.historyType === HistoryWindowType.LAST_INTERVAL) {
this.innerValue.displayValue += this.translate.instant('timewindow.last-prefix') + ' ' +
diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts
index 047ec7b38d..7de754dadf 100644
--- a/ui-ngx/src/app/shared/models/notification.models.ts
+++ b/ui-ngx/src/app/shared/models/notification.models.ts
@@ -612,3 +612,12 @@ export const TriggerTypeTranslationMap = new Map([
[TriggerType.NEW_PLATFORM_VERSION, 'notification.trigger.new-platform-version'],
[TriggerType.RATE_LIMITS, 'notification.trigger.rate-limits'],
]);
+
+export interface NotificationUserSettings {
+ prefs: {[key: string]: NotificationUserSetting};
+}
+
+export interface NotificationUserSetting {
+ enabled: boolean;
+ enabledDeliveryMethods: {[key: string]: boolean};
+}
diff --git a/ui-ngx/src/app/shared/models/widget-settings.models.ts b/ui-ngx/src/app/shared/models/widget-settings.models.ts
index 18782cdd93..fb15622754 100644
--- a/ui-ngx/src/app/shared/models/widget-settings.models.ts
+++ b/ui-ngx/src/app/shared/models/widget-settings.models.ts
@@ -17,7 +17,7 @@
import { isDefinedAndNotNull, isNumber, isNumeric, parseFunction } from '@core/utils';
import { DataKey, Datasource, DatasourceData } from '@shared/models/widget.models';
import { Injector } from '@angular/core';
-import { DatePipe, formatDate } from '@angular/common';
+import { DatePipe } from '@angular/common';
import { DateAgoPipe } from '@shared/pipe/date-ago.pipe';
import { TranslateService } from '@ngx-translate/core';
@@ -97,13 +97,15 @@ export interface TimewindowStyle {
iconPosition: 'left' | 'right';
font?: Font;
color?: string;
+ displayTypePrefix?: boolean;
}
export const defaultTimewindowStyle: TimewindowStyle = {
showIcon: true,
icon: 'query_builder',
iconSize: '24px',
- iconPosition: 'left'
+ iconPosition: 'left',
+ displayTypePrefix: true
};
export const constantColor = (color: string): ColorSettings => ({
@@ -117,18 +119,44 @@ export const constantColor = (color: string): ColorSettings => ({
'return \'blue\';'
});
+export const cssSizeToStrSize = (size?: number, unit?: cssUnit): string => (isDefinedAndNotNull(size) ? size + '' : '0') + (unit || 'px');
+
+export const resolveCssSize = (strSize?: string): [number, cssUnit] => {
+ if (!strSize || !strSize.trim().length) {
+ return [0, 'px'];
+ }
+ let resolvedUnit: cssUnit;
+ let resolvedSize = strSize;
+ for (const unit of cssUnits) {
+ if (strSize.endsWith(unit)) {
+ resolvedUnit = unit;
+ break;
+ }
+ }
+ if (resolvedUnit) {
+ resolvedSize = strSize.substring(0, strSize.length - resolvedUnit.length);
+ }
+ resolvedUnit = resolvedUnit || 'px';
+ let numericSize = 0;
+ if (isNumeric(resolvedSize)) {
+ numericSize = Number(resolvedSize);
+ }
+ return [numericSize, resolvedUnit];
+};
+
type ValueColorFunction = (value: any) => string;
export abstract class ColorProcessor {
static fromSettings(color: ColorSettings): ColorProcessor {
- switch (color.type) {
+ const settings = color || constantColor('rgba(0, 0, 0, 0.87)');
+ switch (settings.type) {
case ColorType.constant:
- return new ConstantColorProcessor(color);
+ return new ConstantColorProcessor(settings);
case ColorType.range:
- return new RangeColorProcessor(color);
+ return new RangeColorProcessor(settings);
case ColorType.function:
- return new FunctionColorProcessor(color);
+ return new FunctionColorProcessor(settings);
}
}
@@ -164,13 +192,19 @@ class RangeColorProcessor extends ColorProcessor {
if (this.settings.rangeList?.length && isDefinedAndNotNull(value) && isNumeric(value)) {
const num = Number(value);
for (const range of this.settings.rangeList) {
- if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) {
+ if (this.constantRange(range) && range.from === num) {
+ return range.color;
+ } else if ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)) {
return range.color;
}
}
}
return this.settings.color;
}
+
+ private constantRange(range: ColorRange): boolean {
+ return isNumber(range.from) && isNumber(range.to) && range.from === range.to;
+ }
}
class FunctionColorProcessor extends ColorProcessor {
@@ -242,7 +276,7 @@ export abstract class DateFormatProcessor {
}
}
- formatted = '';
+ formatted = ' ';
protected constructor(protected $injector: Injector,
protected settings: DateFormatSettings) {
@@ -412,3 +446,13 @@ export const getSingleTsValue = (data: Array): [number, any] =>
}
return null;
};
+
+export const getLatestSingleTsValue = (data: Array): [number, any] => {
+ if (data.length) {
+ const dsData = data[0];
+ if (dsData.data.length) {
+ return dsData.data[dsData.data.length - 1];
+ }
+ }
+ return null;
+};
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index a4d8c70363..562cc91926 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -11,7 +11,8 @@
"permission-denied-text": "You don't have permission to perform this operation!"
},
"account": {
- "account": "Account"
+ "account": "Account",
+ "notification-settings": "Notification settings"
},
"action": {
"activate": "Activate",
@@ -1226,7 +1227,11 @@
"delta-calculation-result-delta-absolute": "Delta (absolute)",
"delta-calculation-result-delta-percent": "Delta (percent)",
"source": "Source",
- "latest": "Latest"
+ "latest": "Latest",
+ "latest-value": "Latest value",
+ "delta": "delta",
+ "percent": "percent",
+ "absolute": "absolute"
},
"datasource": {
"type": "Datasource type",
@@ -3154,7 +3159,17 @@
"webhook-url": "Webhook URL",
"webhook-url-required": "Webhook URL is required",
"channel-name": "Channel name",
- "channel-name-required": "Channel name is required"
+ "channel-name-required": "Channel name is required",
+ "settings": {
+ "notification-settings": "Notification settings",
+ "reset-all": "Reset all settings",
+ "reset-all-title": "Are you sure you want to reset form?",
+ "reset-all-text": "After the confirmation, the settings form will reset to the default value and save.",
+ "type": "Type",
+ "enable-all": "Enable all",
+ "disable-all": "Disable all",
+ "delivery-not-configured": "Delivery method is not configured"
+ }
},
"ota-update": {
"add": "Add package",
@@ -3938,6 +3953,7 @@
"icon-position-right": "Right",
"font": "Font",
"color": "Color",
+ "displayTypePrefix": "Display Realtime/History prefix",
"preview": "Preview"
},
"unit": {
@@ -5745,6 +5761,25 @@
"date": "Date",
"value-card-style": "Value card style"
},
+ "aggregated-value-card": {
+ "subtitle": "Subtitle",
+ "chart": "Chart",
+ "values": "Values",
+ "value-appearance": "Value appearance",
+ "position": "Position",
+ "position-center": "Center",
+ "position-right-top": "Right top",
+ "position-right-bottom": "Right bottom",
+ "position-left-top": "Left top",
+ "position-left-bottom": "Left bottom",
+ "font": "Font",
+ "color": "Color",
+ "display-up-down-arrow": "Display Up/Down arrow",
+ "add-value": "Add value",
+ "remove-value": "Remove value",
+ "no-values": "No values configured",
+ "aggregation": "Aggregation"
+ },
"table": {
"common-table-settings": "Common Table Settings",
"enable-search": "Enable search",
diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss
index 8bf8aef2ab..c5115add1b 100644
--- a/ui-ngx/src/form.scss
+++ b/ui-ngx/src/form.scss
@@ -122,18 +122,6 @@
font: inherit;
}
}
- .mat-slide {
- margin: 0;
- &.margin {
- margin: 8px 0;
- }
- .mdc-form-field>label {
- font-weight: 400;
- font-size: 16px;
- line-height: 24px;
- margin-left: 12px;
- }
- }
}
.tb-form-panel-title {
@@ -200,6 +188,21 @@
}
}
+ .tb-form-panel, .tb-form-row {
+ .mat-slide {
+ margin: 0;
+ &.margin {
+ margin: 8px 0;
+ }
+ .mdc-form-field>label {
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 24px;
+ margin-left: 12px;
+ }
+ }
+ }
+
.tb-form-row .mat-mdc-form-field, .mat-mdc-form-field.tb-inline-field {
&.mat-form-field-appearance-fill {
.mdc-text-field--filled:not(.mdc-text-field--disabled) {
@@ -397,12 +400,18 @@
}
}
- .tb-form-table-row {
- &.tb-draggable {
- gap: 0;
- padding-left: 0;
- background: #fff;
+ .tb-draggable-form-table-row {
+ background: #fff;
+ display: flex;
+ flex-direction: row;
+ place-content: center flex-start;
+ align-items: center;
+ .tb-form-table-row {
+ padding-right: 0;
}
+ }
+
+ .tb-form-table-row {
&-cell {
color: rgba(0, 0, 0, 0.87);