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: &#123;site&#125;, &#123;area&#125;,
+                            &#123;asset&#125;, &#123;asset_path&#125;,
+                            &#123;adapterName&#125;, &#123;asset_type&#125;,
+                            &#123;isa95_type&#125;,
+                            &#123;additional.&lt;key&gt;&#125;,
+                            &#123;asset_additional.&lt;key&gt;&#125;
+                        </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();

Reply via email to