This is an automated email from the ASF dual-hosted git repository. zehnder pushed a commit to branch test-uns in repository https://gitbox.apache.org/repos/asf/streampipes.git
commit 8c65f6f55822aa1381633a6645784648b01b40a1 Author: Philipp Zehnder <[email protected]> AuthorDate: Fri Mar 20 14:35:41 2026 +0100 feat: Add topic selection based on asset model --- .../start-adapter-configuration.component.html | 45 ++++++ .../start-adapter-configuration.component.scss | 4 + .../start-adapter-configuration.component.ts | 156 +++++++++++++++++++++ .../existing-adapters.component.html | 27 +++- .../existing-adapters.component.scss | 4 + .../existing-adapters.component.ts | 29 ++++ 6 files changed, 263 insertions(+), 2 deletions(-) diff --git a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.html b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.html index c988134b88..6bf397ff18 100644 --- a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.html +++ b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.html @@ -91,10 +91,55 @@ </mat-form-field> </sp-form-field> @if (!isEditMode) { + <sp-form-field + [level]="2" + [label]="'Topic Template' | translate" + > + <mat-form-field color="accent"> + <input + formControlName="adapterTopicTemplate" + matInput + id="input-AdapterTopicTemplate" + [placeholder]=" + '{site}/{area}/{asset_path}/{adapterName}' + | translate + " + data-cy="sp-adapter-topic-template" + /> + </mat-form-field> + <small> + Placeholders: {site}, {area}, + {asset}, {asset_path}, + {adapterName}, {asset_type}, + {isa95_type}, + {additional.<key>}, + {asset_additional.<key>} + </small> + </sp-form-field> <sp-form-field [level]="2" [label]="'Topic Name (Optional)' | translate" > + <button + form-field-actions + mat-stroked-button + color="accent" + type="button" + [disabled]="!canApplyTopicTemplate" + [matTooltip]=" + canApplyTopicTemplate + ? ('Generate topic from selected asset' + | translate) + : ('Select an asset and provide a template' + | translate) + " + matTooltipPosition="above" + data-cy="sp-generate-topic-from-asset" + (click)="applyTopicTemplateFromAsset()" + > + <mat-icon>auto_fix_high</mat-icon> + {{ 'Apply Template' | translate }} + </button> <mat-form-field color="accent"> <input formControlName="adapterTopicName" diff --git a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.scss b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.scss index 13cbc4aacb..a68cc09536 100644 --- a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.scss +++ b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.scss @@ -15,3 +15,7 @@ * limitations under the License. * */ + +.topic-input-row { + gap: 8px; +} diff --git a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.ts b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.ts index 1688e75d94..37a3b1aff7 100644 --- a/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.ts +++ b/ui/src/app/connect/components/adapter-configuration/start-adapter-configuration/start-adapter-configuration.component.ts @@ -25,9 +25,15 @@ import { } from '@angular/core'; import { AdapterDescription, + AssetConstants, + AssetManagementService, + AssetSiteDesc, EventSchema, + GenericStorageService, ReduceEventRateRule, RemoveDuplicateRule, + SpAsset, + SpAssetModel, SpAssetTreeNode, UserInfo, } from '@streampipes/platform-services'; @@ -66,6 +72,8 @@ import { MatOption, MatSelect } from '@angular/material/select'; import { MatTooltip } from '@angular/material/tooltip'; import { AdapterCodePanelComponent } from '../../adapter-code-panel/adapter-code-panel.component'; import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'sp-start-adapter-configuration', @@ -89,6 +97,7 @@ import { MatButton } from '@angular/material/button'; MatTooltip, AdapterCodePanelComponent, MatButton, + MatIcon, TranslatePipe, TimestampPipe, ], @@ -100,6 +109,8 @@ export class StartAdapterConfigurationComponent implements OnInit { private timestampPipe = inject(TimestampPipe); private translateService = inject(TranslateService); private currentUserService = inject(CurrentUserService); + private assetManagementService = inject(AssetManagementService); + private genericStorageService = inject(GenericStorageService); /** * Adapter description the selected format is added to @@ -134,6 +145,8 @@ export class StartAdapterConfigurationComponent implements OnInit { startAdapterForm: UntypedFormGroup; startAdapterSettingsFormValid = false; + readonly defaultTopicTemplate = '{site}/{area}/{asset_path}/{adapterName}'; + private sitesById: Record<string, AssetSiteDesc> = {}; currentUser: UserInfo; @@ -172,6 +185,10 @@ export class StartAdapterConfigurationComponent implements OnInit { ValidateName(), ]), ); + this.startAdapterForm.addControl( + 'adapterTopicTemplate', + new UntypedFormControl(this.defaultTopicTemplate), + ); this.startAdapterForm.addControl( 'adapterTopicName', new UntypedFormControl(this.adapterDescription.topicName ?? ''), @@ -251,6 +268,145 @@ export class StartAdapterConfigurationComponent implements OnInit { this.originalAssets = updatedAssets; } + async applyTopicTemplateFromAsset(): Promise<void> { + const selectedAsset = this.selectedAssets[0]; + const template = + this.startAdapterForm.get('adapterTopicTemplate')?.value ?? + this.defaultTopicTemplate; + + if (!selectedAsset || !template?.trim()) { + return; + } + + const assetModel = await firstValueFrom( + this.assetManagementService.getAsset(selectedAsset.spAssetModelId), + ); + await this.ensureSitesLoaded(); + const assetPath = this.findAssetPath(assetModel, selectedAsset.assetId); + + if (!assetPath.length) { + return; + } + + const resolvedTopic = this.resolveTopicTemplate( + template, + assetModel, + assetPath, + ); + + this.startAdapterForm.get('adapterTopicName')?.setValue(resolvedTopic); + } + + get canApplyTopicTemplate(): boolean { + return ( + !this.isEditMode && + this.selectedAssets.length > 0 && + !!this.startAdapterForm?.get('adapterTopicTemplate')?.value?.trim() + ); + } + + private resolveTopicTemplate( + template: string, + assetModel: SpAssetModel, + assetPath: SpAsset[], + ): string { + const selectedAsset = assetPath[assetPath.length - 1]; + const delimiter = template.includes('/') ? '/' : '.'; + const hierarchyNames = assetPath.map(asset => + this.normalizeTopicSegment(asset.assetName), + ); + const assetSite = assetModel.assetSite; + const assetType = selectedAsset.assetType; + const siteLabel = + this.sitesById[assetSite?.siteId]?.label ?? assetSite?.siteId; + + const replacements: Record<string, string> = { + site: this.normalizeTopicSegment(siteLabel), + area: this.normalizeTopicSegment(assetSite?.area), + asset: this.normalizeTopicSegment(selectedAsset.assetName), + asset_path: hierarchyNames.join(delimiter), + asset_type: this.normalizeTopicSegment(assetType?.assetTypeLabel), + isa95_type: this.normalizeTopicSegment(assetType?.isa95AssetType), + adapterName: this.normalizeTopicSegment( + this.adapterDescription?.name, + ), + }; + + Object.entries(assetModel.additionalData ?? {}).forEach( + ([key, value]) => { + replacements[`additional.${key}`] = this.normalizeTopicSegment( + String(value), + ); + }, + ); + + Object.entries(selectedAsset.additionalData ?? {}).forEach( + ([key, value]) => { + replacements[`asset_additional.${key}`] = + this.normalizeTopicSegment(String(value)); + }, + ); + + return template + .replace(/\{([^}]+)\}/g, (_match, placeholder: string) => { + return replacements[placeholder] ?? ''; + }) + .replaceAll('//', '/') + .replaceAll('..', '.') + .replace(/^[/.\s]+|[/.\s]+$/g, ''); + } + + private async ensureSitesLoaded(): Promise<void> { + if (Object.keys(this.sitesById).length > 0) { + return; + } + + const sites = await firstValueFrom( + this.genericStorageService.getAllDocuments( + AssetConstants.ASSET_SITES_APP_DOC_NAME, + ), + ); + this.sitesById = (sites as AssetSiteDesc[]).reduce( + (acc, site) => { + acc[site._id] = site; + return acc; + }, + {} as Record<string, AssetSiteDesc>, + ); + } + + private findAssetPath( + assetModel: SpAssetModel, + assetId: string, + ): SpAsset[] { + const walk = (asset: SpAsset, path: SpAsset[]): SpAsset[] | null => { + const nextPath = [...path, asset]; + if (asset.assetId === assetId) { + return nextPath; + } + + for (const child of asset.assets ?? []) { + const result = walk(child, nextPath); + if (result) { + return result; + } + } + + return null; + }; + + return walk(assetModel, []) ?? []; + } + + private normalizeTopicSegment(value: string | null | undefined): string { + return (value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9/_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[-/.]+|[-/.]+$/g, ''); + } + onToggleDuplicates(isChecked: boolean): void { const transformationConfig = this.adapterDescription.transformationConfig; diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html index 7af1986838..73ef86072c 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html @@ -294,9 +294,32 @@ </div> } @else { <div fxFlex="100" fxLayout="column" class="uns-view"> - <mat-accordion> + <div + fxLayout="row" + fxLayoutAlign="end center" + class="uns-actions" + > + <button + mat-stroked-button + color="accent" + type="button" + (click)="toggleAllUnsGroups()" + > + {{ + (areAllUnsGroupsCollapsed + ? 'Expand all' + : 'Collapse all' + ) | translate + }} + </button> + </div> + <mat-accordion [multi]="true"> @for (group of unsTopicGroups; track group.id) { - <mat-expansion-panel [expanded]="$first"> + <mat-expansion-panel + [expanded]="unsGroupExpanded[group.id] ?? true" + (opened)="setUnsGroupExpanded(group.id, true)" + (closed)="setUnsGroupExpanded(group.id, false)" + > <mat-expansion-panel-header> <span class="uns-group-title">{{ group.namespace diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss index fc71abffcf..03eab5f537 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss @@ -59,6 +59,10 @@ margin-top: 10px; } +.uns-actions { + margin-bottom: 10px; +} + .uns-group-title { font-weight: 600; font-family: monospace; diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts index 2f15b1bc12..e283cbe099 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts @@ -192,6 +192,7 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { stopAdapterErrorText = 'Could not stop adapter'; overviewMode: AdapterOverviewMode = 'table'; unsTopicGroups: UnsTopicGroup[] = []; + unsGroupExpanded: Record<string, boolean> = {}; private adapterService = inject(AdapterService); private dialogService = inject(DialogService); @@ -420,6 +421,7 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { }); this.dataSource.data = this.filteredAdapters; this.unsTopicGroups = this.buildUnsTopicGroups(this.filteredAdapters); + this.syncUnsGroupExpansionState(); } startAdapterTutorial() { @@ -447,6 +449,23 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { this.overviewMode = mode; } + setUnsGroupExpanded(groupId: string, expanded: boolean): void { + this.unsGroupExpanded[groupId] = expanded; + } + + toggleAllUnsGroups(): void { + const expand = this.areAllUnsGroupsCollapsed; + this.unsTopicGroups.forEach(group => { + this.unsGroupExpanded[group.id] = expand; + }); + } + + get areAllUnsGroupsCollapsed(): boolean { + return this.unsTopicGroups.every( + group => this.unsGroupExpanded[group.id] === false, + ); + } + getTopicName(adapter: AdapterDescription): string { return ( adapter.eventGrounding?.transportProtocols?.[0]?.topicDefinition @@ -512,6 +531,16 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { return topicName.includes('/') ? '/' : '.'; } + private syncUnsGroupExpansionState(): void { + const nextState: Record<string, boolean> = {}; + + this.unsTopicGroups.forEach(group => { + nextState[group.id] = this.unsGroupExpanded[group.id] ?? true; + }); + + this.unsGroupExpanded = nextState; + } + ngOnDestroy() { this.user$?.unsubscribe(); this.tutorial$?.unsubscribe();
