Merge branch 'feature/calculated-fields' of github.com:thingsboard/thingsboard into calculated-fields

This commit is contained in:
IrynaMatveieva 2025-02-19 08:51:38 +02:00
commit b49abaa523
27 changed files with 423 additions and 116 deletions

View File

@ -52,9 +52,11 @@ public abstract class AbstractCalculatedFieldStateService implements CalculatedF
protected void processRestoredState(CalculatedFieldStateProto stateMsg) {
var id = fromProto(stateMsg.getId());
var state = fromProto(stateMsg);
processRestoredState(id, state);
}
protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state) {
actorSystemContext.tell(new CalculatedFieldStateRestoreMsg(id, state));
}
}

View File

@ -24,10 +24,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.queue.TbQueueCallback;
import org.thingsboard.server.queue.TbQueueMsgHeaders;
import org.thingsboard.server.queue.TbQueueMsgMetadata;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.discovery.PartitionService;
@ -46,6 +51,11 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToString;
import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToUuid;
import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.stringToBytes;
import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.uuidToBytes;
@Service
@RequiredArgsConstructor
@Slf4j
@ -81,7 +91,11 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
.msgPackProcessor((msgs, consumer, config) -> {
for (TbProtoQueueMsg<CalculatedFieldStateProto> msg : msgs) {
try {
if (msg.getValue() != null) {
processRestoredState(msg.getValue());
} else {
processRestoredState(getStateId(msg.getHeaders()), null);
}
} catch (Throwable t) {
log.error("Failed to process state message: {}", msg, t);
}
@ -103,7 +117,11 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
@Override
protected void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback) {
TopicPartitionInfo tpi = partitionService.resolve(QueueKey.CF_STATES, stateId.entityId());
stateProducer.send(tpi, stateId.toKey(), new TbProtoQueueMsg<>(stateId.entityId().getId(), stateMsgProto), new TbQueueCallback() {
TbProtoQueueMsg<CalculatedFieldStateProto> msg = new TbProtoQueueMsg<>(stateId.entityId().getId(), stateMsgProto);
if (stateMsgProto == null) {
putStateId(msg.getHeaders(), stateId);
}
stateProducer.send(tpi, stateId.toKey(), msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
if (callback != null) {
@ -122,7 +140,7 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
@Override
protected void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback) {
//TODO: vklimov
doPersist(stateId, null, callback);
}
@Override
@ -138,6 +156,20 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta
log.info("Restored {} calculated field states in {} ms", counter.get(), System.currentTimeMillis() - startTs);
}
private void putStateId(TbQueueMsgHeaders headers, CalculatedFieldEntityCtxId stateId) {
headers.put("tenantId", uuidToBytes(stateId.tenantId().getId()));
headers.put("cfId", uuidToBytes(stateId.cfId().getId()));
headers.put("entityId", uuidToBytes(stateId.entityId().getId()));
headers.put("entityType", stringToBytes(stateId.entityId().getEntityType().name()));
}
private CalculatedFieldEntityCtxId getStateId(TbQueueMsgHeaders headers) {
TenantId tenantId = TenantId.fromUUID(bytesToUuid(headers.get("tenantId")));
CalculatedFieldId cfId = new CalculatedFieldId(bytesToUuid(headers.get("cfId")));
EntityId entityId = EntityIdFactory.getByTypeAndUuid(bytesToString(headers.get("entityType")), bytesToUuid(headers.get("entityId")));
return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId);
}
@PreDestroy
private void preDestroy() {
stateConsumer.stop();

View File

@ -24,35 +24,36 @@ public class AbstractTbQueueTemplate {
protected static final String RESPONSE_TOPIC_HEADER = "responseTopic";
protected static final String EXPIRE_TS_HEADER = "expireTs";
protected byte[] uuidToBytes(UUID uuid) {
public static byte[] uuidToBytes(UUID uuid) {
ByteBuffer buf = ByteBuffer.allocate(16);
buf.putLong(uuid.getMostSignificantBits());
buf.putLong(uuid.getLeastSignificantBits());
return buf.array();
}
protected static UUID bytesToUuid(byte[] bytes) {
public static UUID bytesToUuid(byte[] bytes) {
ByteBuffer bb = ByteBuffer.wrap(bytes);
long firstLong = bb.getLong();
long secondLong = bb.getLong();
return new UUID(firstLong, secondLong);
}
protected byte[] stringToBytes(String string) {
public static byte[] stringToBytes(String string) {
return string.getBytes(StandardCharsets.UTF_8);
}
protected String bytesToString(byte[] data) {
public static String bytesToString(byte[] data) {
return new String(data, StandardCharsets.UTF_8);
}
protected static byte[] longToBytes(long x) {
public static byte[] longToBytes(long x) {
ByteBuffer longBuffer = ByteBuffer.allocate(Long.BYTES);
longBuffer.putLong(0, x);
return longBuffer.array();
}
protected static long bytesToLong(byte[] bytes) {
public static long bytesToLong(byte[] bytes) {
return ByteBuffer.wrap(bytes).getLong();
}
}

View File

@ -50,6 +50,7 @@ public class TbProtoQueueMsg<T extends com.google.protobuf.GeneratedMessageV3> i
@Override
public byte[] getData() {
return value.toByteArray();
return value != null ? value.toByteArray() : null;
}
}

View File

@ -556,7 +556,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi
.stopWhenRead(true)
.clientId("monolith-calculated-field-state-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet())
.groupId(topicService.buildTopicName("monolith-calculated-field-state-consumer"))
.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), CalculatedFieldStateProto.parseFrom(msg.getData()), msg.getHeaders()))
.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), msg.getData() != null ? CalculatedFieldStateProto.parseFrom(msg.getData()) : null, msg.getHeaders()))
.admin(cfStateAdmin)
.statsService(consumerStatsService)
.build();

View File

@ -348,7 +348,7 @@ public class KafkaTbRuleEngineQueueFactory implements TbRuleEngineQueueFactory {
.stopWhenRead(true)
.clientId("tb-rule-engine-calculated-field-state-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet())
.groupId(topicService.buildTopicName("tb-rule-engine-calculated-field-state-consumer"))
.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), CalculatedFieldStateProto.parseFrom(msg.getData()), msg.getHeaders()))
.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), msg.getData() != null ? CalculatedFieldStateProto.parseFrom(msg.getData()) : null, msg.getHeaders()))
.admin(cfStateAdmin)
.statsService(consumerStatsService)
.build();

View File

@ -36,12 +36,14 @@ import { EntityDebugSettingsPanelComponent } from '@home/components/entity/debug
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { catchError, filter, switchMap, tap } from 'rxjs/operators';
import {
ArgumentType,
CalculatedField,
CalculatedFieldEventArguments,
CalculatedFieldDebugDialogData,
CalculatedFieldDialogData,
CalculatedFieldTestScriptDialogData,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
} from '@shared/models/calculated-field.models';
import {
CalculatedFieldDebugDialogComponent,
@ -83,7 +85,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.addCalculatedField.bind(this);
this.addEntity = this.getCalculatedFieldDialog.bind(this);
this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('calculated-fields.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count});
@ -122,7 +124,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
},
{
name: this.translate.instant('entity-view.events'),
icon: 'history',
icon: 'mdi:clipboard-text-clock',
isEnabled: () => true,
onAction: (_, entity) => this.openDebugEventsDialog(entity),
},
@ -179,20 +181,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
}
private addCalculatedField(): Observable<CalculatedField> {
return this.getCalculatedFieldDialog()
.pipe(
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField({ entityId: this.entityId, ...calculatedField })),
)
}
private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void {
this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty)
.pipe(
filter(Boolean),
switchMap((updatedCalculatedField) => this.calculatedFieldsService.saveCalculatedField({ ...calculatedField, ...updatedCalculatedField })),
)
.subscribe((res) => {
if (res) {
this.updateData();
@ -217,7 +207,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
},
enterAnimationDuration: isDirty ? 0 : null,
})
.afterClosed();
.afterClosed()
.pipe(filter(Boolean));
}
private openDebugEventsDialog(calculatedField: CalculatedField): void {
@ -271,7 +262,10 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) ? argumentsObj[key] : '';
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
? { ...argumentsObj[key], type }
: type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() };
return acc;
}, {});
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
@ -282,6 +276,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
arguments: resultArguments,
expression: calculatedField.configuration.expression,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
openCalculatedFieldEdit
}
}).afterClosed()

View File

@ -33,7 +33,7 @@
<section class="datasource-field flex w-1/3 gap-2 xs:hidden">
@if (group.get('refEntityId')?.get('id')?.value) {
<ng-container [formGroup]="group.get('refEntityId')">
<mat-form-field appearance="outline" class="tb-inline-field flex-1" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="tb-inline-field w-1/2" subscriptSizing="dynamic">
<mat-select [value]="group.get('refEntityId').get('entityType').value" formControlName="entityType">
<mat-option [value]="group.get('refEntityId').get('entityType').value">
{{ entityTypeTranslations.get(group.get('refEntityId').get('entityType').value)?.type | translate }}
@ -41,12 +41,13 @@
</mat-select>
</mat-form-field>
<tb-entity-autocomplete
class="flex-1"
class="entity-field w-1/2"
formControlName="id"
[inlineField]="true"
[hideLabel]="true"
[placeholder]="'action.set' | translate"
[entityType]="group.get('refEntityId').get('entityType').value"/>
[entityType]="group.get('refEntityId').get('entityType').value"
/>
</ng-container>
} @else {
<mat-form-field appearance="outline" class="tb-inline-field flex-1" subscriptSizing="dynamic">

View File

@ -40,9 +40,13 @@
}
:host ::ng-deep {
.tb-inline-field {
.entity-field {
a {
font-size: 14px;
white-space: nowrap;
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@ -33,7 +33,6 @@ import {
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
ArgumentEntityType,
@ -49,8 +48,7 @@ import { TbPopoverService } from '@shared/components/popover.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { isDefinedAndNotNull } from '@core/utils';
import { charsWithNumRegex } from '@shared/models/regex.constants';
import { isDefined, isDefinedAndNotNull } from '@core/utils';
import { TbPopoverComponent } from '@shared/components/popover.component';
@Component({
@ -143,10 +141,10 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
entityName: this.entityName,
usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argumentObj.argumentName),
usedArgumentNames: this.argumentsFormArray.getRawValue().map(({ argumentName }) => argumentName).filter(name => name !== argumentObj.argumentName),
};
this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, 'left', false, null,
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, isDefined(index) ? 'left' : 'right', false, null,
ctx,
{},
{}, {}, true);
@ -201,7 +199,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
private getArgumentFormGroup(value: CalculatedFieldArgumentValue): FormGroup {
return this.fb.group({
...value,
argumentName: [value.argumentName, [Validators.required, Validators.maxLength(255), Validators.pattern(charsWithNumRegex)]],
argumentName: [{ value: value.argumentName, disabled: true }],
...(value.refEntityId ? {
refEntityId: this.fb.group({
entityType: [{ value: value.refEntityId.entityType, disabled: true }],

View File

@ -96,9 +96,11 @@
required
formControlName="expressionSCRIPT"
functionName="calculate"
class="expression-edit"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
helpId="calculated-field/expression_fn"
>
@ -183,7 +185,7 @@
</button>
<button mat-raised-button color="primary"
(click)="add()"
[disabled]="fieldFormGroup.invalid || !fieldFormGroup.dirty">
[disabled]="(isLoading$ | async) || fieldFormGroup.invalid || !fieldFormGroup.dirty">
{{ data.buttonTitle | translate }}
</button>
</div>

View File

@ -20,3 +20,21 @@
max-width: 100%;
}
}
:host ::ng-deep {
.expression-edit {
.ace_tb {
&.ace_calculated-field {
&-key {
color: #C52F00;
}
&-ts, &-time-window, &-values, &-value {
color: #7214D0;
}
&-start-ts, &-end-ts, &-limit {
color: #185F2A;
}
}
}
}
}

View File

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { AfterViewInit, Component, Inject } from '@angular/core';
import { Component, DestroyRef, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -28,6 +28,7 @@ import {
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
@ -37,13 +38,14 @@ import { EntityType } from '@shared/models/entity-type.models';
import { map, startWith } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
@Component({
selector: 'tb-calculated-field-dialog',
templateUrl: './calculated-field-dialog.component.html',
styleUrls: ['./calculated-field-dialog.component.scss'],
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> implements AfterViewInit {
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
@ -73,6 +75,12 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj))
);
argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
@ -92,8 +100,11 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService,
private destroyRef: DestroyRef,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.applyDialogData();
this.observeTypeChanges();
}
@ -112,19 +123,15 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
return { configuration: { ...restConfig, type, expression: configuration['expression'+type] }, ...rest, type } as CalculatedField;
}
ngAfterViewInit(): void {
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
cancel(): void {
this.dialogRef.close(null);
}
add(): void {
if (this.fieldFormGroup.valid) {
this.dialogRef.close(this.fromGroupValue);
this.calculatedFieldsService.saveCalculatedField({ entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(calculatedField => this.dialogRef.close(calculatedField));
}
}
@ -169,4 +176,19 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
private observeIsLoading(): void {
this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => {
if (loading) {
this.fieldFormGroup.disable({emitEvent: false});
} else {
this.fieldFormGroup.enable({emitEvent: false});
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
});
}
}

View File

@ -108,7 +108,14 @@
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.timeseries-key' | translate }}</div>
@if (refEntityKeyFormGroup.get('type').value === ArgumentType.LatestTelemetry) {
<ng-container [ngTemplateOutlet]="timeseriesKeyAutocomplete"/>
} @else {
<ng-container [ngTemplateOutlet]="timeseriesKeyAutocomplete"/>
}
<ng-template #timeseriesKeyAutocomplete>
<tb-entity-key-autocomplete class="flex-1" formControlName="key" [dataKeyType]="DataKeyType.timeseries" [entityFilter]="entityFilter"/>
</ng-template>
</div>
} @else {
@if (enableAttributeScopeSelection) {

View File

@ -19,16 +19,38 @@
<div>{{ 'calculated-fields.arguments' | translate }}</div>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell w-1/4" tbTruncateWithTooltip>{{ 'calculated-fields.argument-name' | translate }}</div>
<div class="tb-form-table-header-cell flex-1">{{ 'common.value' | translate }}</div>
<div class="tb-form-table-header-cell w-1/6">{{ 'common.name' | translate }}</div>
<div class="tb-form-table-header-cell w-1/5 xs:hidden">{{ 'common.type' | translate }}</div>
<div class="tb-form-table-header-cell flex-1">{{ 'common.data' | translate }}</div>
</div>
<div class="tb-form-table-body">
@for (group of argumentsFormArray.controls; track group) {
<div [formGroup]="group" class="tb-form-table-row">
<mat-form-field appearance="outline" class="tb-inline-field w-1/4" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic">
<input matInput formControlName="argumentName" placeholder="{{ 'action.set' | translate }}">
</mat-form-field>
<tb-value-input class="argument-value flex-1" [required]="false" [shortBooleanField]="true" formControlName="value"/>
<mat-form-field appearance="outline" class="tb-inline-field w-1/5 xs:hidden" subscriptSizing="dynamic">
<mat-select [value]="argumentsTypeMap.get(group.get('argumentName').value)" [disabled]="true">
<mat-option [value]="argumentsTypeMap.get(group.get('argumentName').value)">
{{ ArgumentTypeTranslations.get(argumentsTypeMap.get(group.get('argumentName').value)) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="flex flex-1 items-center gap-2">
@if (argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling) {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="tb-inline-field flex-1">
<input matInput tb-json-to-string name="values" formControlName="values" placeholder="{{ 'value.json-value' | translate }}*"/>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="tb-inline-field w-1/3" subscriptSizing="dynamic">
<input matInput formControlName="ts" type="number" placeholder="{{ 'action.set' | translate }}">
</mat-form-field>
<tb-value-input class="argument-value min-w-60 flex-1" [required]="false" [hideJsonEdit]="true" [shortBooleanField]="true" formControlName="value"/>
}
<button mat-icon-button class="tb-mat-32" (click)="openEditJSONDialog(group)">
<mat-icon class="tb-mat-20">open_in_new</mat-icon>
</button>
</div>
</div>
}
</div>

View File

@ -15,14 +15,18 @@
*/
@use '../../../../../../../scss/constants' as constants;
:host {
.tb-form-table {
min-width: 700px;
}
}
:host::ng-deep {
.tb-form-table-row {
.argument-value {
.tb-value-type.row {
@media #{constants.$mat-lt-sm} {
width: 100px;
min-width: 100px;
}
width: 120px;
min-width: 120px;
}
}
}

View File

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, forwardRef } from '@angular/core';
import { Component, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
@ -26,6 +26,22 @@ import {
} from '@angular/forms';
import { PageComponent } from '@shared/components/page.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { entityTypeTranslations } from '@shared/models/entity-type.models';
import {
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentEventValue,
CalculatedFieldRollingTelemetryArgumentValue,
CalculatedFieldSingleArgumentValue,
CalculatedFieldEventArguments,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
import {
JsonObjectEditDialogComponent,
JsonObjectEditDialogData
} from '@shared/components/dialog/json-object-edit-dialog.component';
import { filter } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
@Component({
selector: 'tb-calculated-field-test-arguments',
@ -46,32 +62,39 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
})
export class CalculatedFieldTestArgumentsComponent extends PageComponent implements ControlValueAccessor, Validator {
@Input() argumentsTypeMap: Map<string, ArgumentType>;
argumentsFormArray = this.fb.array<FormGroup>([]);
private propagateChange: (value: { argumentName: string; value: unknown }) => void;
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly CalculatedFieldType = CalculatedFieldType;
constructor(private fb: FormBuilder) {
private propagateChange: (value: CalculatedFieldEventArguments) => void = () => {};
constructor(private fb: FormBuilder, private dialog: MatDialog) {
super();
this.argumentsFormArray.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.propagateChange(this.getValue()));
}
registerOnChange(propagateChange: (value: { argumentName: string; value: unknown }) => void): void {
registerOnChange(propagateChange: (value: CalculatedFieldEventArguments) => void): void {
this.propagateChange = propagateChange;
}
registerOnTouched(_): void {
}
writeValue(argumentsObj: Record<string, unknown>): void {
writeValue(argumentsObj: CalculatedFieldEventArguments): void {
this.argumentsFormArray.clear();
Object.keys(argumentsObj).forEach(key => {
this.argumentsFormArray.push(this.fb.group({
argumentName: [{ value: key, disabled: true}],
value: [argumentsObj[key]]
}) as FormGroup, {emitEvent: false});
const value = { ...argumentsObj[key], argumentName: key } as CalculatedFieldArgumentEventValue;
this.argumentsFormArray.push(this.argumentsTypeMap.get(key) === ArgumentType.Rolling
? this.getRollingArgumentFormGroup(value as CalculatedFieldRollingTelemetryArgumentValue)
: this.getSimpleArgumentFormGroup(value as CalculatedFieldSingleArgumentValue)
);
});
}
@ -79,11 +102,46 @@ export class CalculatedFieldTestArgumentsComponent extends PageComponent impleme
return this.argumentsFormArray.valid ? null : { arguments: { valid: false } };
}
private getValue(): { argumentName: string; value: unknown } {
openEditJSONDialog(group: FormGroup): void {
this.dialog.open<JsonObjectEditDialogComponent, JsonObjectEditDialogData, CalculatedFieldArgumentEventValue>(JsonObjectEditDialogComponent, {
disableClose: true,
height: '760px',
maxHeight: '70vh',
minWidth: 'min(700px, 100%)',
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
jsonValue: group.value,
required: true,
fillHeight: true
}
}).afterClosed()
.pipe(filter(Boolean))
.subscribe(result => this.argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling
? group.patchValue({ timeWindow: (result as CalculatedFieldRollingTelemetryArgumentValue).timeWindow, values: (result as CalculatedFieldRollingTelemetryArgumentValue).values })
: group.patchValue({ ts: (result as CalculatedFieldSingleArgumentValue).ts, value: (result as CalculatedFieldSingleArgumentValue).value }) );
}
private getSimpleArgumentFormGroup({ argumentName, ts, value }: CalculatedFieldSingleArgumentValue): FormGroup {
return this.fb.group({
argumentName: [{ value: argumentName, disabled: true}],
ts: [ts],
value: [value]
}) as FormGroup;
}
private getRollingArgumentFormGroup({ argumentName, timeWindow, values }: CalculatedFieldRollingTelemetryArgumentValue): FormGroup {
return this.fb.group({
timeWindow: [timeWindow ?? {}],
argumentName: [{ value: argumentName, disabled: true }],
values: [values]
}) as FormGroup;
}
private getValue(): CalculatedFieldEventArguments {
return this.argumentsFormArray.getRawValue().reduce((acc, rowItem) => {
const { argumentName, value } = rowItem;
const { argumentName, ...value } = rowItem;
acc[argumentName] = value;
return acc;
}, {}) as { argumentName: string; value: unknown };
}, {});
}
}

View File

@ -36,9 +36,11 @@
#expressionContent
formControlName="expression"
functionName="calculate"
class="expression-edit"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[fillHeight]="true"
[highlightRules]="data.argumentsHighlightRules"
[scriptLanguage]="ScriptLanguage.TBEL"
[editorCompleter]="data.argumentsEditorCompleter"
resultType="object"
@ -52,7 +54,7 @@
<div class="block-label-container right-top">
<span class="block-label">{{ 'calculated-fields.arguments' | translate }}</span>
</div>
<tb-calculated-field-test-arguments class="size-full" formControlName="arguments"/>
<tb-calculated-field-test-arguments class="size-full" formControlName="arguments" [argumentsTypeMap]="argumentsTypeMap"/>
</div>
</div>
<div #bottomRightPanel class="test-block-content">

View File

@ -71,4 +71,20 @@
background-image: url("../../../../../../../assets/split.js/grips/vertical.png");
}
}
.expression-edit {
.ace_tb {
&.ace_calculated-field {
&-key {
color: #C52F00;
}
&-ts, &-time-window, &-values, &-value {
color: #7214D0;
}
&-start-ts, &-end-ts, &-limit {
color: #185F2A;
}
}
}
}
}

View File

@ -38,7 +38,12 @@ import { beautifyJs } from '@shared/models/beautify.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs/operators';
import { CalculatedFieldTestScriptDialogData } from '@shared/models/calculated-field.models';
import {
ArgumentType,
CalculatedFieldEventArguments,
CalculatedFieldTestScriptDialogData,
TestArgumentTypeMap
} from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-calculated-field-script-test-dialog',
@ -61,6 +66,7 @@ export class CalculatedFieldScriptTestDialogComponent extends DialogComponent<Ca
arguments: [],
output: []
});
argumentsTypeMap = new Map<string, ArgumentType>();
readonly ContentType = ContentType;
readonly ScriptLanguage = ScriptLanguage;
@ -81,7 +87,7 @@ export class CalculatedFieldScriptTestDialogComponent extends DialogComponent<Ca
beautifyJs(this.data.expression, {indent_size: 4}).pipe(filter(Boolean), takeUntilDestroyed()).subscribe(
(res) => this.calculatedFieldScriptTestFormGroup.get('expression').patchValue(res, {emitEvent: false})
);
this.calculatedFieldScriptTestFormGroup.get('arguments').patchValue(this.data.arguments, {emitEvent: false});
this.calculatedFieldScriptTestFormGroup.get('arguments').patchValue(this.getArgumentsValue());
}
ngAfterViewInit(): void {
@ -117,7 +123,7 @@ export class CalculatedFieldScriptTestDialogComponent extends DialogComponent<Ca
if (this.checkInputParamErrors()) {
return this.calculatedFieldService.testScript({
expression: this.calculatedFieldScriptTestFormGroup.get('expression').value,
arguments: this.calculatedFieldScriptTestFormGroup.get('arguments').value
arguments: this.getTestArguments()
}).pipe(
switchMap(result => {
if (result.error) {
@ -157,6 +163,26 @@ export class CalculatedFieldScriptTestDialogComponent extends DialogComponent<Ca
this.initSplitLayout(this.testScriptContainer.nativeElement.clientWidth <= 960);
}
private getTestArguments(): CalculatedFieldEventArguments {
const argumentsValue = this.calculatedFieldScriptTestFormGroup.get('arguments').value;
return Object.keys(argumentsValue)
.reduce((acc, key) => {
acc[key] = argumentsValue[key];
acc[key].type = TestArgumentTypeMap.get(this.argumentsTypeMap.get(key));
return acc;
}, {});
}
private getArgumentsValue(): CalculatedFieldEventArguments {
return Object.keys(this.data.arguments)
.reduce((acc, key) => {
const { type, ...argumentObj } = this.data.arguments[key];
this.argumentsTypeMap.set(key, type);
acc[key] = argumentObj;
return acc;
}, {});
}
private initSplitLayout(smallMode = false): void {
const [leftPanel, rightPanel, topRightPanel, bottomRightPanel] = [
this.leftPanelElmRef.nativeElement,

View File

@ -15,7 +15,7 @@
limitations under the License.
-->
<form [formGroup]="jsonFormGroup" (ngSubmit)="add()" style="min-width: 400px;">
<form [formGroup]="jsonFormGroup" (ngSubmit)="add()" style="min-width: 400px;" class="h-full">
<mat-toolbar class="flex flex-row" color="primary">
<h2>{{ title }}</h2>
<span class="flex-1"></span>
@ -25,30 +25,26 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async">
<div style="height: 4px;"></div>
<div mat-dialog-content class="flex-1">
<tb-json-object-edit
formControlName="json"
class="block h-full"
label="{{ 'value.json-value' | translate }}"
[jsonRequired]="required"
[fillHeight]="false">
[fillHeight]="data.fillHeight">
</tb-json-object-edit>
</fieldset>
</div>
<div mat-dialog-actions class="flex flex-row items-center justify-end">
<span class="flex-1"></span>
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ cancelButtonLabel }}
</button>
<button mat-button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || jsonFormGroup.invalid || !jsonFormGroup.dirty">
[disabled]="jsonFormGroup.invalid || !jsonFormGroup.dirty">
{{ saveButtonLabel }}
</button>
</div>

View File

@ -30,6 +30,7 @@ export interface JsonObjectEditDialogData {
title?: string;
saveLabel?: string;
cancelLabel?: string;
fillHeight?: boolean;
}
@Component({

View File

@ -184,9 +184,12 @@ export class JsFuncComponent implements OnInit, OnChanges, OnDestroy, ControlVal
this.updateFunctionArgsString();
this.updateFunctionLabel();
}
if (changes.editorCompleter) {
if (changes.editorCompleter?.previousValue) {
this.updateCompleters();
}
if (changes.highlightRules?.previousValue) {
this.updateHighlightRules();
}
}
ngOnInit(): void {
@ -247,21 +250,7 @@ export class JsFuncComponent implements OnInit, OnChanges, OnDestroy, ControlVal
}
});
}
// @ts-ignore
if (!!this.highlightRules && !!this.jsEditor.session.$mode) {
// @ts-ignore
const newMode = new this.jsEditor.session.$mode.constructor();
newMode.$highlightRules = new newMode.HighlightRules();
for(const group in this.highlightRules) {
if(!!newMode.$highlightRules.$rules[group]) {
newMode.$highlightRules.$rules[group].unshift(...this.highlightRules[group]);
} else {
newMode.$highlightRules.$rules[group] = this.highlightRules[group];
}
}
// @ts-ignore
this.jsEditor.session.$onChangeMode(newMode);
}
this.updateHighlightRules();
this.updateJsWorkerGlobals();
this.initialCompleters = this.jsEditor.completers || [];
this.updateCompleters();
@ -282,6 +271,24 @@ export class JsFuncComponent implements OnInit, OnChanges, OnDestroy, ControlVal
}
}
private updateHighlightRules(): void {
// @ts-ignore
if (!!this.highlightRules && !!this.jsEditor.session.$mode) {
// @ts-ignore
const newMode = new this.jsEditor.session.$mode.constructor();
newMode.$highlightRules = new newMode.HighlightRules();
for(const group in this.highlightRules) {
if(!!newMode.$highlightRules.$rules[group]) {
newMode.$highlightRules.$rules[group].unshift(...this.highlightRules[group]);
} else {
newMode.$highlightRules.$rules[group] = this.highlightRules[group];
}
}
// @ts-ignore
this.jsEditor.session.$onChangeMode(newMode);
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();

View File

@ -93,7 +93,7 @@
warning
</mat-icon>
</mat-form-field>
<button mat-icon-button class="tb-mat-32" (click)="openEditJSONDialog($event)">
<button *ngIf="!hideJsonEdit" mat-icon-button class="tb-mat-32" (click)="openEditJSONDialog($event)">
<mat-icon class="tb-mat-20">open_in_new</mat-icon>
</button>
</div>

View File

@ -85,6 +85,10 @@ export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, Contro
@coerceBoolean()
required = true;
@Input()
@coerceBoolean()
hideJsonEdit = false;
@Input()
layout: ValueInputLayout | Layout = 'row';

View File

@ -28,6 +28,7 @@ import { EntityType } from '@shared/models/entity-type.models';
import { AliasFilterType } from '@shared/models/alias.models';
import { Observable } from 'rxjs';
import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { AceHighlightRules } from '@shared/models/ace/ace.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId, ExportableEntity<CalculatedFieldId> {
debugSettings?: EntityDebugSettings;
@ -85,6 +86,19 @@ export enum ArgumentType {
Rolling = 'TS_ROLLING',
}
export enum TestArgumentType {
Single = 'SINGLE_VALUE',
Rolling = 'TS_ROLLING',
}
export const TestArgumentTypeMap = new Map<ArgumentType, TestArgumentType>(
[
[ArgumentType.Attribute, TestArgumentType.Single],
[ArgumentType.LatestTelemetry, TestArgumentType.Single],
[ArgumentType.Rolling, TestArgumentType.Rolling],
]
)
export enum OutputType {
Attribute = 'ATTRIBUTES',
Timeseries = 'TIME_SERIES',
@ -149,12 +163,13 @@ export interface CalculatedFieldDebugDialogData {
}
export interface CalculatedFieldTestScriptInputParams {
arguments: Record<string, unknown>,
arguments: CalculatedFieldEventArguments;
expression: string;
}
export interface CalculatedFieldTestScriptDialogData extends CalculatedFieldTestScriptInputParams {
argumentsEditorCompleter: TbEditorCompleter
argumentsEditorCompleter: TbEditorCompleter;
argumentsHighlightRules: AceHighlightRules;
openCalculatedFieldEdit?: boolean;
}
@ -189,18 +204,23 @@ export const getCalculatedFieldCurrentEntityFilter = (entityName: string, entity
}
}
export interface CalculatedFieldAttributeArgumentValue<ValueType = unknown> {
export interface CalculatedFieldArgumentValueBase {
argumentName: string;
type: ArgumentType;
}
export interface CalculatedFieldAttributeArgumentValue<ValueType = unknown> extends CalculatedFieldArgumentValueBase {
ts: number;
value: ValueType;
}
export interface CalculatedFieldLatestTelemetryArgumentValue<ValueType = unknown> {
export interface CalculatedFieldLatestTelemetryArgumentValue<ValueType = unknown> extends CalculatedFieldArgumentValueBase {
ts: number;
value: ValueType;
}
export interface CalculatedFieldRollingTelemetryArgumentValue<ValueType = unknown> {
timewindow: { startTs: number; endTs: number; limit: number };
export interface CalculatedFieldRollingTelemetryArgumentValue<ValueType = unknown> extends CalculatedFieldArgumentValueBase {
timeWindow: { startTs: number; endTs: number; limit: number };
values: CalculatedFieldSingleArgumentValue<ValueType>[];
}
@ -248,7 +268,7 @@ export const CalculatedFieldAttributeValueArgumentAutocomplete = {
export const CalculatedFieldRollingValueArgumentAutocomplete = {
meta: 'object',
type: '{ values: { ts: number; value: any; }[]; timewindow: { startTs: number; endTs: number; limit: number } }; }',
type: '{ values: { ts: number; value: any; }[]; timeWindow: { startTs: number; endTs: number; limit: number } }; }',
description: 'Calculated field rolling value argument.',
children: {
values: {
@ -256,7 +276,7 @@ export const CalculatedFieldRollingValueArgumentAutocomplete = {
type: '{ ts: number; value: any; }[]',
description: 'Values array',
},
timewindow: {
timeWindow: {
meta: 'object',
type: '{ startTs: number; endTs: number; limit: number }',
description: 'Time window configuration',
@ -295,5 +315,72 @@ export const getCalculatedFieldArgumentsEditorCompleter = (argumentsObj: Record<
break;
}
return acc;
}, {}))
}, {}));
}
export const getCalculatedFieldArgumentsHighlights = (
argumentsObj: Record<string, CalculatedFieldArgument>
): AceHighlightRules => {
return {
start: Object.keys(argumentsObj).map(key => ({
token: 'tb.calculated-field-key',
regex: `\\b${key}\\b`,
next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling
? 'calculatedFieldRollingArgumentValue'
: 'calculatedFieldSingleArgumentValue'
})),
...calculatedFieldSingleArgumentValueHighlightRules,
...calculatedFieldRollingArgumentValueHighlightRules,
...calculatedFieldTimeWindowArgumentValueHighlightRules
};
};
const calculatedFieldSingleArgumentValueHighlightRules: AceHighlightRules = {
calculatedFieldSingleArgumentValue: [
{
token: 'tb.calculated-field-value',
regex: /value/,
next: 'no_regex'
},
{
token: 'tb.calculated-field-ts',
regex: /ts/,
next: 'no_regex'
}
],
}
const calculatedFieldRollingArgumentValueHighlightRules: AceHighlightRules = {
calculatedFieldRollingArgumentValue: [
{
token: 'tb.calculated-field-values',
regex: /values/,
next: 'no_regex'
},
{
token: 'tb.calculated-field-time-window',
regex: /timeWindow/,
next: 'calculatedFieldRollingArgumentTimeWindow'
}
],
}
const calculatedFieldTimeWindowArgumentValueHighlightRules: AceHighlightRules = {
calculatedFieldRollingArgumentTimeWindow: [
{
token: 'tb.calculated-field-start-ts',
regex: /startTs/,
next: 'no_regex'
},
{
token: 'tb.calculated-field-end-ts',
regex: /endTs/,
next: 'no_regex'
},
{
token: 'tb.calculated-field-limit',
regex: /limit/,
next: 'no_regex'
}
]
}

View File

@ -1100,6 +1100,7 @@
"general": "General",
"username": "Username",
"password": "Password",
"data": "Data",
"enter-username": "Enter username",
"enter-password": "Enter password",
"enter-search": "Enter search",