Browse Source

UI: Alarm data query support

pull/3068/head
Igor Kulikov 6 years ago
parent
commit
4747e110bc
  1. 2
      application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java
  2. 5
      application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java
  3. 1
      application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java
  4. 21
      application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdateType.java
  5. 5
      application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java
  6. 176
      ui-ngx/src/app/core/api/alarm-data-subscription.ts
  7. 94
      ui-ngx/src/app/core/api/alarm-data.service.ts
  8. 17
      ui-ngx/src/app/core/api/widget-api.models.ts
  9. 205
      ui-ngx/src/app/core/api/widget-subscription.ts
  10. 15
      ui-ngx/src/app/core/ws/telemetry-websocket.service.ts
  11. 7
      ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html
  12. 243
      ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts
  13. 15
      ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts
  14. 17
      ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts
  15. 9
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  16. 4
      ui-ngx/src/app/shared/models/alarm.models.ts
  17. 56
      ui-ngx/src/app/shared/models/query/query.models.ts
  18. 96
      ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts

2
application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java

@ -256,7 +256,7 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc
} else { } else {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(0, maxEntitiesPerAlarmSubscription, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
EntityDataQuery edq = new EntityDataQuery(adq.getEntityFilter(), edpl, adq.getEntityFields(), adq.getLatestValues(), adq.getKeyFilters()); EntityDataQuery edq = new EntityDataQuery(adq.getEntityFilter(), edpl, adq.getEntityFields(), adq.getLatestValues(), adq.getKeyFilters());
PageData<EntityData> entitiesData = entityService.findEntityDataByQuery(ctx.getTenantId(), ctx.getCustomerId(), edq); PageData<EntityData> entitiesData = entityService.findEntityDataByQuery(ctx.getTenantId(), ctx.getCustomerId(), edq);
List<EntityData> entities = entitiesData.getData(); List<EntityData> entities = entitiesData.getData();

5
application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java

@ -35,6 +35,11 @@ public class AlarmDataUpdate extends DataUpdate<AlarmData> {
super(cmdId, null, null, errorCode, errorMsg); super(cmdId, null, null, errorCode, errorMsg);
} }
@Override
public DataUpdateType getDataUpdateType() {
return DataUpdateType.ALARM_DATA;
}
@JsonCreator @JsonCreator
public AlarmDataUpdate(@JsonProperty("cmdId") int cmdId, public AlarmDataUpdate(@JsonProperty("cmdId") int cmdId,
@JsonProperty("data") PageData<AlarmData> data, @JsonProperty("data") PageData<AlarmData> data,

1
application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java

@ -40,4 +40,5 @@ public abstract class DataUpdate<T> {
this(cmdId, null, null, errorCode, errorMsg); this(cmdId, null, null, errorCode, errorMsg);
} }
public abstract DataUpdateType getDataUpdateType();
} }

21
application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdateType.java

@ -0,0 +1,21 @@
/**
* Copyright © 2016-2020 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.telemetry.cmd.v2;
public enum DataUpdateType {
ENTITY_DATA,
ALARM_DATA
}

5
application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java

@ -33,6 +33,11 @@ public class EntityDataUpdate extends DataUpdate<EntityData> {
super(cmdId, null, null, errorCode, errorMsg); super(cmdId, null, null, errorCode, errorMsg);
} }
@Override
public DataUpdateType getDataUpdateType() {
return DataUpdateType.ENTITY_DATA;
}
@JsonCreator @JsonCreator
public EntityDataUpdate(@JsonProperty("cmdId") int cmdId, public EntityDataUpdate(@JsonProperty("cmdId") int cmdId,
@JsonProperty("data") PageData<EntityData> data, @JsonProperty("data") PageData<EntityData> data,

176
ui-ngx/src/app/core/api/alarm-data-subscription.ts

@ -0,0 +1,176 @@
///
/// Copyright © 2016-2020 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AlarmDataCmd,
DataKeyType,
TelemetryService,
TelemetrySubscriber
} from '@shared/models/telemetry/telemetry.models';
import { DatasourceType } from '@shared/models/widget.models';
import {
AlarmData,
AlarmDataPageLink,
EntityFilter,
EntityKey,
EntityKeyType,
KeyFilter
} from '@shared/models/query/query.models';
import { SubscriptionTimewindow } from '@shared/models/time/time.models';
import { AlarmDataListener } from '@core/api/alarm-data.service';
import { UtilsService } from '@core/services/utils.service';
import { PageData } from '@shared/models/page/page-data';
import { deepClone, isDefined, isDefinedAndNotNull, isObject } from '@core/utils';
import { simulatedAlarm } from '@shared/models/alarm.models';
export interface AlarmSubscriptionDataKey {
name: string;
type: DataKeyType;
}
export interface AlarmDataSubscriptionOptions {
datasourceType: DatasourceType;
dataKeys: Array<AlarmSubscriptionDataKey>;
entityFilter?: EntityFilter;
pageLink?: AlarmDataPageLink;
keyFilters?: Array<KeyFilter>;
additionalKeyFilters?: Array<KeyFilter>;
subscriptionTimewindow?: SubscriptionTimewindow;
}
export class AlarmDataSubscription {
private datasourceType: DatasourceType = this.alarmDataSubscriptionOptions.datasourceType;
private history: boolean;
private realtime: boolean;
private subscriber: TelemetrySubscriber;
private alarmDataCommand: AlarmDataCmd;
private pageData: PageData<AlarmData>;
private alarmIdToDataIndex: {[id: string]: number};
private subsTw: SubscriptionTimewindow;
constructor(public alarmDataSubscriptionOptions: AlarmDataSubscriptionOptions,
private listener: AlarmDataListener,
private telemetryService: TelemetryService,
private utils: UtilsService) {
}
public unsubscribe() {
if (this.datasourceType === DatasourceType.entity) {
if (this.subscriber) {
this.subscriber.unsubscribe();
this.subscriber = null;
}
}
}
public subscribe() {
this.subsTw = this.alarmDataSubscriptionOptions.subscriptionTimewindow;
this.history = this.alarmDataSubscriptionOptions.subscriptionTimewindow &&
isObject(this.alarmDataSubscriptionOptions.subscriptionTimewindow.fixedWindow);
this.realtime = this.alarmDataSubscriptionOptions.subscriptionTimewindow &&
isDefinedAndNotNull(this.alarmDataSubscriptionOptions.subscriptionTimewindow.realtimeWindowMs);
if (this.datasourceType === DatasourceType.entity) {
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.alarmDataCommand = new AlarmDataCmd();
const entityFields: Array<EntityKey> =
this.alarmDataSubscriptionOptions.dataKeys.filter(dataKey => dataKey.type === DataKeyType.entityField).map(
dataKey => ({ type: EntityKeyType.ENTITY_FIELD, key: dataKey.name })
);
const attrFields = this.alarmDataSubscriptionOptions.dataKeys.filter(dataKey => dataKey.type === DataKeyType.attribute).map(
dataKey => ({ type: EntityKeyType.ATTRIBUTE, key: dataKey.name })
);
const tsFields = this.alarmDataSubscriptionOptions.dataKeys.filter(dataKey => dataKey.type === DataKeyType.timeseries).map(
dataKey => ({ type: EntityKeyType.TIME_SERIES, key: dataKey.name })
);
const latestValues = attrFields.concat(tsFields);
let keyFilters = this.alarmDataSubscriptionOptions.keyFilters;
if (this.alarmDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
keyFilters = keyFilters.concat(this.alarmDataSubscriptionOptions.additionalKeyFilters);
} else {
keyFilters = this.alarmDataSubscriptionOptions.additionalKeyFilters;
}
}
this.alarmDataCommand.query = {
entityFilter: this.alarmDataSubscriptionOptions.entityFilter,
pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink),
keyFilters,
entityFields,
latestValues
};
if (this.history) {
this.alarmDataCommand.query.pageLink.startTs = this.subsTw.fixedWindow.startTimeMs;
this.alarmDataCommand.query.pageLink.endTs = this.subsTw.fixedWindow.endTimeMs;
} else {
this.alarmDataCommand.query.pageLink.timeWindow = this.subsTw.realtimeWindowMs;
}
this.subscriber.subscriptionCommands.push(this.alarmDataCommand);
this.subscriber.alarmData$.subscribe((alarmDataUpdate) => {
if (alarmDataUpdate.data) {
this.onPageData(alarmDataUpdate.data);
} else if (alarmDataUpdate.update) {
this.onDataUpdate(alarmDataUpdate.update);
}
});
this.subscriber.subscribe();
} else if (this.datasourceType === DatasourceType.function) {
const pageData: PageData<AlarmData> = {
data: [{...simulatedAlarm, entityId: '1', latest: {}}],
hasNext: false,
totalElements: 1,
totalPages: 1
};
this.onPageData(pageData);
}
}
private resetData() {
this.alarmIdToDataIndex = {};
for (let dataIndex = 0; dataIndex < this.pageData.data.length; dataIndex++) {
const alarmData = this.pageData.data[dataIndex];
this.alarmIdToDataIndex[alarmData.id.id] = dataIndex;
}
}
private onPageData(pageData: PageData<AlarmData>) {
this.pageData = pageData;
this.resetData();
this.listener.alarmsLoaded(pageData, this.alarmDataSubscriptionOptions.pageLink);
}
private onDataUpdate(update: Array<AlarmData>) {
for (const alarmData of update) {
const dataIndex = this.alarmIdToDataIndex[alarmData.id.id];
if (isDefined(dataIndex) && dataIndex >= 0) {
this.pageData.data[dataIndex] = alarmData;
}
}
this.listener.alarmsUpdated(update, this.pageData);
}
}

94
ui-ngx/src/app/core/api/alarm-data.service.ts

@ -0,0 +1,94 @@
///
/// Copyright © 2016-2020 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { SubscriptionTimewindow } from '@shared/models/time/time.models';
import { Datasource, DatasourceType } from '@shared/models/widget.models';
import { PageData } from '@shared/models/page/page-data';
import { AlarmData, AlarmDataPageLink, KeyFilter } from '@shared/models/query/query.models';
import { Injectable } from '@angular/core';
import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service';
import { UtilsService } from '@core/services/utils.service';
import {
AlarmDataSubscription,
AlarmDataSubscriptionOptions,
AlarmSubscriptionDataKey
} from '@core/api/alarm-data-subscription';
import { deepClone } from '@core/utils';
export interface AlarmDataListener {
subscriptionTimewindow?: SubscriptionTimewindow;
alarmSource: Datasource;
alarmsLoaded: (pageData: PageData<AlarmData>, pageLink: AlarmDataPageLink) => void;
alarmsUpdated: (update: Array<AlarmData>, pageData: PageData<AlarmData>) => void;
subscription?: AlarmDataSubscription;
}
@Injectable({
providedIn: 'root'
})
export class AlarmDataService {
constructor(private telemetryService: TelemetryWebsocketService,
private utils: UtilsService) {}
public subscribeForAlarms(listener: AlarmDataListener,
pageLink: AlarmDataPageLink,
keyFilters: KeyFilter[]) {
const alarmSource = listener.alarmSource;
if (alarmSource.type === DatasourceType.entity && (!alarmSource.entityFilter || !pageLink)) {
return;
}
listener.subscription = this.createSubscription(listener,
pageLink, alarmSource.keyFilters, keyFilters);
return listener.subscription.subscribe();
}
public stopSubscription(listener: AlarmDataListener) {
if (listener.subscription) {
listener.subscription.unsubscribe();
}
}
private createSubscription(listener: AlarmDataListener,
pageLink: AlarmDataPageLink,
keyFilters: KeyFilter[],
additionalKeyFilters: KeyFilter[]): AlarmDataSubscription {
const alarmSource = listener.alarmSource;
const alarmSubscriptionDataKeys: Array<AlarmSubscriptionDataKey> = [];
alarmSource.dataKeys.forEach((dataKey) => {
const alarmSubscriptionDataKey: AlarmSubscriptionDataKey = {
name: dataKey.name,
type: dataKey.type
};
alarmSubscriptionDataKeys.push(alarmSubscriptionDataKey);
});
const alarmDataSubscriptionOptions: AlarmDataSubscriptionOptions = {
datasourceType: alarmSource.type,
dataKeys: alarmSubscriptionDataKeys,
subscriptionTimewindow: deepClone(listener.subscriptionTimewindow)
};
if (alarmDataSubscriptionOptions.datasourceType === DatasourceType.entity) {
alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter;
alarmDataSubscriptionOptions.pageLink = pageLink;
alarmDataSubscriptionOptions.keyFilters = keyFilters;
alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters;
}
return new AlarmDataSubscription(alarmDataSubscriptionOptions,
listener, this.telemetryService, this.utils);
}
}

17
ui-ngx/src/app/core/api/widget-api.models.ts

@ -41,6 +41,7 @@ import { EntityInfo } from '@app/shared/models/entity.models';
import { IDashboardComponent } from '@home/models/dashboard-component.models'; import { IDashboardComponent } from '@home/models/dashboard-component.models';
import * as moment_ from 'moment'; import * as moment_ from 'moment';
import { import {
AlarmData, AlarmDataPageLink,
EntityData, EntityData,
EntityDataPageLink, EntityDataPageLink,
EntityFilter, EntityFilter,
@ -51,6 +52,7 @@ import {
import { EntityDataService } from '@core/api/entity-data.service'; import { EntityDataService } from '@core/api/entity-data.service';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AlarmDataService } from '@core/api/alarm-data.service';
export interface TimewindowFunctions { export interface TimewindowFunctions {
onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void;
@ -184,9 +186,9 @@ export class WidgetSubscriptionContext {
timeService: TimeService; timeService: TimeService;
deviceService: DeviceService; deviceService: DeviceService;
alarmService: AlarmService;
translate: TranslateService; translate: TranslateService;
entityDataService: EntityDataService; entityDataService: EntityDataService;
alarmDataService: AlarmDataService;
utils: UtilsService; utils: UtilsService;
raf: RafService; raf: RafService;
widgetUtils: IWidgetUtils; widgetUtils: IWidgetUtils;
@ -218,10 +220,10 @@ export interface WidgetSubscriptionOptions {
type?: widgetType; type?: widgetType;
stateData?: boolean; stateData?: boolean;
alarmSource?: Datasource; alarmSource?: Datasource;
alarmSearchStatus?: AlarmSearchStatus; /* alarmSearchStatus?: AlarmSearchStatus;
alarmsPollingInterval?: number; alarmsPollingInterval?: number;
alarmsMaxCountLoad?: number; alarmsMaxCountLoad?: number;
alarmsFetchSize?: number; alarmsFetchSize?: number; */
datasources?: Array<Datasource>; datasources?: Array<Datasource>;
hasDataPageLink?: boolean; hasDataPageLink?: boolean;
singleEntity?: boolean; singleEntity?: boolean;
@ -269,10 +271,10 @@ export interface IWidgetSubscription {
timeWindow?: WidgetTimewindow; timeWindow?: WidgetTimewindow;
comparisonTimeWindow?: WidgetTimewindow; comparisonTimeWindow?: WidgetTimewindow;
alarms?: Array<AlarmInfo>; alarms?: PageData<AlarmData>;
alarmSource?: Datasource; alarmSource?: Datasource;
alarmSearchStatus?: AlarmSearchStatus; /* alarmSearchStatus?: AlarmSearchStatus;
alarmsPollingInterval?: number; alarmsPollingInterval?: number; */
targetDeviceAliasIds?: Array<string>; targetDeviceAliasIds?: Array<string>;
targetDeviceIds?: Array<string>; targetDeviceIds?: Array<string>;
@ -309,6 +311,9 @@ export interface IWidgetSubscription {
pageLink: EntityDataPageLink, pageLink: EntityDataPageLink,
keyFilters: KeyFilter[]): Observable<any>; keyFilters: KeyFilter[]): Observable<any>;
subscribeForAlarms(pageLink: AlarmDataPageLink,
keyFilters: KeyFilter[]): void;
isDataResolved(): boolean; isDataResolved(): boolean;
destroy(): void; destroy(): void;

205
ui-ngx/src/app/core/api/widget-subscription.ts

@ -47,21 +47,23 @@ import {
import { forkJoin, Observable, of, ReplaySubject, Subject, throwError } from 'rxjs'; import { forkJoin, Observable, of, ReplaySubject, Subject, throwError } from 'rxjs';
import { CancelAnimationFrame } from '@core/services/raf.service'; import { CancelAnimationFrame } from '@core/services/raf.service';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { AlarmInfo, AlarmSearchStatus } from '@shared/models/alarm.models';
import { createLabelFromDatasource, deepClone, isDefined, isEqual } from '@core/utils'; import { createLabelFromDatasource, deepClone, isDefined, isEqual } from '@core/utils';
import { AlarmSourceListener } from '@core/http/alarm.service';
import { EntityId } from '@app/shared/models/id/entity-id'; import { EntityId } from '@app/shared/models/id/entity-id';
import * as moment_ from 'moment'; import * as moment_ from 'moment';
import { PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityDataListener } from '@core/api/entity-data.service'; import { EntityDataListener } from '@core/api/entity-data.service';
import { import {
AlarmData,
AlarmDataPageLink,
EntityData, EntityData,
EntityDataPageLink, EntityDataPageLink,
entityDataToEntityInfo, entityDataToEntityInfo,
EntityKeyType,
KeyFilter, KeyFilter,
updateDatasourceFromEntityInfo updateDatasourceFromEntityInfo
} from '@shared/models/query/query.models'; } from '@shared/models/query/query.models';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AlarmDataListener } from '@core/api/alarm-data.service';
const moment = moment_; const moment = moment_;
@ -102,10 +104,11 @@ export class WidgetSubscription implements IWidgetSubscription {
comparisonTimeWindow: WidgetTimewindow; comparisonTimeWindow: WidgetTimewindow;
timewindowForComparison: SubscriptionTimewindow; timewindowForComparison: SubscriptionTimewindow;
alarms: Array<AlarmInfo>; // alarms: Array<AlarmInfo>;
alarms: PageData<AlarmData>;
alarmSource: Datasource; alarmSource: Datasource;
private alarmSearchStatusValue: AlarmSearchStatus; /* private alarmSearchStatusValue: AlarmSearchStatus;
set alarmSearchStatus(value: AlarmSearchStatus) { set alarmSearchStatus(value: AlarmSearchStatus) {
if (this.alarmSearchStatusValue !== value) { if (this.alarmSearchStatusValue !== value) {
@ -116,12 +119,14 @@ export class WidgetSubscription implements IWidgetSubscription {
get alarmSearchStatus(): AlarmSearchStatus { get alarmSearchStatus(): AlarmSearchStatus {
return this.alarmSearchStatusValue; return this.alarmSearchStatusValue;
} }*/
alarmDataListener: AlarmDataListener;
alarmsPollingInterval: number; /* alarmsPollingInterval: number;
alarmsMaxCountLoad: number; alarmsMaxCountLoad: number;
alarmsFetchSize: number; alarmsFetchSize: number;
alarmSourceListener: AlarmSourceListener; alarmSourceListener: AlarmSourceListener;*/
loadingData: boolean; loadingData: boolean;
@ -181,7 +186,7 @@ export class WidgetSubscription implements IWidgetSubscription {
this.callbacks.dataLoading = this.callbacks.dataLoading || (() => {}); this.callbacks.dataLoading = this.callbacks.dataLoading || (() => {});
this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {}); this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || (() => {});
this.alarmSource = options.alarmSource; this.alarmSource = options.alarmSource;
this.alarmSearchStatusValue = isDefined(options.alarmSearchStatus) ? /*this.alarmSearchStatusValue = isDefined(options.alarmSearchStatus) ?
options.alarmSearchStatus : AlarmSearchStatus.ANY; options.alarmSearchStatus : AlarmSearchStatus.ANY;
this.alarmsPollingInterval = isDefined(options.alarmsPollingInterval) ? this.alarmsPollingInterval = isDefined(options.alarmsPollingInterval) ?
options.alarmsPollingInterval : 5000; options.alarmsPollingInterval : 5000;
@ -189,8 +194,10 @@ export class WidgetSubscription implements IWidgetSubscription {
options.alarmsMaxCountLoad : 0; options.alarmsMaxCountLoad : 0;
this.alarmsFetchSize = isDefined(options.alarmsFetchSize) ? this.alarmsFetchSize = isDefined(options.alarmsFetchSize) ?
options.alarmsFetchSize : 100; options.alarmsFetchSize : 100;
this.alarmSourceListener = null; this.alarmSourceListener = null;*/
this.alarms = []; this.alarmDataListener = null;
// this.alarms = [];
this.alarms = emptyPageData();
this.originalTimewindow = null; this.originalTimewindow = null;
this.timeWindow = {}; this.timeWindow = {};
this.useDashboardTimewindow = options.useDashboardTimewindow; this.useDashboardTimewindow = options.useDashboardTimewindow;
@ -290,7 +297,7 @@ export class WidgetSubscription implements IWidgetSubscription {
if (this.targetDeviceId) { if (this.targetDeviceId) {
this.rpcEnabled = true; this.rpcEnabled = true;
} else { } else {
this.rpcEnabled = this.ctx.utils.widgetEditMode ? true : false; this.rpcEnabled = this.ctx.utils.widgetEditMode;
} }
this.hasResolvedData = this.rpcEnabled; this.hasResolvedData = this.rpcEnabled;
this.callbacks.rpcStateChanged(this); this.callbacks.rpcStateChanged(this);
@ -317,7 +324,7 @@ export class WidgetSubscription implements IWidgetSubscription {
if (this.targetDeviceId) { if (this.targetDeviceId) {
this.rpcEnabled = true; this.rpcEnabled = true;
} else { } else {
this.rpcEnabled = this.ctx.utils.widgetEditMode ? true : false; this.rpcEnabled = this.ctx.utils.widgetEditMode;
} }
this.hasResolvedData = true; this.hasResolvedData = true;
this.callbacks.rpcStateChanged(this); this.callbacks.rpcStateChanged(this);
@ -356,6 +363,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
private configureAlarmsData() { private configureAlarmsData() {
this.notifyDataLoaded();
} }
private initDataSubscription(): Observable<any> { private initDataSubscription(): Observable<any> {
@ -482,13 +490,17 @@ export class WidgetSubscription implements IWidgetSubscription {
entityName = this.targetDeviceName; entityName = this.targetDeviceName;
} }
} else if (this.type === widgetType.alarm) { } else if (this.type === widgetType.alarm) {
if (this.alarmSource && this.alarmSource.entityType && this.alarmSource.entityId) { if (this.alarms && this.alarms.data.length) {
entityId = { const data = this.alarms.data[0];
entityType: this.alarmSource.entityType, entityId = data.originator;
id: this.alarmSource.entityId entityName = data.originatorName;
}; if (data.latest && data.latest[EntityKeyType.ENTITY_FIELD]) {
entityName = this.alarmSource.entityName; const entityFields = data.latest[EntityKeyType.ENTITY_FIELD];
entityLabel = this.alarmSource.entityLabel; const labelValue = entityFields.label;
if (labelValue) {
entityLabel = labelValue.value;
}
}
} }
} else { } else {
for (const datasource of this.datasources) { for (const datasource of this.datasources) {
@ -522,7 +534,6 @@ export class WidgetSubscription implements IWidgetSubscription {
} else { } else {
return this.checkSubscriptions(aliasIds); return this.checkSubscriptions(aliasIds);
} }
return false;
} }
onFiltersChanged(filterIds: Array<string>): boolean { onFiltersChanged(filterIds: Array<string>): boolean {
@ -573,12 +584,6 @@ export class WidgetSubscription implements IWidgetSubscription {
return false; return false;
} }
private onAlarmSearchStatusChanged() {
if (this.type === widgetType.alarm) {
this.update();
}
}
updateDataVisibility(index: number): void { updateDataVisibility(index: number): void {
if (this.displayLegend) { if (this.displayLegend) {
const hidden = this.legendData.keys[index].dataKey.hidden; const hidden = this.legendData.keys[index].dataKey.hidden;
@ -752,11 +757,12 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
update() { update() {
if (this.type === widgetType.rpc || this.type === widgetType.alarm) { if (this.type !== widgetType.rpc) {
this.unsubscribe(); if (this.type === widgetType.alarm) {
this.subscribe(); this.updateAlarmDataSubscription();
} else { } else {
this.dataSubscribe(); this.dataSubscribe();
}
} }
} }
@ -821,13 +827,41 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
} }
private doSubscribe() { subscribeForAlarms(pageLink: AlarmDataPageLink,
if (this.type === widgetType.rpc) { keyFilters: KeyFilter[]) {
return; if (this.alarmDataListener) {
this.ctx.alarmDataService.stopSubscription(this.alarmDataListener);
} }
if (this.type === widgetType.alarm) { if (this.timeWindowConfig) {
this.alarmsSubscribe(); this.updateRealtimeSubscription();
} else { if (this.subscriptionTimewindow.fixedWindow) {
this.onDataUpdated();
}
}
this.alarmDataListener = {
subscriptionTimewindow: this.subscriptionTimewindow,
alarmSource: this.alarmSource,
alarmsLoaded: this.alarmsLoaded.bind(this),
alarmsUpdated: this.alarmsUpdated.bind(this)
};
this.alarms = emptyPageData();
this.ctx.alarmDataService.subscribeForAlarms(this.alarmDataListener, pageLink, keyFilters);
let forceUpdate = false;
if (this.alarmSource.unresolvedStateEntity ||
(this.alarmSource.type === DatasourceType.entity && !this.alarmSource.entityId)
) {
forceUpdate = true;
}
if (forceUpdate) {
this.onDataUpdated();
}
}
private doSubscribe() {
if (this.type !== widgetType.rpc && this.type !== widgetType.alarm) {
this.dataSubscribe(); this.dataSubscribe();
} }
} }
@ -858,7 +892,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
} }
private alarmsSubscribe() { /* private alarmsSubscribe() {
this.notifyDataLoading(); this.notifyDataLoading();
if (this.timeWindowConfig) { if (this.timeWindowConfig) {
this.updateRealtimeSubscription(); this.updateRealtimeSubscription();
@ -875,9 +909,10 @@ export class WidgetSubscription implements IWidgetSubscription {
alarmsFetchSize: this.alarmsFetchSize, alarmsFetchSize: this.alarmsFetchSize,
alarmsUpdated: alarms => this.alarmsUpdated(alarms) alarmsUpdated: alarms => this.alarmsUpdated(alarms)
}; };
this.alarms = null;
this.ctx.alarmService.subscribeForAlarms(this.alarmSourceListener); this.alarms = emptyPageData();
this.ctx.alarmDataService.subscribeForAlarms(this.alarmDataListener);
let forceUpdate = false; let forceUpdate = false;
if (this.alarmSource.unresolvedStateEntity || if (this.alarmSource.unresolvedStateEntity ||
@ -889,7 +924,7 @@ export class WidgetSubscription implements IWidgetSubscription {
this.notifyDataLoaded(); this.notifyDataLoaded();
this.onDataUpdated(); this.onDataUpdated();
} }
} } */
unsubscribe() { unsubscribe() {
@ -910,33 +945,62 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
private alarmsUnsubscribe() { private alarmsUnsubscribe() {
if (this.alarmSourceListener) { if (this.alarmDataListener) {
this.ctx.alarmService.unsubscribeFromAlarms(this.alarmSourceListener); this.ctx.alarmDataService.stopSubscription(this.alarmDataListener);
this.alarmSourceListener = null; this.alarmDataListener = null;
} }
} }
private checkRpcTarget(aliasIds: Array<string>): boolean { private checkRpcTarget(aliasIds: Array<string>): boolean {
if (aliasIds.indexOf(this.targetDeviceAliasId) > -1) { return aliasIds.indexOf(this.targetDeviceAliasId) > -1;
return true;
} else {
return false;
}
} }
private checkAlarmSource(aliasIds: Array<string>): boolean { private checkAlarmSource(aliasIds: Array<string>): boolean {
if (this.options.alarmSource && this.options.alarmSource.entityAliasId) { if (this.options.alarmSource && this.options.alarmSource.entityAliasId) {
return aliasIds.indexOf(this.options.alarmSource.entityAliasId) > -1; if (aliasIds.indexOf(this.options.alarmSource.entityAliasId) > -1) {
} else { this.updateAlarmSubscription();
return false; }
} }
return false;
} }
private checkAlarmSourceFilters(filterIds: Array<string>): boolean { private checkAlarmSourceFilters(filterIds: Array<string>): boolean {
if (this.options.alarmSource && this.options.alarmSource.filterId) { if (this.options.alarmSource && this.options.alarmSource.filterId) {
return filterIds.indexOf(this.options.alarmSource.filterId) > -1; if (filterIds.indexOf(this.options.alarmSource.filterId) > -1) {
this.updateAlarmSubscription();
}
}
return false;
}
private updateAlarmSubscription() {
this.alarmSource = this.options.alarmSource;
if (!this.ctx.aliasController) {
this.hasResolvedData = true;
this.configureAlarmsData();
this.updateAlarmDataSubscription();
} else { } else {
return false; this.ctx.aliasController.resolveAlarmSource(this.alarmSource).subscribe(
(alarmSource) => {
this.alarmSource = alarmSource;
if (alarmSource) {
this.hasResolvedData = true;
}
this.configureAlarmsData();
this.updateAlarmDataSubscription();
},
() => {
this.notifyDataLoaded();
}
);
}
}
private updateAlarmDataSubscription() {
if (this.alarmDataListener) {
const pageLink = this.alarmDataListener.subscription.alarmDataSubscriptionOptions.pageLink;
const keyFilters = this.alarmDataListener.subscription.alarmDataSubscriptionOptions.additionalKeyFilters;
this.subscribeForAlarms(pageLink, keyFilters);
} }
} }
@ -999,7 +1063,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
); );
}, },
(err) => { () => {
this.notifyDataLoaded(); this.notifyDataLoaded();
} }
); );
@ -1031,11 +1095,6 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
} }
private notifyDataLoading() {
this.loadingData = true;
this.callbacks.dataLoading(this);
}
private notifyDataLoaded() { private notifyDataLoaded() {
this.loadingData = false; this.loadingData = false;
this.callbacks.dataLoading(this); this.callbacks.dataLoading(this);
@ -1100,23 +1159,21 @@ export class WidgetSubscription implements IWidgetSubscription {
const datasources = pageData.data.map((entityData, index) => const datasources = pageData.data.map((entityData, index) =>
this.entityDataToDatasource(datasource, entityData, index) this.entityDataToDatasource(datasource, entityData, index)
); );
const datasourcesPage: PageData<Datasource> = { this.datasourcePages[datasourceIndex] = {
data: datasources, data: datasources,
hasNext: pageData.hasNext, hasNext: pageData.hasNext,
totalElements: pageData.totalElements, totalElements: pageData.totalElements,
totalPages: pageData.totalPages totalPages: pageData.totalPages
}; };
this.datasourcePages[datasourceIndex] = datasourcesPage;
const datasourceData = datasources.map((datasourceElement, index) => const datasourceData = datasources.map((datasourceElement, index) =>
this.entityDataToDatasourceData(datasourceElement, data[index]) this.entityDataToDatasourceData(datasourceElement, data[index])
); );
const datasourceDataPage: PageData<Array<DatasourceData>> = { this.dataPages[datasourceIndex] = {
data: datasourceData, data: datasourceData,
hasNext: pageData.hasNext, hasNext: pageData.hasNext,
totalElements: pageData.totalElements, totalElements: pageData.totalElements,
totalPages: pageData.totalPages totalPages: pageData.totalPages
}; };
this.dataPages[datasourceIndex] = datasourceDataPage;
if (datasource.type === DatasourceType.entity && if (datasource.type === DatasourceType.entity &&
pageData.hasNext && pageLink.pageSize > 1) { pageData.hasNext && pageLink.pageSize > 1) {
if (this.warnOnPageDataOverflow) { if (this.warnOnPageDataOverflow) {
@ -1215,8 +1272,8 @@ export class WidgetSubscription implements IWidgetSubscription {
private entityDataToDatasourceData(datasource: Datasource, data: Array<DataSetHolder>): Array<DatasourceData> { private entityDataToDatasourceData(datasource: Datasource, data: Array<DataSetHolder>): Array<DatasourceData> {
return datasource.dataKeys.map((dataKey, keyIndex) => { return datasource.dataKeys.map((dataKey, keyIndex) => {
dataKey.hidden = dataKey.settings.hideDataByDefault ? true : false; dataKey.hidden = !!dataKey.settings.hideDataByDefault;
dataKey.inLegend = dataKey.settings.removeFromLegend ? false : true; dataKey.inLegend = !dataKey.settings.removeFromLegend;
if (this.comparisonEnabled && dataKey.isAdditional && dataKey.settings.comparisonSettings.comparisonValuesLabel) { if (this.comparisonEnabled && dataKey.isAdditional && dataKey.settings.comparisonSettings.comparisonValuesLabel) {
dataKey.label = createLabelFromDatasource(datasource, dataKey.settings.comparisonSettings.comparisonValuesLabel); dataKey.label = createLabelFromDatasource(datasource, dataKey.settings.comparisonSettings.comparisonValuesLabel);
} else { } else {
@ -1242,7 +1299,7 @@ export class WidgetSubscription implements IWidgetSubscription {
const newDatasource = deepClone(configDatasource); const newDatasource = deepClone(configDatasource);
const entityInfo = entityDataToEntityInfo(entityData); const entityInfo = entityDataToEntityInfo(entityData);
updateDatasourceFromEntityInfo(newDatasource, entityInfo); updateDatasourceFromEntityInfo(newDatasource, entityInfo);
newDatasource.generated = index > 0 ? true : false; newDatasource.generated = index > 0;
return newDatasource; return newDatasource;
} }
@ -1285,16 +1342,16 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
} }
private alarmsUpdated(alarms: Array<AlarmInfo>) { private alarmsLoaded(alarms: PageData<AlarmData>) {
this.notifyDataLoaded();
const updated = !this.alarms || !isEqual(this.alarms, alarms);
this.alarms = alarms; this.alarms = alarms;
if (this.subscriptionTimewindow && this.subscriptionTimewindow.realtimeWindowMs) { if (this.subscriptionTimewindow && this.subscriptionTimewindow.realtimeWindowMs) {
this.updateTimewindow(); this.updateTimewindow();
} }
if (updated) { this.onDataUpdated();
this.onDataUpdated(); }
}
private alarmsUpdated(_updated: Array<AlarmData>, alarms: PageData<AlarmData>) {
this.alarmsLoaded(alarms);
} }
private updateLegend(dataIndex: number, data: DataSet, detectChanges: boolean) { private updateLegend(dataIndex: number, data: DataSet, detectChanges: boolean) {

15
ui-ngx/src/app/core/ws/telemetry-websocket.service.ts

@ -16,8 +16,10 @@
import { Inject, Injectable, NgZone } from '@angular/core'; import { Inject, Injectable, NgZone } from '@angular/core';
import { import {
AlarmDataCmd, AlarmDataUnsubscribeCmd,
AlarmDataUpdate,
AttributesSubscriptionCmd, EntityDataCmd, EntityDataUnsubscribeCmd, EntityDataUpdate, AttributesSubscriptionCmd, EntityDataCmd, EntityDataUnsubscribeCmd, EntityDataUpdate,
GetHistoryCmd, isEntityDataUpdateMsg, GetHistoryCmd, isAlarmDataUpdateMsg, isEntityDataUpdateMsg,
SubscriptionCmd, SubscriptionCmd,
SubscriptionUpdate, SubscriptionUpdate,
SubscriptionUpdateMsg, SubscriptionUpdateMsg,
@ -107,6 +109,8 @@ export class TelemetryWebsocketService implements TelemetryService {
this.cmdsWrapper.historyCmds.push(subscriptionCommand); this.cmdsWrapper.historyCmds.push(subscriptionCommand);
} else if (subscriptionCommand instanceof EntityDataCmd) { } else if (subscriptionCommand instanceof EntityDataCmd) {
this.cmdsWrapper.entityDataCmds.push(subscriptionCommand); this.cmdsWrapper.entityDataCmds.push(subscriptionCommand);
} else if (subscriptionCommand instanceof AlarmDataCmd) {
this.cmdsWrapper.alarmDataCmds.push(subscriptionCommand);
} }
} }
); );
@ -142,6 +146,10 @@ export class TelemetryWebsocketService implements TelemetryService {
const entityDataUnsubscribeCmd = new EntityDataUnsubscribeCmd(); const entityDataUnsubscribeCmd = new EntityDataUnsubscribeCmd();
entityDataUnsubscribeCmd.cmdId = subscriptionCommand.cmdId; entityDataUnsubscribeCmd.cmdId = subscriptionCommand.cmdId;
this.cmdsWrapper.entityDataUnsubscribeCmds.push(entityDataUnsubscribeCmd); this.cmdsWrapper.entityDataUnsubscribeCmds.push(entityDataUnsubscribeCmd);
} else if (subscriptionCommand instanceof AlarmDataCmd) {
const alarmDataUnsubscribeCmd = new AlarmDataUnsubscribeCmd();
alarmDataUnsubscribeCmd.cmdId = subscriptionCommand.cmdId;
this.cmdsWrapper.alarmDataUnsubscribeCmds.push(alarmDataUnsubscribeCmd);
} }
const cmdId = subscriptionCommand.cmdId; const cmdId = subscriptionCommand.cmdId;
if (cmdId) { if (cmdId) {
@ -281,6 +289,11 @@ export class TelemetryWebsocketService implements TelemetryService {
if (subscriber) { if (subscriber) {
subscriber.onEntityData(new EntityDataUpdate(message)); subscriber.onEntityData(new EntityDataUpdate(message));
} }
} else if (isAlarmDataUpdateMsg(message)) {
subscriber = this.subscribersMap.get(message.cmdId);
if (subscriber) {
subscriber.onAlarmData(new AlarmDataUpdate(message));
}
} else if (message.subscriptionId) { } else if (message.subscriptionId) {
subscriber = this.subscribersMap.get(message.subscriptionId); subscriber = this.subscribersMap.get(message.subscriptionId);
if (subscriber) { if (subscriber) {

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

@ -62,7 +62,7 @@
</mat-toolbar> </mat-toolbar>
<div fxFlex class="table-container"> <div fxFlex class="table-container">
<table mat-table [dataSource]="alarmsDatasource" <table mat-table [dataSource]="alarmsDatasource"
matSort [matSortActive]="sortOrderProperty" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> matSort [matSortActive]="sortOrderProperty" [matSortDirection]="pageLinkSortDirection()" matSortDisableClear>
<ng-container matColumnDef="select" sticky> <ng-container matColumnDef="select" sticky>
<mat-header-cell *matHeaderCellDef style="width: 30px;"> <mat-header-cell *matHeaderCellDef style="width: 30px;">
<mat-checkbox (change)="$event ? alarmsDatasource.masterToggle() : null" <mat-checkbox (change)="$event ? alarmsDatasource.masterToggle() : null"
@ -125,9 +125,12 @@
*matRowDef="let alarm; columns: displayedColumns;" *matRowDef="let alarm; columns: displayedColumns;"
(click)="onRowClick($event, alarm)"></mat-row> (click)="onRowClick($event, alarm)"></mat-row>
</table> </table>
<span [fxShow]="alarmsDatasource.isEmpty() | async" <span [fxShow]="(alarmsDatasource.isEmpty() | async) && !alarmsDatasource.dataLoading"
fxLayoutAlign="center center" fxLayoutAlign="center center"
class="no-data-found" translate>alarm.no-alarms-prompt</span> class="no-data-found" translate>alarm.no-alarms-prompt</span>
<span [fxShow]="alarmsDatasource.dataLoading"
fxLayoutAlign="center center"
class="no-data-found">{{ 'common.loading' | translate }}</span>
</div> </div>
<mat-divider *ngIf="displayPagination"></mat-divider> <mat-divider *ngIf="displayPagination"></mat-divider>
<mat-paginator *ngIf="displayPagination" <mat-paginator *ngIf="displayPagination"

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

@ -29,21 +29,21 @@ import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { WidgetAction, WidgetContext } from '@home/models/widget-component.models'; import { WidgetAction, WidgetContext } from '@home/models/widget-component.models';
import { Datasource, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; import { DataKey, Datasource, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models';
import { IWidgetSubscription } from '@core/api/widget-api.models'; import { IWidgetSubscription } from '@core/api/widget-api.models';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { deepClone, isDefined, isNumber, createLabelFromDatasource, hashCode } from '@core/utils'; import { createLabelFromDatasource, deepClone, hashCode, isDefined, isNumber } from '@core/utils';
import cssjs from '@core/css/css'; import cssjs from '@core/css/css';
import { PageLink } from '@shared/models/page/page-link'; import { sortItems } from '@shared/models/page/page-link';
import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections';
import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, of } from 'rxjs'; import { BehaviorSubject, forkJoin, fromEvent, merge, Observable } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { entityTypeTranslations } from '@shared/models/entity-type.models'; import { entityTypeTranslations } from '@shared/models/entity-type.models';
import { catchError, debounceTime, distinctUntilChanged, map, take, tap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, map, take, tap } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort, SortDirection } from '@angular/material/sort';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { import {
CellContentInfo, CellContentInfo,
@ -51,14 +51,16 @@ import {
constructTableCssString, constructTableCssString,
DisplayColumn, DisplayColumn,
EntityColumn, EntityColumn,
fromAlarmColumnDef, entityDataSortOrderFromString,
findColumnByEntityKey,
findEntityKeyByColumnDef,
fromEntityColumnDef,
getAlarmValue, getAlarmValue,
getCellContentInfo, getCellContentInfo,
getCellStyleInfo, getCellStyleInfo,
getColumnWidth, getColumnWidth,
TableWidgetDataKeySettings, TableWidgetDataKeySettings,
TableWidgetSettings, TableWidgetSettings,
toAlarmColumnDef,
widthStyle widthStyle
} from '@home/components/widget/lib/table-widget.models'; } from '@home/components/widget/lib/table-widget.models';
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
@ -69,8 +71,8 @@ import {
DisplayColumnsPanelData DisplayColumnsPanelData
} from '@home/components/widget/lib/display-columns-panel.component'; } from '@home/components/widget/lib/display-columns-panel.component';
import { import {
AlarmDataInfo,
alarmFields, alarmFields,
AlarmInfo,
alarmSeverityColors, alarmSeverityColors,
alarmSeverityTranslations, alarmSeverityTranslations,
AlarmStatus, AlarmStatus,
@ -90,6 +92,15 @@ import { MatDialog } from '@angular/material/dialog';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { AlarmService } from '@core/http/alarm.service'; import { AlarmService } from '@core/http/alarm.service';
import {
AlarmData,
AlarmDataPageLink,
dataKeyToEntityKey,
dataKeyTypeToEntityKeyType,
entityDataPageLinkSortDirection,
KeyFilter
} from '@app/shared/models/query/query.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
interface AlarmsTableWidgetSettings extends TableWidgetSettings { interface AlarmsTableWidgetSettings extends TableWidgetSettings {
alarmsTitle: string; alarmsTitle: string;
@ -125,7 +136,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
public displayPagination = true; public displayPagination = true;
public enableStickyAction = false; public enableStickyAction = false;
public pageSizeOptions; public pageSizeOptions;
public pageLink: PageLink; public pageLink: AlarmDataPageLink;
public sortOrderProperty: string; public sortOrderProperty: string;
public textSearchMode = false; public textSearchMode = false;
public columns: Array<EntityColumn> = []; public columns: Array<EntityColumn> = [];
@ -189,9 +200,11 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
private dialogService: DialogService, private dialogService: DialogService,
private alarmService: AlarmService) { private alarmService: AlarmService) {
super(store); super(store);
this.pageLink = {
const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); page: 0,
this.pageLink = new PageLink(this.defaultPageSize, 0, null, sortOrder); pageSize: this.defaultPageSize,
textSearch: null
};
} }
ngOnInit(): void { ngOnInit(): void {
@ -232,11 +245,15 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
public onDataUpdated() { public onDataUpdated() {
this.ngZone.run(() => { this.ngZone.run(() => {
this.alarmsDatasource.updateAlarms(this.subscription.alarms); this.alarmsDatasource.updateAlarms();
this.ctx.detectChanges(); this.ctx.detectChanges();
}); });
} }
public pageLinkSortDirection(): SortDirection {
return entityDataPageLinkSortDirection(this.pageLink);
}
private initializeConfig() { private initializeConfig() {
this.ctx.widgetActions = [this.searchAction, this.statusFilterAction, this.columnDisplayAction]; this.ctx.widgetActions = [this.searchAction, this.statusFilterAction, this.columnDisplayAction];
@ -304,6 +321,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3];
this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY;
// TODO: search status, severity, types, searchPropagatedAlarms from widget config to pageLink
this.pageLink.searchPropagatedAlarms = false; // true for old widget configs
this.pageLink.severityList = [];
this.pageLink.statusList = [];
this.pageLink.typeList = [];
const cssString = constructTableCssString(this.widgetConfig); const cssString = constructTableCssString(this.widgetConfig);
const cssParser = new cssjs(); const cssParser = new cssjs();
cssParser.testMode = false; cssParser.testMode = false;
@ -319,9 +342,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.displayedColumns.push('select'); this.displayedColumns.push('select');
} }
const latestDataKeys: Array<DataKey> = [];
if (this.alarmSource) { if (this.alarmSource) {
this.alarmSource.dataKeys.forEach((alarmDataKey) => { this.alarmSource.dataKeys.forEach((alarmDataKey) => {
const dataKey: EntityColumn = deepClone(alarmDataKey) as EntityColumn; const dataKey: EntityColumn = deepClone(alarmDataKey) as EntityColumn;
dataKey.entityKey = dataKeyToEntityKey(alarmDataKey);
dataKey.title = this.utils.customTranslation(dataKey.label, dataKey.label); dataKey.title = this.utils.customTranslation(dataKey.label, dataKey.label);
dataKey.def = 'def' + this.columns.length; dataKey.def = 'def' + this.columns.length;
const keySettings: TableWidgetDataKeySettings = dataKey.settings; const keySettings: TableWidgetDataKeySettings = dataKey.settings;
@ -330,19 +356,28 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, alarm, ctx'); this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, alarm, ctx');
this.columnWidth[dataKey.def] = getColumnWidth(keySettings); this.columnWidth[dataKey.def] = getColumnWidth(keySettings);
this.columns.push(dataKey); this.columns.push(dataKey);
if (dataKey.type !== DataKeyType.alarm) {
latestDataKeys.push(dataKey);
}
}); });
this.displayedColumns.push(...this.columns.map(column => column.def)); this.displayedColumns.push(...this.columns.map(column => column.def));
} }
if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) {
this.defaultSortOrder = this.settings.defaultSortOrder; this.defaultSortOrder = this.settings.defaultSortOrder;
} }
this.pageLink.sortOrder = sortOrderFromString(this.defaultSortOrder); this.pageLink.sortOrder = entityDataSortOrderFromString(this.defaultSortOrder, this.columns);
this.sortOrderProperty = toAlarmColumnDef(this.pageLink.sortOrder.property, this.columns); let sortColumn: EntityColumn;
if (this.pageLink.sortOrder) {
sortColumn = findColumnByEntityKey(this.pageLink.sortOrder.key, this.columns);
}
this.sortOrderProperty = sortColumn ? sortColumn.def : null;
if (this.actionCellDescriptors.length) { if (this.actionCellDescriptors.length) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');
} }
this.alarmsDatasource = new AlarmsDatasource();
this.alarmsDatasource = new AlarmsDatasource(this.subscription, latestDataKeys);
if (this.enableSelection) { if (this.enableSelection) {
this.alarmsDatasource.selectionModeChanged$.subscribe((selectionMode) => { this.alarmsDatasource.selectionModeChanged$.subscribe((selectionMode) => {
const hideTitlePanel = selectionMode || this.textSearchMode; const hideTitlePanel = selectionMode || this.textSearchMode;
@ -467,9 +502,13 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} else { } else {
this.pageLink.page = 0; this.pageLink.page = 0;
} }
this.pageLink.sortOrder.property = fromAlarmColumnDef(this.sort.active, this.columns); this.pageLink.sortOrder = {
this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; key: findEntityKeyByColumnDef(this.sort.active, this.columns),
this.alarmsDatasource.loadAlarms(this.pageLink); direction: Direction[this.sort.direction.toUpperCase()]
};
const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns);
const keyFilters: KeyFilter[] = null; // TODO:
this.alarmsDatasource.loadAlarms(this.pageLink, sortOrderLabel, keyFilters);
this.ctx.detectChanges(); this.ctx.detectChanges();
} }
@ -482,7 +521,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
return widthStyle(columnWidth); return widthStyle(columnWidth);
} }
public cellStyle(alarm: AlarmInfo, key: EntityColumn): any { public cellStyle(alarm: AlarmDataInfo, key: EntityColumn): any {
let style: any = {}; let style: any = {};
if (alarm && key) { if (alarm && key) {
const styleInfo = this.stylesInfo[key.def]; const styleInfo = this.stylesInfo[key.def];
@ -504,7 +543,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
return style; return style;
} }
public cellContent(alarm: AlarmInfo, key: EntityColumn): SafeHtml { public cellContent(alarm: AlarmDataInfo, key: EntityColumn): SafeHtml {
if (alarm && key) { if (alarm && key) {
const contentInfo = this.contentsInfo[key.def]; const contentInfo = this.contentsInfo[key.def];
const value = getAlarmValue(alarm, key); const value = getAlarmValue(alarm, key);
@ -524,7 +563,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
public onRowClick($event: Event, alarm: AlarmInfo) { public onRowClick($event: Event, alarm: AlarmDataInfo) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -541,7 +580,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
public onActionButtonClick($event: Event, alarm: AlarmInfo, actionDescriptor: AlarmWidgetActionDescriptor) { public onActionButtonClick($event: Event, alarm: AlarmDataInfo, actionDescriptor: AlarmWidgetActionDescriptor) {
if (actionDescriptor.details) { if (actionDescriptor.details) {
this.openAlarmDetails($event, alarm); this.openAlarmDetails($event, alarm);
} else if (actionDescriptor.acknowledge) { } else if (actionDescriptor.acknowledge) {
@ -562,7 +601,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
public actionEnabled(alarm: AlarmInfo, actionDescriptor: AlarmWidgetActionDescriptor): boolean { public actionEnabled(alarm: AlarmDataInfo, actionDescriptor: AlarmWidgetActionDescriptor): boolean {
if (actionDescriptor.acknowledge) { if (actionDescriptor.acknowledge) {
return (alarm.status === AlarmStatus.ACTIVE_UNACK || return (alarm.status === AlarmStatus.ACTIVE_UNACK ||
alarm.status === AlarmStatus.CLEARED_UNACK); alarm.status === AlarmStatus.CLEARED_UNACK);
@ -573,7 +612,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
return true; return true;
} }
private openAlarmDetails($event: Event, alarm: AlarmInfo) { private openAlarmDetails($event: Event, alarm: AlarmDataInfo) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -599,7 +638,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
private ackAlarm($event: Event, alarm: AlarmInfo) { private ackAlarm($event: Event, alarm: AlarmDataInfo) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -626,12 +665,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation(); $event.stopPropagation();
} }
if (this.alarmsDatasource.selection.hasValue()) { if (this.alarmsDatasource.selection.hasValue()) {
const alarms = this.alarmsDatasource.selection.selected.filter( const alarmIds = this.alarmsDatasource.selection.selected.filter(
(alarm) => alarm.id.id !== NULL_UUID (alarmId) => alarmId !== NULL_UUID
); );
if (alarms.length) { if (alarmIds.length) {
const title = this.translate.instant('alarm.aknowledge-alarms-title', {count: alarms.length}); const title = this.translate.instant('alarm.aknowledge-alarms-title', {count: alarmIds.length});
const content = this.translate.instant('alarm.aknowledge-alarms-text', {count: alarms.length}); const content = this.translate.instant('alarm.aknowledge-alarms-text', {count: alarmIds.length});
this.dialogService.confirm( this.dialogService.confirm(
title, title,
content, content,
@ -641,8 +680,8 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
if (res) { if (res) {
if (res) { if (res) {
const tasks: Observable<void>[] = []; const tasks: Observable<void>[] = [];
for (const alarm of alarms) { for (const alarmId of alarmIds) {
tasks.push(this.alarmService.ackAlarm(alarm.id.id)); tasks.push(this.alarmService.ackAlarm(alarmId));
} }
forkJoin(tasks).subscribe(() => { forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection(); this.alarmsDatasource.clearSelection();
@ -655,7 +694,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
} }
private clearAlarm($event: Event, alarm: AlarmInfo) { private clearAlarm($event: Event, alarm: AlarmDataInfo) {
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
@ -682,12 +721,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation(); $event.stopPropagation();
} }
if (this.alarmsDatasource.selection.hasValue()) { if (this.alarmsDatasource.selection.hasValue()) {
const alarms = this.alarmsDatasource.selection.selected.filter( const alarmIds = this.alarmsDatasource.selection.selected.filter(
(alarm) => alarm.id.id !== NULL_UUID (alarmId) => alarmId !== NULL_UUID
); );
if (alarms.length) { if (alarmIds.length) {
const title = this.translate.instant('alarm.clear-alarms-title', {count: alarms.length}); const title = this.translate.instant('alarm.clear-alarms-title', {count: alarmIds.length});
const content = this.translate.instant('alarm.clear-alarms-text', {count: alarms.length}); const content = this.translate.instant('alarm.clear-alarms-text', {count: alarmIds.length});
this.dialogService.confirm( this.dialogService.confirm(
title, title,
content, content,
@ -697,8 +736,8 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
if (res) { if (res) {
if (res) { if (res) {
const tasks: Observable<void>[] = []; const tasks: Observable<void>[] = [];
for (const alarm of alarms) { for (const alarmId of alarmIds) {
tasks.push(this.alarmService.clearAlarm(alarm.id.id)); tasks.push(this.alarmService.clearAlarm(alarmId));
} }
forkJoin(tasks).subscribe(() => { forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection(); this.alarmsDatasource.clearSelection();
@ -756,27 +795,29 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
} }
class AlarmsDatasource implements DataSource<AlarmInfo> { class AlarmsDatasource implements DataSource<AlarmDataInfo> {
private alarmsSubject = new BehaviorSubject<AlarmInfo[]>([]); private alarmsSubject = new BehaviorSubject<AlarmDataInfo[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<AlarmInfo>>(emptyPageData<AlarmInfo>()); private pageDataSubject = new BehaviorSubject<PageData<AlarmDataInfo>>(emptyPageData<AlarmDataInfo>());
public selection = new SelectionModel<AlarmInfo>(true, [], false); public selection = new SelectionModel<string>(true, [], false);
private selectionModeChanged = new EventEmitter<boolean>(); private selectionModeChanged = new EventEmitter<boolean>();
public selectionModeChanged$ = this.selectionModeChanged.asObservable(); public selectionModeChanged$ = this.selectionModeChanged.asObservable();
private currentAlarm: AlarmDataInfo = null;
private allAlarms: Array<AlarmInfo> = []; public dataLoading = true;
private allAlarmsSubject = new BehaviorSubject<AlarmInfo[]>([]);
private allAlarms$: Observable<Array<AlarmInfo>> = this.allAlarmsSubject.asObservable();
private currentAlarm: AlarmInfo = null; private appliedPageLink: AlarmDataPageLink;
private appliedSortOrderLabel: string;
constructor() { constructor(private subscription: IWidgetSubscription,
private dataKeys: Array<DataKey>) {
} }
connect(collectionViewer: CollectionViewer): Observable<AlarmInfo[] | ReadonlyArray<AlarmInfo>> { connect(collectionViewer: CollectionViewer): Observable<AlarmDataInfo[] | ReadonlyArray<AlarmDataInfo>> {
return this.alarmsSubject.asObservable(); return this.alarmsSubject.asObservable();
} }
@ -785,51 +826,63 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
this.pageDataSubject.complete(); this.pageDataSubject.complete();
} }
loadAlarms(pageLink: PageLink) { loadAlarms(pageLink: AlarmDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) {
if (this.selection.hasValue()) { if (this.selection.hasValue()) {
this.selection.clear(); this.selection.clear();
this.onSelectionModeChanged(false); this.onSelectionModeChanged(false);
} }
this.fetchAlarms(pageLink).pipe( this.appliedPageLink = pageLink;
catchError(() => of(emptyPageData<AlarmInfo>())), this.appliedSortOrderLabel = sortOrderLabel;
).subscribe( this.subscription.subscribeForAlarms(pageLink, keyFilters);
(pageData) => {
this.alarmsSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
}
);
} }
updateAlarms(alarms: AlarmInfo[]) { updateAlarms() {
alarms.forEach((newAlarm) => { const subscriptionAlarms = this.subscription.alarms;
const existingAlarmIndex = this.allAlarms.findIndex(alarm => alarm.id.id === newAlarm.id.id); let alarms = new Array<AlarmDataInfo>();
if (existingAlarmIndex > -1) { subscriptionAlarms.data.forEach((alarmData) => {
Object.assign(this.allAlarms[existingAlarmIndex], newAlarm); alarms.push(this.alarmDataToInfo(alarmData));
} else {
this.allAlarms.push(newAlarm);
}
}); });
for (let i = this.allAlarms.length - 1; i >= 0; i--) { if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) {
const oldAlarm = this.allAlarms[i]; const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC;
const newAlarmIndex = alarms.findIndex(alarm => alarm.id.id === oldAlarm.id.id); alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc));
if (newAlarmIndex === -1) {
this.allAlarms.splice(i, 1);
}
} }
if (this.selection.hasValue()) { if (this.selection.hasValue()) {
const toRemove: AlarmInfo[] = []; const alarmIds = alarms.map((alarm) => alarm.id.id);
this.selection.selected.forEach((selectedAlarm) => { const toRemove = this.selection.selected.filter(alarmId => alarmIds.indexOf(alarmId) === -1);
const existingAlarm = this.allAlarms.find(alarm => alarm.id.id === selectedAlarm.id.id);
if (!existingAlarm) {
toRemove.push(selectedAlarm);
}
});
this.selection.deselect(...toRemove); this.selection.deselect(...toRemove);
if (this.selection.isEmpty()) { if (this.selection.isEmpty()) {
this.onSelectionModeChanged(false); this.onSelectionModeChanged(false);
} }
} }
this.allAlarmsSubject.next(this.allAlarms); const alarmsPageData: PageData<AlarmDataInfo> = {
data: alarms,
totalPages: subscriptionAlarms.totalPages,
totalElements: subscriptionAlarms.totalElements,
hasNext: subscriptionAlarms.hasNext
};
this.alarmsSubject.next(alarms);
this.pageDataSubject.next(alarmsPageData);
this.dataLoading = false;
}
private alarmDataToInfo(alarmData: AlarmData): AlarmDataInfo {
const alarm: AlarmDataInfo = deepClone(alarmData);
delete alarm.latest;
const latest = alarmData.latest;
this.dataKeys.forEach((dataKey, index) => {
const type = dataKeyTypeToEntityKeyType(dataKey.type);
let value = '';
if (type) {
if (latest && latest[type]) {
const tsVal = latest[type][dataKey.name];
if (tsVal) {
value = tsVal.value;
}
}
}
alarm[dataKey.label] = value;
});
return alarm;
} }
isAllSelected(): Observable<boolean> { isAllSelected(): Observable<boolean> {
@ -851,16 +904,16 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
); );
} }
toggleSelection(alarm: AlarmInfo) { toggleSelection(alarm: AlarmDataInfo) {
const hasValue = this.selection.hasValue(); const hasValue = this.selection.hasValue();
this.selection.toggle(alarm); this.selection.toggle(alarm.id.id);
if (hasValue !== this.selection.hasValue()) { if (hasValue !== this.selection.hasValue()) {
this.onSelectionModeChanged(this.selection.hasValue()); this.onSelectionModeChanged(this.selection.hasValue());
} }
} }
isSelected(alarm: AlarmInfo): boolean { isSelected(alarm: AlarmDataInfo): boolean {
return this.selection.isSelected(alarm); return this.selection.isSelected(alarm.id.id);
} }
clearSelection() { clearSelection() {
@ -881,7 +934,7 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
} }
} else { } else {
alarms.forEach(row => { alarms.forEach(row => {
this.selection.select(row); this.selection.select(row.id.id);
}); });
if (numSelected === 0) { if (numSelected === 0) {
this.onSelectionModeChanged(true); this.onSelectionModeChanged(true);
@ -892,7 +945,7 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
).subscribe(); ).subscribe();
} }
public toggleCurrentAlarm(alarm: AlarmInfo): boolean { public toggleCurrentAlarm(alarm: AlarmDataInfo): boolean {
if (this.currentAlarm !== alarm) { if (this.currentAlarm !== alarm) {
this.currentAlarm = alarm; this.currentAlarm = alarm;
return true; return true;
@ -901,7 +954,7 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
} }
} }
public isCurrentAlarm(alarm: AlarmInfo): boolean { public isCurrentAlarm(alarm: AlarmDataInfo): boolean {
return (this.currentAlarm && alarm && this.currentAlarm.id && alarm.id) && return (this.currentAlarm && alarm && this.currentAlarm.id && alarm.id) &&
(this.currentAlarm.id.id === alarm.id.id); (this.currentAlarm.id.id === alarm.id.id);
} }
@ -909,10 +962,4 @@ class AlarmsDatasource implements DataSource<AlarmInfo> {
private onSelectionModeChanged(selectionMode: boolean) { private onSelectionModeChanged(selectionMode: boolean) {
this.selectionModeChanged.emit(selectionMode); this.selectionModeChanged.emit(selectionMode);
} }
private fetchAlarms(pageLink: PageLink): Observable<PageData<AlarmInfo>> {
return this.allAlarms$.pipe(
map((data) => pageLink.filterData(data))
);
}
} }

15
ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts

@ -77,6 +77,7 @@ import {
DisplayColumnsPanelData DisplayColumnsPanelData
} from '@home/components/widget/lib/display-columns-panel.component'; } from '@home/components/widget/lib/display-columns-panel.component';
import { import {
dataKeyToEntityKey,
Direction, Direction,
EntityDataPageLink, EntityDataPageLink,
entityDataPageLinkSortDirection, entityDataPageLinkSortDirection,
@ -205,7 +206,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
public onDataUpdated() { public onDataUpdated() {
this.ngZone.run(() => { this.ngZone.run(() => {
this.entityDatasource.dataUpdated(); // .updateEntitiesData(this.subscription.data); this.entityDatasource.dataUpdated();
this.ctx.detectChanges(); this.ctx.detectChanges();
}); });
} }
@ -339,19 +340,9 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
if (datasource && datasource.dataKeys) { if (datasource && datasource.dataKeys) {
datasource.dataKeys.forEach((entityDataKey) => { datasource.dataKeys.forEach((entityDataKey) => {
const dataKey: EntityColumn = deepClone(entityDataKey) as EntityColumn; const dataKey: EntityColumn = deepClone(entityDataKey) as EntityColumn;
dataKey.entityKey = { dataKey.entityKey = dataKeyToEntityKey(entityDataKey);
key: dataKey.name,
type: null
};
if (dataKey.type === DataKeyType.function) { if (dataKey.type === DataKeyType.function) {
dataKey.name = dataKey.label; dataKey.name = dataKey.label;
dataKey.entityKey.type = EntityKeyType.ENTITY_FIELD;
} else if (dataKey.type === DataKeyType.entityField) {
dataKey.entityKey.type = EntityKeyType.ENTITY_FIELD;
} else if (dataKey.type === DataKeyType.attribute) {
dataKey.entityKey.type = EntityKeyType.ATTRIBUTE;
} else if (dataKey.type === DataKeyType.timeseries) {
dataKey.entityKey.type = EntityKeyType.TIME_SERIES;
} }
dataKeys.push(dataKey); dataKeys.push(dataKey);

17
ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts

@ -17,9 +17,10 @@
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { DataKey, WidgetConfig } from '@shared/models/widget.models'; import { DataKey, WidgetConfig } from '@shared/models/widget.models';
import { getDescendantProp, isDefined } from '@core/utils'; import { getDescendantProp, isDefined } from '@core/utils';
import { alarmFields, AlarmInfo } from '@shared/models/alarm.models'; import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models';
import * as tinycolor_ from 'tinycolor2'; import * as tinycolor_ from 'tinycolor2';
import { Direction, EntityDataSortOrder, EntityKey } from '@shared/models/query/query.models'; import { Direction, EntityDataSortOrder, EntityKey } from '@shared/models/query/query.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
const tinycolor = tinycolor_; const tinycolor = tinycolor_;
@ -120,7 +121,15 @@ export function findColumn(searchProperty: string, searchValue: string, columns:
} }
export function findColumnByLabel(label: string, columns: EntityColumn[]): EntityColumn { export function findColumnByLabel(label: string, columns: EntityColumn[]): EntityColumn {
return findColumn('label', label, columns); let column: EntityColumn;
const alarmColumns = columns.filter(c => c.type === DataKeyType.alarm);
if (alarmColumns.length) {
column = findColumn('name', label, alarmColumns);
}
if (!column) {
column = findColumn('label', label, columns);
}
return column;
} }
export function findColumnByDef(def: string, columns: EntityColumn[]): EntityColumn { export function findColumnByDef(def: string, columns: EntityColumn[]): EntityColumn {
@ -160,12 +169,12 @@ export function getEntityValue(entity: any, key: DataKey): any {
return getDescendantProp(entity, key.label); return getDescendantProp(entity, key.label);
} }
export function getAlarmValue(alarm: AlarmInfo, key: EntityColumn) { export function getAlarmValue(alarm: AlarmDataInfo, key: EntityColumn) {
const alarmField = alarmFields[key.name]; const alarmField = alarmFields[key.name];
if (alarmField) { if (alarmField) {
return getDescendantProp(alarm, alarmField.value); return getDescendantProp(alarm, alarmField.value);
} else { } else {
return getDescendantProp(alarm, key.name); return getDescendantProp(alarm, key.label);
} }
} }

9
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -94,6 +94,7 @@ import { ResizeObserver } from '@juggle/resize-observer';
import { EntityDataService } from '@core/api/entity-data.service'; import { EntityDataService } from '@core/api/entity-data.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { NotificationType } from '@core/notification/notification.models'; import { NotificationType } from '@core/notification/notification.models';
import { AlarmDataService } from '@core/api/alarm-data.service';
@Component({ @Component({
selector: 'tb-widget', selector: 'tb-widget',
@ -167,9 +168,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private timeService: TimeService, private timeService: TimeService,
private deviceService: DeviceService, private deviceService: DeviceService,
private entityService: EntityService, private entityService: EntityService,
private alarmService: AlarmService,
private dashboardService: DashboardService, private dashboardService: DashboardService,
private entityDataService: EntityDataService, private entityDataService: EntityDataService,
private alarmDataService: AlarmDataService,
private translate: TranslateService, private translate: TranslateService,
private utils: UtilsService, private utils: UtilsService,
private raf: RafService, private raf: RafService,
@ -300,9 +301,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.subscriptionContext = new WidgetSubscriptionContext(this.widgetContext.dashboard); this.subscriptionContext = new WidgetSubscriptionContext(this.widgetContext.dashboard);
this.subscriptionContext.timeService = this.timeService; this.subscriptionContext.timeService = this.timeService;
this.subscriptionContext.deviceService = this.deviceService; this.subscriptionContext.deviceService = this.deviceService;
this.subscriptionContext.alarmService = this.alarmService;
this.subscriptionContext.translate = this.translate; this.subscriptionContext.translate = this.translate;
this.subscriptionContext.entityDataService = this.entityDataService; this.subscriptionContext.entityDataService = this.entityDataService;
this.subscriptionContext.alarmDataService = this.alarmDataService;
this.subscriptionContext.utils = this.utils; this.subscriptionContext.utils = this.utils;
this.subscriptionContext.raf = this.raf; this.subscriptionContext.raf = this.raf;
this.subscriptionContext.widgetUtils = this.widgetContext.utils; this.subscriptionContext.widgetUtils = this.widgetContext.utils;
@ -901,14 +902,14 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
}; };
if (this.widget.type === widgetType.alarm) { if (this.widget.type === widgetType.alarm) {
options.alarmSource = deepClone(this.widget.config.alarmSource); options.alarmSource = deepClone(this.widget.config.alarmSource);
options.alarmSearchStatus = isDefined(this.widget.config.alarmSearchStatus) ? /*options.alarmSearchStatus = isDefined(this.widget.config.alarmSearchStatus) ?
this.widget.config.alarmSearchStatus : AlarmSearchStatus.ANY; this.widget.config.alarmSearchStatus : AlarmSearchStatus.ANY;
options.alarmsPollingInterval = isDefined(this.widget.config.alarmsPollingInterval) ? options.alarmsPollingInterval = isDefined(this.widget.config.alarmsPollingInterval) ?
this.widget.config.alarmsPollingInterval * 1000 : 5000; this.widget.config.alarmsPollingInterval * 1000 : 5000;
options.alarmsMaxCountLoad = isDefined(this.widget.config.alarmsMaxCountLoad) ? options.alarmsMaxCountLoad = isDefined(this.widget.config.alarmsMaxCountLoad) ?
this.widget.config.alarmsMaxCountLoad : 0; this.widget.config.alarmsMaxCountLoad : 0;
options.alarmsFetchSize = isDefined(this.widget.config.alarmsFetchSize) ? options.alarmsFetchSize = isDefined(this.widget.config.alarmsFetchSize) ?
this.widget.config.alarmsFetchSize : 100; this.widget.config.alarmsFetchSize : 100;*/
} else { } else {
options.datasources = deepClone(this.widget.config.datasources); options.datasources = deepClone(this.widget.config.datasources);
} }

4
ui-ngx/src/app/shared/models/alarm.models.ts

@ -103,6 +103,10 @@ export interface AlarmInfo extends Alarm {
originatorName: string; originatorName: string;
} }
export interface AlarmDataInfo extends AlarmInfo {
[key: string]: any;
}
export const simulatedAlarm: AlarmInfo = { export const simulatedAlarm: AlarmInfo = {
id: new AlarmId(NULL_UUID), id: new AlarmId(NULL_UUID),
tenantId: new TenantId(NULL_UUID), tenantId: new TenantId(NULL_UUID),

56
ui-ngx/src/app/shared/models/query/query.models.ts

@ -20,10 +20,11 @@ import { SortDirection } from '@angular/material/sort';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityInfo } from '@shared/models/entity.models'; import { EntityInfo } from '@shared/models/entity.models';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { Datasource, DatasourceType } from '@shared/models/widget.models'; import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { isDefined, isEqual } from '@core/utils'; import { isDefined, isEqual } from '@core/utils';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AlarmInfo, AlarmSearchStatus, AlarmSeverity } from '../alarm.models';
export enum EntityKeyType { export enum EntityKeyType {
ATTRIBUTE = 'ATTRIBUTE', ATTRIBUTE = 'ATTRIBUTE',
@ -31,7 +32,8 @@ export enum EntityKeyType {
SHARED_ATTRIBUTE = 'SHARED_ATTRIBUTE', SHARED_ATTRIBUTE = 'SHARED_ATTRIBUTE',
SERVER_ATTRIBUTE = 'SERVER_ATTRIBUTE', SERVER_ATTRIBUTE = 'SERVER_ATTRIBUTE',
TIME_SERIES = 'TIME_SERIES', TIME_SERIES = 'TIME_SERIES',
ENTITY_FIELD = 'ENTITY_FIELD' ENTITY_FIELD = 'ENTITY_FIELD',
ALARM_FIELD = 'ENTITY_FIELD'
} }
export const entityKeyTypeTranslationMap = new Map<EntityKeyType, string>( export const entityKeyTypeTranslationMap = new Map<EntityKeyType, string>(
@ -53,6 +55,23 @@ export function entityKeyTypeToDataKeyType(entityKeyType: EntityKeyType): DataKe
return DataKeyType.timeseries; return DataKeyType.timeseries;
case EntityKeyType.ENTITY_FIELD: case EntityKeyType.ENTITY_FIELD:
return DataKeyType.entityField; return DataKeyType.entityField;
case EntityKeyType.ALARM_FIELD:
return DataKeyType.alarm;
}
}
export function dataKeyTypeToEntityKeyType(dataKeyType: DataKeyType): EntityKeyType {
switch (dataKeyType) {
case DataKeyType.timeseries:
return EntityKeyType.TIME_SERIES;
case DataKeyType.attribute:
return EntityKeyType.ATTRIBUTE;
case DataKeyType.function:
return EntityKeyType.ENTITY_FIELD;
case DataKeyType.alarm:
return EntityKeyType.ALARM_FIELD;
case DataKeyType.entityField:
return EntityKeyType.ENTITY_FIELD;
} }
} }
@ -61,6 +80,14 @@ export interface EntityKey {
key: string; key: string;
} }
export function dataKeyToEntityKey(dataKey: DataKey): EntityKey {
const entityKey: EntityKey = {
key: dataKey.name,
type: dataKeyTypeToEntityKeyType(dataKey.type)
};
return entityKey;
}
export enum EntityKeyValueType { export enum EntityKeyValueType {
STRING = 'STRING', STRING = 'STRING',
NUMERIC = 'NUMERIC', NUMERIC = 'NUMERIC',
@ -479,6 +506,16 @@ export interface EntityDataPageLink {
dynamic?: boolean; dynamic?: boolean;
} }
export interface AlarmDataPageLink extends EntityDataPageLink {
startTs?: number;
endTs?: number;
timeWindow?: number;
typeList?: Array<string>;
statusList?: Array<AlarmSearchStatus>;
severityList?: Array<AlarmSeverity>;
searchPropagatedAlarms?: boolean;
}
export function entityDataPageLinkSortDirection(pageLink: EntityDataPageLink): SortDirection { export function entityDataPageLinkSortDirection(pageLink: EntityDataPageLink): SortDirection {
if (pageLink.sortOrder) { if (pageLink.sortOrder) {
return (pageLink.sortOrder.direction + '').toLowerCase() as SortDirection; return (pageLink.sortOrder.direction + '').toLowerCase() as SortDirection;
@ -508,13 +545,19 @@ export interface EntityCountQuery {
entityFilter: EntityFilter; entityFilter: EntityFilter;
} }
export interface EntityDataQuery extends EntityCountQuery { export interface AbstractDataQuery<T extends EntityDataPageLink> extends EntityCountQuery {
pageLink: EntityDataPageLink; pageLink: T;
entityFields?: Array<EntityKey>; entityFields?: Array<EntityKey>;
latestValues?: Array<EntityKey>; latestValues?: Array<EntityKey>;
keyFilters?: Array<KeyFilter>; keyFilters?: Array<KeyFilter>;
} }
export interface EntityDataQuery extends AbstractDataQuery<EntityDataPageLink> {
}
export interface AlarmDataQuery extends AbstractDataQuery<AlarmDataPageLink> {
}
export interface TsValue { export interface TsValue {
ts: number; ts: number;
value: string; value: string;
@ -526,6 +569,11 @@ export interface EntityData {
timeseries: {[key: string]: Array<TsValue>}; timeseries: {[key: string]: Array<TsValue>};
} }
export interface AlarmData extends AlarmInfo {
entityId: string;
latest: {[entityKeyType: string]: {[key: string]: TsValue}};
}
export function entityPageDataChanged(prevPageData: PageData<EntityData>, nextPageData: PageData<EntityData>): boolean { export function entityPageDataChanged(prevPageData: PageData<EntityData>, nextPageData: PageData<EntityData>): boolean {
const prevIds = prevPageData.data.map((entityData) => entityData.entityId.id); const prevIds = prevPageData.data.map((entityData) => entityData.entityId.id);
const nextIds = nextPageData.data.map((entityData) => entityData.entityId.id); const nextIds = nextPageData.data.map((entityData) => entityData.entityId.id);

96
ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts

@ -21,7 +21,7 @@ import { Observable, ReplaySubject, Subject } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { NgZone } from '@angular/core'; import { NgZone } from '@angular/core';
import { EntityData, EntityDataQuery, EntityKey } from '@shared/models/query/query.models'; import { AlarmData, AlarmDataQuery, EntityData, EntityDataQuery, EntityKey } from '@shared/models/query/query.models';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
export enum DataKeyType { export enum DataKeyType {
@ -165,16 +165,31 @@ export class EntityDataCmd implements WebsocketCmd {
} }
} }
export class AlarmDataCmd implements WebsocketCmd {
cmdId: number;
query?: AlarmDataQuery;
public isEmpty(): boolean {
return !this.query;
}
}
export class EntityDataUnsubscribeCmd implements WebsocketCmd { export class EntityDataUnsubscribeCmd implements WebsocketCmd {
cmdId: number; cmdId: number;
} }
export class AlarmDataUnsubscribeCmd implements WebsocketCmd {
cmdId: number;
}
export class TelemetryPluginCmdsWrapper { export class TelemetryPluginCmdsWrapper {
attrSubCmds: Array<AttributesSubscriptionCmd>; attrSubCmds: Array<AttributesSubscriptionCmd>;
tsSubCmds: Array<TimeseriesSubscriptionCmd>; tsSubCmds: Array<TimeseriesSubscriptionCmd>;
historyCmds: Array<GetHistoryCmd>; historyCmds: Array<GetHistoryCmd>;
entityDataCmds: Array<EntityDataCmd>; entityDataCmds: Array<EntityDataCmd>;
entityDataUnsubscribeCmds: Array<EntityDataUnsubscribeCmd>; entityDataUnsubscribeCmds: Array<EntityDataUnsubscribeCmd>;
alarmDataCmds: Array<AlarmDataCmd>;
alarmDataUnsubscribeCmds: Array<AlarmDataUnsubscribeCmd>;
constructor() { constructor() {
this.attrSubCmds = []; this.attrSubCmds = [];
@ -182,6 +197,8 @@ export class TelemetryPluginCmdsWrapper {
this.historyCmds = []; this.historyCmds = [];
this.entityDataCmds = []; this.entityDataCmds = [];
this.entityDataUnsubscribeCmds = []; this.entityDataUnsubscribeCmds = [];
this.alarmDataCmds = [];
this.alarmDataUnsubscribeCmds = [];
} }
public hasCommands(): boolean { public hasCommands(): boolean {
@ -189,7 +206,9 @@ export class TelemetryPluginCmdsWrapper {
this.historyCmds.length > 0 || this.historyCmds.length > 0 ||
this.attrSubCmds.length > 0 || this.attrSubCmds.length > 0 ||
this.entityDataCmds.length > 0 || this.entityDataCmds.length > 0 ||
this.entityDataUnsubscribeCmds.length > 0; this.entityDataUnsubscribeCmds.length > 0 ||
this.alarmDataCmds.length > 0 ||
this.alarmDataUnsubscribeCmds.length > 0;
} }
public clear() { public clear() {
@ -198,6 +217,8 @@ export class TelemetryPluginCmdsWrapper {
this.historyCmds.length = 0; this.historyCmds.length = 0;
this.entityDataCmds.length = 0; this.entityDataCmds.length = 0;
this.entityDataUnsubscribeCmds.length = 0; this.entityDataUnsubscribeCmds.length = 0;
this.alarmDataCmds.length = 0;
this.alarmDataUnsubscribeCmds.length = 0;
} }
public preparePublishCommands(maxCommands: number): TelemetryPluginCmdsWrapper { public preparePublishCommands(maxCommands: number): TelemetryPluginCmdsWrapper {
@ -212,6 +233,10 @@ export class TelemetryPluginCmdsWrapper {
preparedWrapper.entityDataCmds = this.popCmds(this.entityDataCmds, leftCount); preparedWrapper.entityDataCmds = this.popCmds(this.entityDataCmds, leftCount);
leftCount -= preparedWrapper.entityDataCmds.length; leftCount -= preparedWrapper.entityDataCmds.length;
preparedWrapper.entityDataUnsubscribeCmds = this.popCmds(this.entityDataUnsubscribeCmds, leftCount); preparedWrapper.entityDataUnsubscribeCmds = this.popCmds(this.entityDataUnsubscribeCmds, leftCount);
leftCount -= preparedWrapper.entityDataUnsubscribeCmds.length;
preparedWrapper.alarmDataCmds = this.popCmds(this.alarmDataCmds, leftCount);
leftCount -= preparedWrapper.alarmDataCmds.length;
preparedWrapper.alarmDataUnsubscribeCmds = this.popCmds(this.alarmDataUnsubscribeCmds, leftCount);
return preparedWrapper; return preparedWrapper;
} }
@ -239,18 +264,38 @@ export interface SubscriptionUpdateMsg extends SubscriptionDataHolder {
errorMsg: string; errorMsg: string;
} }
export interface EntityDataUpdateMsg { export enum DataUpdateType {
ENTITY_DATA = 'ENTITY_DATA',
ALARM_DATA = 'ALARM_DATA'
}
export interface DataUpdateMsg<T> {
cmdId: number; cmdId: number;
data?: PageData<EntityData>; data?: PageData<T>;
update?: Array<EntityData>; update?: Array<T>;
errorCode: number; errorCode: number;
errorMsg: string; errorMsg: string;
dataUpdateType: DataUpdateType;
}
export interface EntityDataUpdateMsg extends DataUpdateMsg<EntityData> {
dataUpdateType: DataUpdateType.ENTITY_DATA;
} }
export type WebsocketDataMsg = EntityDataUpdateMsg | SubscriptionUpdateMsg; export interface AlarmDataUpdateMsg extends DataUpdateMsg<AlarmData> {
dataUpdateType: DataUpdateType.ALARM_DATA;
}
export type WebsocketDataMsg = AlarmDataUpdateMsg | EntityDataUpdateMsg | SubscriptionUpdateMsg;
export function isEntityDataUpdateMsg(message: WebsocketDataMsg): message is EntityDataUpdateMsg { export function isEntityDataUpdateMsg(message: WebsocketDataMsg): message is EntityDataUpdateMsg {
return (message as EntityDataUpdateMsg).cmdId !== undefined; const updateMsg = (message as DataUpdateMsg<any>);
return updateMsg.cmdId !== undefined && updateMsg.dataUpdateType === DataUpdateType.ENTITY_DATA;
}
export function isAlarmDataUpdateMsg(message: WebsocketDataMsg): message is AlarmDataUpdateMsg {
const updateMsg = (message as DataUpdateMsg<any>);
return updateMsg.cmdId !== undefined && updateMsg.dataUpdateType === DataUpdateType.ALARM_DATA;
} }
export class SubscriptionUpdate implements SubscriptionUpdateMsg { export class SubscriptionUpdate implements SubscriptionUpdateMsg {
@ -302,19 +347,33 @@ export class SubscriptionUpdate implements SubscriptionUpdateMsg {
} }
} }
export class EntityDataUpdate implements EntityDataUpdateMsg { export class DataUpdate<T> implements DataUpdateMsg<T> {
cmdId: number; cmdId: number;
errorCode: number; errorCode: number;
errorMsg: string; errorMsg: string;
data?: PageData<EntityData>; data?: PageData<T>;
update?: Array<EntityData>; update?: Array<T>;
dataUpdateType: DataUpdateType;
constructor(msg: EntityDataUpdateMsg) { constructor(msg: DataUpdateMsg<T>) {
this.cmdId = msg.cmdId; this.cmdId = msg.cmdId;
this.errorCode = msg.errorCode; this.errorCode = msg.errorCode;
this.errorMsg = msg.errorMsg; this.errorMsg = msg.errorMsg;
this.data = msg.data; this.data = msg.data;
this.update = msg.update; this.update = msg.update;
this.dataUpdateType = msg.dataUpdateType;
}
}
export class EntityDataUpdate extends DataUpdate<EntityData> {
constructor(msg: EntityDataUpdateMsg) {
super(msg);
}
}
export class AlarmDataUpdate extends DataUpdate<AlarmData> {
constructor(msg: AlarmDataUpdateMsg) {
super(msg);
} }
} }
@ -328,6 +387,7 @@ export class TelemetrySubscriber {
private dataSubject = new ReplaySubject<SubscriptionUpdate>(1); private dataSubject = new ReplaySubject<SubscriptionUpdate>(1);
private entityDataSubject = new ReplaySubject<EntityDataUpdate>(1); private entityDataSubject = new ReplaySubject<EntityDataUpdate>(1);
private alarmDataSubject = new ReplaySubject<AlarmDataUpdate>(1);
private reconnectSubject = new Subject(); private reconnectSubject = new Subject();
private zone: NgZone; private zone: NgZone;
@ -336,6 +396,7 @@ export class TelemetrySubscriber {
public data$ = this.dataSubject.asObservable(); public data$ = this.dataSubject.asObservable();
public entityData$ = this.entityDataSubject.asObservable(); public entityData$ = this.entityDataSubject.asObservable();
public alarmData$ = this.alarmDataSubject.asObservable();
public reconnect$ = this.reconnectSubject.asObservable(); public reconnect$ = this.reconnectSubject.asObservable();
public static createEntityAttributesSubscription(telemetryService: TelemetryService, public static createEntityAttributesSubscription(telemetryService: TelemetryService,
@ -379,6 +440,7 @@ export class TelemetrySubscriber {
public complete() { public complete() {
this.dataSubject.complete(); this.dataSubject.complete();
this.entityDataSubject.complete(); this.entityDataSubject.complete();
this.alarmDataSubject.complete();
this.reconnectSubject.complete(); this.reconnectSubject.complete();
} }
@ -416,6 +478,18 @@ export class TelemetrySubscriber {
} }
} }
public onAlarmData(message: AlarmDataUpdate) {
if (this.zone) {
this.zone.run(
() => {
this.alarmDataSubject.next(message);
}
);
} else {
this.alarmDataSubject.next(message);
}
}
public onReconnected() { public onReconnected() {
this.reconnectSubject.next(); this.reconnectSubject.next();
} }

Loading…
Cancel
Save