From da86dcc23404503f91d1dad337b082cf791770ec Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Jun 2026 17:59:39 +0300 Subject: [PATCH 01/10] refactor(iot-hub): centralise item-type icons and chip colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add itemTypeIcons + getItemTypeIcon helper to iot-hub-item.models, mirroring mp-item.models in the iot-hub project: widgets / dashboard / apps / mdi:function-variant / settings_ethernet / mdi:bell-cog / devices_other with `category` as the fallback. - Replace every hand-rolled item-type → icon switch with calls to getItemTypeIcon: item detail dialog (getTypeIcon / getCompactIcon fallback path), installed-items table (getItemTypeIcon), iot-hub-home (getCompactIcon / getItemTypeIcon), item-card (getPlaceholderIcon default + per-type fallbacks), item-link-card (getCompactIcon / getTypeIcon). - Swap the remaining mat-icon placeholders that render an item-type icon to tb-icon (installed-items table chip, home search popup thumb placeholder, item-link-card thumb fallback). Generic status / action icons stay on mat-icon. - Refresh the colored item-type chip palette in the installed items table to match itemTypeChipColors from mp-item.models, and add the previously-missing tb-type-alarm-rule rule so that chip picks up the new orange palette instead of falling through to no background. --- ...t-hub-installed-items-table.component.html | 2 +- ...t-hub-installed-items-table.component.scss | 14 ++++++----- ...iot-hub-installed-items-table.component.ts | 13 ++-------- .../iot-hub/iot-hub-item-card.component.ts | 22 ++++++++++------- .../iot-hub-item-detail-dialog.component.ts | 19 ++++----------- .../iot-hub-item-link-card.component.html | 2 +- .../iot-hub-item-link-card.component.ts | 24 +++---------------- .../pages/iot-hub/iot-hub-home.component.html | 2 +- .../pages/iot-hub/iot-hub-home.component.ts | 21 +++------------- .../models/iot-hub/iot-hub-item.models.ts | 17 +++++++++++++ 10 files changed, 54 insertions(+), 82 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html index 7bbff19a85..545d219596 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html @@ -29,7 +29,7 @@ {{ 'iot-hub.item-type' | translate }} - {{ getItemTypeIcon(item.itemType) }} + {{ getItemTypeIcon(item.itemType) }} {{ getItemTypeLabel(item.itemType) }} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss index d83bb3e192..1edef65112 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -122,12 +122,14 @@ --mat-icon-color: currentColor; } -.tb-type-widget { color: rgb(32, 115, 61); background: rgba(46, 166, 88, 0.06); } -.tb-type-dashboard { color: rgb(61, 76, 166); background: rgba(61, 76, 166, 0.06); } -.tb-type-solution-template { color: rgb(0, 137, 186); background: rgba(0, 137, 186, 0.06); } -.tb-type-calc-field { color: rgb(40, 120, 148); background: rgba(40, 120, 148, 0.06); } -.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); } -.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 0.06); } +// Colors mirror itemTypeChipColors from the iot-hub mp-item.models. +.tb-type-device { color: #3d4ca6; background: rgba(61, 76, 166, 0.06); } +.tb-type-solution-template { color: #2c6cb4; background: rgba(44, 108, 180, 0.06); } +.tb-type-widget { color: #20733d; background: rgba(46, 166, 88, 0.06); } +.tb-type-calc-field { color: #3db5e0; background: rgba(61, 181, 224, 0.06); } +.tb-type-alarm-rule { color: #d7702f; background: rgba(215, 112, 47, 0.06); } +.tb-type-rule-chain { color: #aa5be3; background: rgba(170, 91, 227, 0.06); } +.tb-type-dashboard { color: #607d8b; background: rgba(96, 125, 139, 0.06); } // "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8 .tb-installed-version-btn { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts index 09917528be..b625d96e2b 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts @@ -44,7 +44,7 @@ import { ItemPublishedVersionInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { EntityType } from '@shared/models/entity-type.models'; import { getEntityDetailsPageURL } from '@core/utils'; import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; @@ -153,16 +153,7 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges, } getItemTypeIcon(itemType: string): string { - switch (itemType) { - case 'WIDGET': return 'widgets'; - case 'DASHBOARD': return 'dashboard'; - case 'SOLUTION_TEMPLATE': return 'integration_instructions'; - case 'CALCULATED_FIELD': return 'functions'; - case 'ALARM_RULE': return 'notification_important'; - case 'RULE_CHAIN': return 'settings_ethernet'; - case 'DEVICE': return 'memory'; - default: return 'category'; - } + return getItemTypeIcon(itemType); } deleteItem(item: IotHubInstalledItem): void { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts index 38c1e26dcf..bdf50acddf 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts @@ -16,7 +16,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getItemTypeIcon, ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { TranslateService } from '@ngx-translate/core'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; @@ -69,17 +69,21 @@ export class TbIotHubItemCardComponent { getPlaceholderIcon(): string { switch (this.item.type) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboard'; - case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; case ItemType.CALCULATED_FIELD: - return this.item.icon || cfTypeIcons.get(this.item.dataDescriptor?.cfType) || 'functions'; + return this.item.icon + || cfTypeIcons.get(this.item.dataDescriptor?.cfType) + || getItemTypeIcon(ItemType.CALCULATED_FIELD); case ItemType.ALARM_RULE: - return this.item.icon || 'notification_important'; + return this.item.icon || getItemTypeIcon(ItemType.ALARM_RULE); case ItemType.RULE_CHAIN: - return this.item.icon || (this.item.dataDescriptor?.ruleChainType === 'EDGE' ? 'router' : 'device_hub'); - case ItemType.DEVICE: return 'memory'; - default: return 'extension'; + return this.item.icon + || (this.item.dataDescriptor?.ruleChainType === 'EDGE' + ? 'router' + : (this.item.dataDescriptor?.ruleChainType === 'CORE' + ? 'device_hub' + : getItemTypeIcon(ItemType.RULE_CHAIN))); + default: + return getItemTypeIcon(this.item.type); } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts index c770045ddc..382f2d0f88 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -21,7 +21,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DialogComponent } from '@shared/components/dialog.component'; import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, NodeInfo } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { TranslateService } from '@ngx-translate/core'; @@ -103,16 +103,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent } @else { - {{ getTypeIcon() }} + {{ getTypeIcon() }} } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts index c2ca0ba1ea..743f02bea4 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts @@ -17,7 +17,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getItemTypeIcon, ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; type CardState = 'loading' | 'loaded' | 'unavailable'; @@ -71,29 +71,11 @@ export class TbIotHubItemLinkCardComponent implements OnInit { getCompactIcon(): string { const item = this.item!; - if (item.icon) { - return item.icon; - } - switch (item.type) { - case ItemType.CALCULATED_FIELD: return 'functions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.RULE_CHAIN: return 'account_tree'; - default: return 'category'; - } + return item.icon || getItemTypeIcon(item.type); } getTypeIcon(): string { - const item = this.item!; - switch (item.type) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboard'; - case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; - case ItemType.CALCULATED_FIELD: return 'functions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.RULE_CHAIN: return 'account_tree'; - case ItemType.DEVICE: return 'memory'; - default: return 'category'; - } + return getItemTypeIcon(this.item!.type); } getCompactColor(): string { diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html index 682411d884..f9f7285c3b 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -109,7 +109,7 @@ @if (getItemImage(item); as imgUrl) { } @else { - {{ getItemTypeIcon(item.type) }} + {{ getItemTypeIcon(item.type) }} } } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts index 72a5513056..c8fff1ea7e 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -25,7 +25,7 @@ import { MediaBreakpoints } from '@shared/models/constants'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { TranslateService } from '@ngx-translate/core'; @@ -266,15 +266,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } getCompactIcon(item: MpItemVersionView): string { - if (item.icon) { - return item.icon; - } - switch (item.type) { - case ItemType.CALCULATED_FIELD: return 'functions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.RULE_CHAIN: return 'settings_ethernet'; - default: return 'category'; - } + return item.icon || getItemTypeIcon(item.type); } getItemImage(item: MpItemVersionView): string | null { @@ -282,14 +274,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } getItemTypeIcon(type: ItemType): string { - switch (type) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboard'; - case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.DEVICE: return 'memory'; - default: return 'category'; - } + return getItemTypeIcon(type); } getSearchGroupLabel(type: ItemType): string { diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts index 6451bc5d52..6c528384c4 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts @@ -36,6 +36,23 @@ export const itemTypeTranslations = new Map( ] ); +// Canonical icon lookup per item type. Values are tb-icon +// identifiers (Material symbol names or `mdi:*` strings) and should +// be used everywhere an icon is rendered for an item type so the +// mapping stays consistent across the app. +export const itemTypeIcons: Record = { + [ItemType.WIDGET]: 'widgets', + [ItemType.DASHBOARD]: 'dashboard', + [ItemType.SOLUTION_TEMPLATE]: 'apps', + [ItemType.CALCULATED_FIELD]: 'mdi:function-variant', + [ItemType.RULE_CHAIN]: 'settings_ethernet', + [ItemType.ALARM_RULE]: 'mdi:bell-cog', + [ItemType.DEVICE]: 'devices_other' +}; + +export const getItemTypeIcon = (type?: string | null): string => + type && itemTypeIcons[type] ? itemTypeIcons[type] : 'category'; + /** * Item types discoverable to creators in the marketplace UI. * DASHBOARD is intentionally absent (IoT Hub no longer accepts Dashboard contributions). From 18232e9c8c7acc3ec0e84be93b7b4898b407d295 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Jun 2026 19:49:13 +0300 Subject: [PATCH 02/10] feat(iot-hub): force markdown links to new tab + verified item-link card - TbIotHubMarkdownComponent.parseData now runs through a forceLinksOpenInNewTab post-pass that (a) appends the {:target="_blank"} suffix to the TEXT of any markdown link [text](url) that doesn't already carry it, matching the MarkedOptionsService.renderer.link contract that strips the suffix and rewrites the with target="_blank", and (b) injects target="_blank" into raw HTML anchors that don't already declare a target= attribute. Image syntax ![alt](url) is excluded via lookbehind. - iot-hub-markdown SCSS narrows the link styling selector to a:not(.mdc-button):not(.tb-iot-hub-item-link-card) so the embedded item-link card anchor keeps its own typography. - iot-hub-item-link-card now renders a verified icon (teal #00695c via a new tb-iot-hub-item-link-verified-icon class) when item.creatorVerified is true, matching the item-card treatment; the default person icon is used otherwise. --- .../iot-hub-item-link-card.component.html | 6 ++++- .../iot-hub-item-link-card.component.scss | 8 ++++++ .../iot-hub/iot-hub-markdown.component.scss | 2 +- .../iot-hub/iot-hub-markdown.component.ts | 27 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html index 8d2d8b25c9..fbf00c7dd7 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html @@ -47,7 +47,11 @@ diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss index 2713303d6c..cdf4005199 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss @@ -37,6 +37,9 @@ background: rgba(4, 138, 211, 0.04); border-color: rgba(4, 138, 211, 0.32); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + .tb-iot-hub-item-link-name, .tb-iot-hub-item-link-author { + text-decoration: underline; + } } &--unavailable { @@ -128,6 +131,11 @@ width: 14px; height: 14px; } + + // Verified-creator accent — matches the item card chip color. + .tb-iot-hub-item-link-verified-icon { + color: #00695c; + } } .tb-iot-hub-item-link-skeleton-block, diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss index 57e3d25101..be2ab69a76 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss @@ -128,7 +128,7 @@ $gallery-border-hover-color: #2a7dec; max-width: 100%; } - a:not(.mdc-button) { + a:not(.mdc-button):not(.tb-iot-hub-item-link-card) { font-weight: 500; color: $link-color; text-decoration: none; diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts index 57df5a8074..a63d17c914 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts @@ -109,9 +109,36 @@ export class TbIotHubMarkdownComponent implements OnInit, OnChanges { parsed = replaceItemLinkPlaceholders(parsed); parsed = this.resolveImages(parsed); parsed = this.resolveVariables(parsed); + parsed = this.forceLinksOpenInNewTab(parsed); return parsed; } + // Ensure every link rendered through tb-markdown opens in a new tab. + // 1) For markdown links `[text](url)` — append the + // `{:target="_blank"}` suffix to the link TEXT (not the URL) + // so MarkedOptionsService.renderer.link, which checks + // `token.text.endsWith(targetBlankBlock)`, emits + // `target="_blank"` on the rendered . Skip links whose + // text already ends with the suffix and skip image syntax + // (`![alt](url)`). + // 2) For raw HTML anchors authored in the markdown — add + // `target="_blank"` when no `target=` attribute is already + // present. + private forceLinksOpenInNewTab(content: string): string { + const TARGET_BLANK_BLOCK = '{:target="_blank"}'; + content = content.replace( + /(? + text.endsWith(TARGET_BLANK_BLOCK) ? match : `[${text}${TARGET_BLANK_BLOCK}](${url})` + ); + content = content.replace( + /]*)>/gi, + (match, attrs: string) => + /\btarget\s*=/i.test(attrs) ? match : `` + ); + return content; + } + private prefixResourceUrls(markdown: string): string { const baseUrl = this.iotHubApiService.baseUrl; return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); From 8afc94cfc97728cc8b58723511de90980a40d036 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 10 Jun 2026 20:23:45 +0300 Subject: [PATCH 03/10] feat(chip-overflow): optional showOverflowedTitle native tooltip - Add a `showOverflowedTitle` boolean input (defaults to false, coerceBoolean-decorated) to ChipOverflowDirective. When true and hiddenCount > 0, the overflow `+N` chip gets a `title` attribute set to the comma-joined trimmed textContent of every overflowing chip, surfacing them via the native browser tooltip. When the flag is off, no items overflow, or hiddenCount drops back to zero, the title attribute is removed. - Keep `pointer-events` in sync with the title so the native tooltip actually fires: remove `pointer-events: none` while a title is present; reapply it otherwise (matches the initial state set in createOverflowChip). - Opt the IoT Hub item detail dialog's three tb-chip-overflow meta-chip groups (connectivity, categories, use-cases) into showOverflowedTitle so hovering the `+N` chip lists the hidden values. --- .../iot-hub-item-detail-dialog.component.html | 6 ++--- .../directives/chip-overflow.directive.ts | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html index 6c0318abbc..3827599242 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -203,7 +203,7 @@ @if (item.dataDescriptor?.connectivity?.length) {
{{ 'iot-hub.connectivity' | translate }} -
+
@for (conn of item.dataDescriptor.connectivity; track conn) { {{ conn }} } @@ -214,7 +214,7 @@ @if (item.categories?.length) {
{{ 'iot-hub.category' | translate }} -
+
@for (cat of item.categories; track cat) { {{ cat }} } @@ -225,7 +225,7 @@ @if (item.useCases?.length) {
{{ 'iot-hub.use-cases' | translate }} -
+
@for (uc of item.useCases; track uc) { {{ uc }} } diff --git a/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts index 7ed84f10e7..10585f1453 100644 --- a/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts +++ b/ui-ngx/src/app/shared/directives/chip-overflow.directive.ts @@ -15,6 +15,7 @@ /// import { AfterViewInit, Directive, ElementRef, Input, NgZone, OnDestroy, Renderer2 } from '@angular/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Directive({ selector: '[tb-chip-overflow]', @@ -28,6 +29,10 @@ export class ChipOverflowDirective implements AfterViewInit, OnDestroy { @Input() overflowClass = 'tb-overflow-chip'; @Input() minChips = 1; + @Input() + @coerceBoolean() + showOverflowedTitle = false; + private resizeObserver?: ResizeObserver; private mutationObserver?: MutationObserver; private overflowEl!: HTMLElement; @@ -151,6 +156,7 @@ export class ChipOverflowDirective implements AfterViewInit, OnDestroy { // 4. Determine which chips fit let usedWidth = 0; let hiddenCount = 0; + const hiddenChips: HTMLElement[] = []; const minChips = Math.max(1, this.minChips); for (let i = 0; i < chips.length; i++) { @@ -163,12 +169,29 @@ export class ChipOverflowDirective implements AfterViewInit, OnDestroy { usedWidth = nextUsedWidth; } else { hiddenCount++; + hiddenChips.push(chips[i]); } } if (hiddenCount > 0) { this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', String(hiddenCount))); this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); + if (this.showOverflowedTitle) { + const title = hiddenChips + .map(chip => (chip.textContent || '').trim()) + .filter(text => !!text) + .join(', '); + this.renderer.setAttribute(this.overflowEl, 'title', title); + // Allow pointer interaction so the browser's native title + // tooltip appears on hover. + this.renderer.removeStyle(this.overflowEl, 'pointerEvents'); + } else { + this.renderer.removeAttribute(this.overflowEl, 'title'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); + } + } else { + this.renderer.removeAttribute(this.overflowEl, 'title'); + this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); } } } From 54a0b80d22ebaa5ba719bdc3f6652658b56cb6f5 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 11:06:13 +0300 Subject: [PATCH 04/10] refactor(iot-hub): centralise installed-item entity URL + selectedTab deep link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a single getInstalledItemUrl(descriptor) helper to iot-hub-installed-item.models that maps every descriptor type (WIDGET / DASHBOARD / CALCULATED_FIELD / ALARM_RULE / RULE_CHAIN / SOLUTION_TEMPLATE) to its entity-details URL via the shared getEntityDetailsPageURL utility, appending `?selectedTab=cf` for calculated-field installs so the entity tabs page lands on the CF tab on first paint. - Drop the parallel resolveEntityDetailsUrl helper + iot-hub-components.models.ts file and rewire every installed-item open path (install dialog, update dialog, installed-items table, item detail dialog) to call getInstalledItemUrl. Installed-items table openEntity collapses to parseUrl + serializeUrl on the helper's output now that any selectedTab is part of the URL. - Teach EntityDetailsPageComponent to honour a `selectedTab=…` query param: read it from the current route on entityId change, strip it (replaceUrl) so refresh doesn't keep jumping the tab, then resolve a tab index via the new EntityTabsComponent.resolveTabIndex(tab) hook. Asset, device, asset-profile, and device-profile tabs override the hook to map the `cf` shortcut to their respective Calculated Fields tab position; other entities fall back to index 0. - EntityDetailsPanelComponent now applies this.selectedTab to the underlying MatTabGroup both when the entity-tabs query list refreshes and after view init, so the initial tab matches the resolved index instead of staying on 0. --- .../entity/entity-details-page.component.ts | 19 ++++++- .../entity/entity-details-panel.component.ts | 2 + .../entity/entity-tabs.component.ts | 4 ++ .../iot-hub/iot-hub-components.models.ts | 50 ------------------- .../iot-hub-install-dialog.component.ts | 8 +-- ...iot-hub-installed-items-table.component.ts | 42 ++-------------- .../iot-hub-item-detail-dialog.component.ts | 31 ++---------- .../iot-hub-update-dialog.component.ts | 8 +-- .../asset-profile-tabs.component.ts | 8 +++ .../home/pages/asset/asset-tabs.component.ts | 8 +++ .../device-profile-tabs.component.ts | 8 +++ .../pages/device/device-tabs.component.ts | 8 +++ .../iot-hub/iot-hub-installed-item.models.ts | 34 +++++++++++++ 13 files changed, 110 insertions(+), 120 deletions(-) delete mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts index 8988a02380..7d9b9e07f7 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts @@ -98,7 +98,24 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl const id = paramMap.get('entityId'); this.currentEntityId = { id, entityType }; this.reload(); - this.selectedTab = 0; + const queryParams = this.route.snapshot.queryParams; + let selectedTabIndex = 0; + if (queryParams['selectedTab']) { + this.router.navigate([], { + queryParams: { + selectedTab: null + }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + if (this.entityTabsComponent) { + const selectedTab: string = queryParams['selectedTab']; + if (selectedTab) { + selectedTabIndex = this.entityTabsComponent.resolveTabIndex(selectedTab); + } + } + } + this.selectedTab = selectedTabIndex; } })); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index 8c6fc004d8..b9ed698a47 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -215,6 +215,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV if (entityTabs) { if (this.viewInited) { this.matTabGroup._tabs.reset([...this.inclusiveTabs.toArray(), ...entityTabs]); + this.matTabGroup.selectedIndex = this.selectedTab; this.matTabGroup._tabs.notifyOnChanges(); } else { this.pendingTabs = entityTabs; @@ -321,6 +322,7 @@ export class EntityDetailsPanelComponent extends PageComponent implements AfterV this.viewInited = true; if (this.pendingTabs) { this.matTabGroup._tabs.reset([...this.inclusiveTabs.toArray(), ...this.pendingTabs]); + this.matTabGroup.selectedIndex = this.selectedTab; this.matTabGroup._tabs.notifyOnChanges(); this.pendingTabs = null; } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts index ec6e46fb72..c5b2eebeb4 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts @@ -117,6 +117,10 @@ export abstract class EntityTabsComponent, ); } + resolveTabIndex(tab: string): number { + return 0; + } + protected setEntity(entity: T) { this.entityValue = entity; } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts deleted file mode 100644 index 1233dfa89c..0000000000 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts +++ /dev/null @@ -1,50 +0,0 @@ -/// -/// Copyright © 2016-2026 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 { EntityType } from '@shared/models/entity-type.models'; -import { IotHubInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; -import { getEntityDetailsPageURL } from '@core/utils'; - -export const ITEM_TYPE_TO_ENTITY_TYPE: Record = { - 'WIDGET': EntityType.WIDGET_TYPE, - 'DASHBOARD': EntityType.DASHBOARD, - 'CALCULATED_FIELD': EntityType.CALCULATED_FIELD, - 'ALARM_RULE': EntityType.CALCULATED_FIELD, - 'RULE_CHAIN': EntityType.RULE_CHAIN, - 'DEVICE': EntityType.DEVICE_PROFILE -}; - -export function resolveEntityDetailsUrl(descriptor: IotHubInstalledItemDescriptor, itemType: string): string | null { - if (!descriptor) { - return null; - } - const entityType = ITEM_TYPE_TO_ENTITY_TYPE[itemType]; - if (!entityType) { - return null; - } - let entityId: string | null = null; - switch (descriptor.type) { - case 'WIDGET': entityId = descriptor.widgetTypeId?.id; break; - case 'DASHBOARD': entityId = descriptor.dashboardId?.id; break; - case 'CALCULATED_FIELD': entityId = descriptor.calculatedFieldId?.id; break; - case 'ALARM_RULE': entityId = descriptor.calculatedFieldId?.id; break; - case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; break; - } - if (!entityId) { - return null; - } - return getEntityDetailsPageURL(entityId, entityType) || null; -} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts index 6484a32c4b..34056abbfe 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts @@ -24,12 +24,14 @@ import { AppState } from '@core/core.state'; import { DialogComponent } from '@shared/components/dialog.component'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; -import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { + getInstalledItemUrl, + SolutionTemplateInstalledItemDescriptor +} from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; -import { resolveEntityDetailsUrl } from './iot-hub-components.models'; import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; import { Observable, of } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; @@ -256,7 +258,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 1; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts index 10ef434755..03aa7a1fa1 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts @@ -36,4 +36,12 @@ export class AssetTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 3; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index 1a762ac881..bfefdf5d4c 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -56,6 +56,14 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + resolveTabIndex(tab: string): number { + if (tab === 'cf') { + return 3; + } else { + return super.resolveTabIndex(tab); + } + } + } diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts index 25ac09d161..b46bdb2829 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -15,6 +15,8 @@ /// import { BaseData } from '@shared/models/base-data'; +import { EntityType } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL } from '@core/utils'; export interface WidgetInstalledItemDescriptor { type: 'WIDGET'; @@ -97,3 +99,35 @@ export interface IotHubInstalledItem extends BaseData<{id: string}> { version: string; descriptor: IotHubInstalledItemDescriptor; } + +export const getInstalledItemUrl = (descriptor?: IotHubInstalledItemDescriptor): string | null => { + let entityId: string | null = null; + let entityType: EntityType | null = null; + let query: string | null = null; + switch (descriptor.type) { + case 'WIDGET': entityId = descriptor.widgetTypeId?.id; entityType = EntityType.WIDGET_TYPE; break; + case 'DASHBOARD': entityId = descriptor.dashboardId?.id; entityType = EntityType.DASHBOARD; break; + case 'CALCULATED_FIELD': + case 'ALARM_RULE': + entityId = descriptor.entityId?.id; + entityType = descriptor.entityId?.entityType as EntityType; + if (descriptor.type === 'CALCULATED_FIELD') { + query = 'selectedTab=cf'; + } + break; + case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; entityType = EntityType.RULE_CHAIN; break; + case 'SOLUTION_TEMPLATE': entityId = descriptor.dashboardId?.id; entityType = EntityType.DASHBOARD; break; + } + if (entityType && entityId) { + let url = getEntityDetailsPageURL(entityId, entityType); + if (url) { + if (query) { + url = `${url}?${query}`; + } + return url; + } else { + return null; + } + } + return null; +} From 8861bc6c9089965c7659a95557b134ea86df30c4 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 11:15:56 +0300 Subject: [PATCH 05/10] style(iot-hub): polish getInstalledItemUrl readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard the helper against a missing descriptor by short-circuiting to null up front instead of letting the switch crash on `undefined.type`. - Expand the inline single-line `case 'X': … break;` arms in the switch so each branch reads on its own line, matching the formatting of the already-multi-line CALCULATED_FIELD / ALARM_RULE arms. --- .../iot-hub/iot-hub-installed-item.models.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts index b46bdb2829..3bdaa87e66 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -101,12 +101,21 @@ export interface IotHubInstalledItem extends BaseData<{id: string}> { } export const getInstalledItemUrl = (descriptor?: IotHubInstalledItemDescriptor): string | null => { + if (!descriptor) { + return null; + } let entityId: string | null = null; let entityType: EntityType | null = null; let query: string | null = null; switch (descriptor.type) { - case 'WIDGET': entityId = descriptor.widgetTypeId?.id; entityType = EntityType.WIDGET_TYPE; break; - case 'DASHBOARD': entityId = descriptor.dashboardId?.id; entityType = EntityType.DASHBOARD; break; + case 'WIDGET': + entityId = descriptor.widgetTypeId?.id; + entityType = EntityType.WIDGET_TYPE; + break; + case 'DASHBOARD': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; case 'CALCULATED_FIELD': case 'ALARM_RULE': entityId = descriptor.entityId?.id; @@ -115,8 +124,14 @@ export const getInstalledItemUrl = (descriptor?: IotHubInstalledItemDescriptor): query = 'selectedTab=cf'; } break; - case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; entityType = EntityType.RULE_CHAIN; break; - case 'SOLUTION_TEMPLATE': entityId = descriptor.dashboardId?.id; entityType = EntityType.DASHBOARD; break; + case 'RULE_CHAIN': + entityId = descriptor.ruleChainId?.id; + entityType = EntityType.RULE_CHAIN; + break; + case 'SOLUTION_TEMPLATE': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; } if (entityType && entityId) { let url = getEntityDetailsPageURL(entityId, entityType); From bf3e405ea2c23f2201ce80fc721f9876d9decb3c Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 11:56:04 +0300 Subject: [PATCH 06/10] feat(iot-hub): show install time in installed-items table createdTime column Switch the createdTime column formatter from 'mediumDate' (date only) to the custom pattern 'MMM d, y, h:mm a' so each row also surfaces the install time. Angular's built-in 'medium' alias was not used because it includes seconds. --- .../iot-hub/iot-hub-installed-items-table.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html index 545d219596..a64f9ab73e 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html @@ -44,7 +44,7 @@ {{ 'iot-hub.installed-date' | translate }} - {{ item.createdTime | date:'mediumDate' }} + {{ item.createdTime | date:'MMM d, y, h:mm a' }} From 428a6d5ded5268a7315ad9385813f08bf971c1e2 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 12:29:13 +0300 Subject: [PATCH 07/10] style(iot-hub): widen createdTime column for added install time Bump the installed-items table .mat-column-createdTime width from 100px to 160px so the new "MMM d, y, h:mm a" format (introduced when the column started showing the install time) fits without wrapping or ellipsis. --- .../iot-hub/iot-hub-installed-items-table.component.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss index 1edef65112..400bb5778b 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss @@ -72,9 +72,9 @@ } .mat-column-createdTime { - width: 100px; - min-width: 100px; - max-width: 100px; + width: 160px; + min-width: 160px; + max-width: 160px; } .mat-column-updates { From f8f7e00cce4a934cf333d38d9d625eba915b6d14 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 12:35:31 +0300 Subject: [PATCH 08/10] fix(iot-hub): resolve DEVICE installed-item url to its dashboard Add a DEVICE arm to getInstalledItemUrl so device-installs link to their generated dashboard (descriptor.dashboardId / EntityType.DASHBOARD), matching the existing SOLUTION_TEMPLATE handling instead of falling through to no URL. --- .../shared/models/iot-hub/iot-hub-installed-item.models.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts index 3bdaa87e66..c568c96a71 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts @@ -108,6 +108,10 @@ export const getInstalledItemUrl = (descriptor?: IotHubInstalledItemDescriptor): let entityType: EntityType | null = null; let query: string | null = null; switch (descriptor.type) { + case 'DEVICE': + entityId = descriptor.dashboardId?.id; + entityType = EntityType.DASHBOARD; + break; case 'WIDGET': entityId = descriptor.widgetTypeId?.id; entityType = EntityType.WIDGET_TYPE; From cab7c5102769854b388147d39fcc99a82d816844 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 15:08:28 +0300 Subject: [PATCH 09/10] feat(shared): add AutocompleteAutoScrollRepositionDirective - New input[matAutocomplete]/textarea[matAutocomplete] directive that keeps an open MatAutocomplete panel attached to its anchor while the page scrolls or the window resizes: tracks the input's visibility via IntersectionObserver and re-requests panel position (or closes the panel when off-screen) when the nearest scroll container or window emits a resize/scroll event. - Declare and export the directive from SharedModule so it lights up across every screen that uses mat-autocomplete. - Drop the transparent backdrop from MAT_AUTOCOMPLETE_DEFAULT_OPTIONS (the directive now handles scroll-and-resize repositioning, so the backdrop-driven outside- click close is no longer needed) and keep the existing hideSingleSelectionIndicator default. --- ...mplete-auto-scroll-reposition.directive.ts | 80 +++++++++++++++++++ ui-ngx/src/app/shared/shared.module.ts | 7 +- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts diff --git a/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts new file mode 100644 index 0000000000..5f620789f1 --- /dev/null +++ b/ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2026 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 { + Directive, + ElementRef, + OnDestroy, + inject, AfterViewInit, Renderer2, +} from '@angular/core'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { Subscription } from 'rxjs'; +import { onParentScrollOrWindowResize } from '@core/utils'; + +@Directive({ + selector: 'input[matAutocomplete], textarea[matAutocomplete]', + standalone: false +}) +export class AutocompleteAutoScrollRepositionDirective implements AfterViewInit, OnDestroy { + private readonly trigger = inject(MatAutocompleteTrigger, { host: true }); + private readonly elementRef = inject(ElementRef); + private readonly renderer = inject(Renderer2); + + private parentScrollSubscription: Subscription = null; + private isIntersecting: boolean = false; + + private intersectionObserver = new IntersectionObserver((entries) => { + if (this.isIntersecting !== entries[0].isIntersecting) { + this.isIntersecting = entries[0].isIntersecting; + this.updatePanelVisibility(); + } + }, {threshold: [0.5]}); + + constructor() { + } + + ngAfterViewInit(): void { + this.parentScrollSubscription = onParentScrollOrWindowResize(this.elementRef.nativeElement).subscribe(() => { + if (this.trigger.panelOpen) { + this.trigger.updatePosition(); + } + }); + this.intersectionObserver.observe(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + this.parentScrollSubscription = null; + } + if (this.intersectionObserver) { + this.intersectionObserver.unobserve(this.elementRef.nativeElement); + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + } + + private updatePanelVisibility(): void { + if (this.trigger.panelOpen) { + if (this.isIntersecting) { + this.renderer.removeStyle(this.trigger.autocomplete.panel.nativeElement, 'display'); + } else { + this.renderer.setStyle(this.trigger.autocomplete.panel.nativeElement, 'display', 'none'); + } + } + } + +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 6fe78f7ee0..d5073332d2 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -230,6 +230,9 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; +import { + AutocompleteAutoScrollRepositionDirective +} from '@shared/directives/autocomplete-auto-scroll-reposition.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -278,8 +281,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: { - hasBackdrop: true, - backdropClass: 'cdk-overlay-transparent-backdrop', hideSingleSelectionIndicator: true } }, @@ -389,6 +390,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ContextMenuDirective, ChipOverflowDirective, PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -655,6 +657,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ContextMenuDirective, ChipOverflowDirective, PhotoSwipeGalleryDirective, + AutocompleteAutoScrollRepositionDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, From a880a8635a03daef73959b8349301058cb24f9e8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 11 Jun 2026 16:53:31 +0300 Subject: [PATCH 10/10] feat(iot-hub): hardening + dao-level lookups - IotHubRestClient.getVersionFileData now streams the response and enforces a configurable size cap (iot-hub.max-file-data-size-bytes, env IOT_HUB_MAX_FILE_DATA_SIZE_BYTES, default 100 MiB). Oversized payloads are rejected up front via Content-Length and mid-stream for chunked transfers, preventing OOMs from unknown bodies. - DefaultIotHubService guards every iotHubRestClient.getVersionInfo call against a null response, and rejects update attempts where the installed item's type does not match the new version's type ("Installed item type does not match the new version's item type."). Solution-template descriptor is also populated with tenantTelemetryKeys / tenantAttributeKeys captured during install. Drops the empty updateDeviceProfile stub. - Add a tenant-scoped findByTenantIdAndId + deleteByTenantIdAndId to IotHubInstalledItemDao (and the bulk deleteByTenantIdAndIdIn JPA query). IotHubInstalledItemServiceImpl now uses these so reads and deletes always honour the tenant boundary. - Database: add lts schema_update.sql + schema-entities-idx.sql indexes on iot_hub_installed_item(tenant_id), (tenant_id, item_type), and (tenant_id, item_id) for the new tenant-scoped lookups. - DefaultSolutionService: minor cleanup touched alongside. - .gitignore trailing-newline fix; remove stale superpowers plan/spec docs that have been folded into the implementation. --- .gitignore | 1 + .../main/data/upgrade/lts/schema_update.sql | 6 + .../service/iot_hub/DefaultIotHubService.java | 27 +- .../service/iot_hub/IotHubRestClient.java | 65 +- .../solutions/DefaultSolutionService.java | 2 +- .../src/main/resources/thingsboard.yml | 3 + .../dao/iot_hub/IotHubInstalledItemDao.java | 6 + .../IotHubInstalledItemServiceImpl.java | 4 +- .../IotHubInstalledItemRepository.java | 9 + .../iot_hub/JpaIotHubInstalledItemDao.java | 13 + .../server/dao/tenant/TenantServiceImpl.java | 3 +- .../resources/sql/schema-entities-idx.sql | 6 + .../2026-04-22-iot-hub-item-deep-link.md | 939 ----------------- .../plans/2026-04-27-install-method-sync.md | 685 ------------ .../plans/2026-05-06-iot-hub-item-link.md | 974 ------------------ .../2026-04-02-gateway-support-design.md | 267 ----- .../2026-04-02-transport-variables-design.md | 72 -- ...-04-09-provisioning-conflict-resolution.md | 102 -- ...026-04-22-iot-hub-item-deep-link-design.md | 289 ------ .../specs/2026-05-06-iot-hub-item-link.md | 145 --- 20 files changed, 132 insertions(+), 3486 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md delete mode 100644 docs/superpowers/plans/2026-04-27-install-method-sync.md delete mode 100644 docs/superpowers/plans/2026-05-06-iot-hub-item-link.md delete mode 100644 docs/superpowers/specs/2026-04-02-gateway-support-design.md delete mode 100644 docs/superpowers/specs/2026-04-02-transport-variables-design.md delete mode 100644 docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md delete mode 100644 docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md delete mode 100644 docs/superpowers/specs/2026-05-06-iot-hub-item-link.md diff --git a/.gitignore b/.gitignore index ee8c78dad3..833038e151 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ rebuild-docker.sh .run .claude .planning +docs/superpowers/ \ No newline at end of file diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql index 443b54ed78..b52d8ee14e 100644 --- a/application/src/main/data/upgrade/lts/schema_update.sql +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -32,4 +32,10 @@ CREATE TABLE IF NOT EXISTS iot_hub_installed_item ( descriptor JSONB NOT NULL ); +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_tenant_id ON iot_hub_installed_item(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_type ON iot_hub_installed_item(tenant_id, item_type); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_id ON iot_hub_installed_item(tenant_id, item_id); + -- IOT HUB INSTALLED ITEM END diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java index 5c08b4f12b..26d208dbf6 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -106,6 +106,9 @@ public class DefaultIotHubService implements IotHubService { try { JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } String itemType = versionInfo.get("type").asText(); String itemName = versionInfo.get("name").asText(); UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); @@ -320,6 +323,9 @@ public class DefaultIotHubService implements IotHubService { // Skip checksum validation for solution templates if (!"SOLUTION_TEMPLATE".equals(itemType)) { JsonNode installedVersionInfo = iotHubRestClient.getVersionInfo(installedItem.getItemVersionId().toString()); + if (installedVersionInfo == null) { + throw new IllegalArgumentException("Failed to get installed version info from IoT Hub"); + } String installedChecksum = installedVersionInfo.has("checksum") ? installedVersionInfo.get("checksum").asText() : null; log.info("[{}] Installed version info: name={}, version={}, checksum={}", tenantId, installedItem.getItemName(), installedItem.getVersion(), installedChecksum); @@ -335,6 +341,16 @@ public class DefaultIotHubService implements IotHubService { } JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } + + String updateItemType = versionInfo.get("type").asText(); + if (!itemType.equals(updateItemType)) { + throw new IllegalArgumentException("Installed item type does not match the new version's item type."); + } + String itemName = versionInfo.get("name").asText(); String version = versionInfo.get("version").asText(); @@ -360,6 +376,8 @@ public class DefaultIotHubService implements IotHubService { throw new RuntimeException(response.getDetails()); } stDescriptor.setCreatedEntityIds(response.getCreatedEntityIds()); + stDescriptor.setTenantTelemetryKeys(response.getTenantTelemetryKeys()); + stDescriptor.setTenantAttributeKeys(response.getTenantAttributeKeys()); stDescriptor.setDashboardId(response.getDashboardId()); stDescriptor.setPublicId(response.getPublicId()); stDescriptor.setMainDashboardPublic(response.isMainDashboardPublic()); @@ -456,10 +474,6 @@ public class DefaultIotHubService implements IotHubService { ruleChainService.saveRuleChainMetaData(tenantId, metadata, tbRuleChainService::updateRuleNodeConfiguration); } - private void updateDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { - // TODO: implement device profile update - } - private String calculateEntityChecksum(TenantId tenantId, IotHubInstalledItem installedItem) { IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor(); if (descriptor instanceof WidgetInstalledItemDescriptor wd) { @@ -549,6 +563,11 @@ public class DefaultIotHubService implements IotHubService { try { JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + + if (versionInfo == null) { + throw new IllegalArgumentException("Failed to get version info from IoT Hub"); + } + String itemName = versionInfo.get("name").asText(); UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); String version = versionInfo.get("version").asText(); diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java index 1155bcf0ad..1ea8b60d2e 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java @@ -16,23 +16,47 @@ package org.thingsboard.server.service.iot_hub; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.thingsboard.server.queue.util.TbCoreComponent; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; + @Component @TbCoreComponent @Slf4j public class IotHubRestClient { - private final RestTemplate restTemplate = new RestTemplate(); - @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") private String baseUrl; + @Value("${iot-hub.connect-timeout-sec:5}") + private int connectTimeoutSec; + + @Value("${iot-hub.read-timeout-sec:10}") + private int readTimeoutSec; + + @Value("${iot-hub.max-file-data-size-bytes:104857600}") + private long maxFileDataSizeBytes; + + private RestTemplate restTemplate; + + @PostConstruct + public void initRestClient() { + restTemplate = new RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(connectTimeoutSec)) + .readTimeout(Duration.ofSeconds(readTimeoutSec)) + .build(); + } + public JsonNode getVersionInfo(String versionId) { String url = baseUrl + "/api/versions/" + versionId; log.debug("Fetching IoT Hub version info: {}", url); @@ -42,8 +66,39 @@ public class IotHubRestClient { public byte[] getVersionFileData(String versionId) { String url = baseUrl + "/api/versions/" + versionId + "/fileData"; log.debug("Fetching IoT Hub version file data: {}", url); - ResponseEntity response = restTemplate.getForEntity(url, byte[].class); - return response.getBody(); + // Stream the response so we can reject oversized payloads before + // they get loaded fully into memory: + // 1) refuse early if the server advertises a Content-Length + // larger than the configured cap; + // 2) refuse mid-stream once we have already buffered more than + // the cap (for chunked transfers without Content-Length). + final long limit = maxFileDataSizeBytes; + return restTemplate.execute(url, HttpMethod.GET, null, response -> { + long contentLength = response.getHeaders().getContentLength(); + if (contentLength > limit) { + throw new IllegalStateException("IoT Hub file data size " + contentLength + + " bytes exceeds the configured limit of " + limit + " bytes"); + } + int initialCapacity = contentLength > 0 && contentLength <= Integer.MAX_VALUE + ? (int) contentLength : 8192; + try (InputStream in = response.getBody(); + ByteArrayOutputStream out = new ByteArrayOutputStream(initialCapacity)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > limit) { + throw new IllegalStateException("IoT Hub file data stream exceeded the configured limit of " + + limit + " bytes"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read IoT Hub file data", e); + } + }); } public void reportVersionInstalled(String versionId) { diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java index b68fe5515c..9d13f15b96 100644 --- a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -269,7 +269,7 @@ public class DefaultSolutionService implements SolutionService { tsService.remove(tenantId, tenantId, queries).get(); } List attrKeys = descriptor.getTenantAttributeKeys(); - if (tsKeys != null && !tsKeys.isEmpty()) { + if (attrKeys != null && !attrKeys.isEmpty()) { attributesService.removeAll(tenantId, tenantId, AttributeScope.SERVER_SCOPE, attrKeys).get(); } } catch (Exception e) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index bfbb211c5c..97f14f7969 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2167,3 +2167,6 @@ mqtt: jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}" iot-hub: base-url: "${IOT_HUB_BASE_URL:https://iot-hub.thingsboard.io}" # IoT Hub base URL for fetching published items, resources, etc. + connect-timeout-sec: "${IOT_HUB_CONNECT_TIMEOUT_SEC:5}" # timeout in seconds to connect to IoT Hub server. + read-timeout-sec: "${IOT_HUB_READ_TIMEOUT_SEC:10}" # timeout in seconds to read from IoT Hub server. + max-file-data-size-bytes: "${IOT_HUB_MAX_FILE_DATA_SIZE_BYTES:104857600}" # maximum size in bytes of a file-data payload fetched from IoT Hub. Requests advertising a larger Content-Length, or streams that exceed this size, are rejected to avoid out-of-memory issues on the platform side. diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java index 2ad7d1d01a..22692f6ee4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.iot_hub; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; import org.thingsboard.server.common.data.page.PageData; @@ -23,10 +24,13 @@ import org.thingsboard.server.dao.Dao; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; public interface IotHubInstalledItemDao extends Dao { + Optional findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); List findInstalledItemIdsByTenantId(TenantId tenantId); @@ -37,4 +41,6 @@ public interface IotHubInstalledItemDao extends Dao { void deleteByTenantId(TenantId tenantId); + boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java index c98f03d104..5bb857ca2c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -43,7 +43,7 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { @Override public IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id) { - return iotHubInstalledItemDao.findById(tenantId, id.getId()); + return iotHubInstalledItemDao.findByTenantIdAndId(tenantId, id).orElse(null); } @Override @@ -68,7 +68,7 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { @Override public void deleteById(TenantId tenantId, IotHubInstalledItemId id) { - iotHubInstalledItemDao.removeById(tenantId, id.getId()); + iotHubInstalledItemDao.deleteByTenantIdAndId(tenantId, id); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java index 69f93cd5e7..8f06beaa39 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java @@ -25,10 +25,14 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.UUID; interface IotHubInstalledItemRepository extends JpaRepository { + Optional findByTenantIdAndId(UUID tenantId, UUID id); + @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId") List findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId); @@ -64,4 +68,9 @@ interface IotHubInstalledItemRepository extends JpaRepository ids); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java index b0db650ae0..9d61b13209 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; import org.thingsboard.server.common.data.page.PageData; @@ -35,6 +36,8 @@ import org.thingsboard.server.dao.util.SqlDao; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; @SqlDao @@ -44,6 +47,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) { + return repository.findByTenantIdAndId(tenantId.getId(), installedItemId.getId()).map(DaoUtil::getData); + } + @Override public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { return DaoUtil.toPageData(repository.findByTenantId( @@ -80,6 +88,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao 0; + } + private static PageRequest toPageRequest(PageLink pageLink) { Sort sort; SortOrder sortOrder = pageLink.getSortOrder(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index e7939ea32e..0045a1042e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -39,6 +39,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -83,7 +84,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog. - -**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. - -**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode. - -**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task. - -**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract. - ---- - -## File structure - -**Create:** -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles - -**Modify:** -- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last) -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff -- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block - ---- - -### Task 1: Add i18n keys - -Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later). - -**Files:** -- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` - -- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block** - -Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key): - -```json - "item-detail": "IoT Hub item", - "item-preview": "IoT Hub item preview", - "unpublished-warning-title": "Unpublished content", - "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", - "unpublished-warning-confirm": "I understand the risk, continue", - "unpublished-preview": "Unpublished preview", - "deep-link-invalid-id": "Invalid IoT Hub item link.", - "deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", - "deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." -``` - -- [ ] **Step 2: Validate JSON** - -Run: -```bash -cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')" -``` -Expected output: `OK`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/assets/locale/locale.constant-en_US.json -git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning" -``` - ---- - -### Task 2: Deep-link utility module - -A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog. - -**Files:** -- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` - -- [ ] **Step 1: Create the file** - -Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with: - -```ts -/// -/// Copyright © 2016-2026 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 { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function isUUID(s: string | null | undefined): s is string { - return !!s && UUID_RE.test(s); -} - -export function typeSegment(t: ItemType): string | undefined { - switch (t) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboards'; - case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; - case ItemType.CALCULATED_FIELD: return 'calculated-fields'; - case ItemType.RULE_CHAIN: return 'rule-chains'; - case ItemType.DEVICE: return 'devices'; - default: return undefined; - } -} - -export function isPublished(v: MpItemVersionView): boolean { - return !!v.publishedTime && v.publishedTime > 0; -} - -export interface DeepLinkOpenItem { - version: MpItemVersionView; - preview: boolean; -} -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 -``` -Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts -git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)" -``` - ---- - -### Task 3: IoT Hub API service — two new methods - -Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`). - -**Files:** -- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts` - -- [ ] **Step 1: Add the two methods** - -Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100): - -```ts - public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { - return this.http.get( - `${this.baseUrl}/api/items/${itemId}/published`, - { params: this.buildParams(config) } - ); - } - - public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable { - return this.http.get( - `${this.baseUrl}/api/items/${itemId}/latest`, - { params: this.buildParams(config) } - ); - } -``` - -Use the exact `Edit` tool call: `old_string` should be the line ` public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable {` (and enough context around it), and `new_string` should prepend the two new methods above it. - -- [ ] **Step 2: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/core/http/iot-hub-api.service.ts -git commit -m "feat(iot-hub): add getPublishedVersion and getLatestVersion API methods" -``` - ---- - -### Task 4: Unpublished warning dialog component - -New dialog styled after `TbIotHubDeleteDialogComponent`: title, description, item summary, Cancel + danger-accented confirm button. Returns `boolean` from `afterClosed`. - -**Files:** -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` - -- [ ] **Step 1: Create the component .ts** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`: - -```ts -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { DialogComponent } from '@shared/components/dialog.component'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; - -export interface IotHubUnpublishedWarningDialogData { - item: MpItemVersionView; -} - -@Component({ - selector: 'tb-iot-hub-unpublished-warning-dialog', - standalone: false, - templateUrl: './iot-hub-unpublished-warning-dialog.component.html', - styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss'] -}) -export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent { - - constructor( - protected store: Store, - protected router: Router, - protected dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData - ) { - super(store, router, dialogRef); - } - - confirm(): void { - this.dialogRef.close(true); - } - - cancel(): void { - this.dialogRef.close(false); - } -} -``` - -- [ ] **Step 2: Create the component .html** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`: - -```html - -
-
- warning -

{{ 'iot-hub.unpublished-warning-title' | translate }}

-
-

{{ 'iot-hub.unpublished-warning-text' | translate }}

-
- {{ data.item.name }} - v {{ data.item.version }} -
-
-
- - -
-``` - -- [ ] **Step 3: Create the component .scss** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`: - -```scss -/** - * Copyright © 2016-2026 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. - */ - -.tb-iot-hub-warning-content { - display: flex; - flex-direction: column; - gap: 16px; - padding: 24px; - max-width: 480px; -} - -.tb-iot-hub-warning-title-row { - display: flex; - align-items: center; - gap: 12px; -} - -.tb-iot-hub-warning-icon { - color: #d32f2f; - font-size: 28px; - width: 28px; - height: 28px; -} - -.tb-iot-hub-warning-title { - font-size: 20px; - font-weight: 600; - line-height: 24px; - letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.87); - margin: 0; -} - -.tb-iot-hub-warning-text { - font-size: 14px; - font-weight: 400; - line-height: 20px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.75); - margin: 0; -} - -.tb-iot-hub-warning-item { - display: flex; - align-items: baseline; - gap: 8px; - padding: 12px 16px; - background: rgba(211, 47, 47, 0.08); - border-left: 3px solid #d32f2f; - border-radius: 2px; - - .tb-iot-hub-warning-item-name { - font-weight: 600; - color: rgba(0, 0, 0, 0.87); - } - - .tb-iot-hub-warning-item-version { - font-size: 13px; - color: rgba(0, 0, 0, 0.54); - } -} - -.tb-iot-hub-warning-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding: 8px; -} -``` - -- [ ] **Step 4: Register in `IotHubComponentsModule`** - -Open `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`. - -Add the import line after the existing `TbIotHubDeleteDialogComponent` import: - -```ts -import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; -``` - -In the `declarations` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. - -In the `exports` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. - -- [ ] **Step 5: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 6: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts -git commit -m "feat(iot-hub): add unpublished content warning dialog" -``` - ---- - -### Task 5: Detail dialog `preview` flag + badge, actions service signature - -Add `preview?: boolean` to `IotHubItemDetailDialogData`, store on the component, render an "Unpublished preview" badge in the meta-bar next to the version chip. Extend `IotHubActionsService.openItemDetail` to forward the flag. - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` - -- [ ] **Step 1: Extend `IotHubItemDetailDialogData` + component field** - -In `iot-hub-item-detail-dialog.component.ts`, find the `IotHubItemDetailDialogData` interface (around line 43) and add the optional `preview` field: - -```ts -export interface IotHubItemDetailDialogData { - item: MpItemVersionView; - installedItem?: IotHubInstalledItem; - installedItemsCount?: number; - mode?: IotHubItemDetailDialogMode; - showCreator?: boolean; - preview?: boolean; -} -``` - -In the component class, add a new public field below `showCreator`: - -```ts - preview: boolean; -``` - -In the constructor body (below `this.showCreator = data.showCreator !== false;`), add: - -```ts - this.preview = data.preview === true; -``` - -- [ ] **Step 2: Add badge markup in template** - -In `iot-hub-item-detail-dialog.component.html`, find the block starting with `` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `` of that version group (just before the `@if (item.publishedTime)` block), insert: - -```html - @if (preview) { - - - warning - {{ 'iot-hub.unpublished-preview' | translate }} - - } -``` - -- [ ] **Step 3: Style the badge** - -Append to `iot-hub-item-detail-dialog.component.scss`: - -```scss -.dlg-subtitle-group.tb-unpublished-preview-badge { - color: #d32f2f; - font-weight: 600; - - .dlg-subtitle-icon { - color: #d32f2f; - } -} -``` - -- [ ] **Step 4: Extend `IotHubActionsService.openItemDetail`** - -In `iot-hub-actions.service.ts`, replace the `openItemDetail` method signature and body with: - -```ts - openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number, - mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable { - return this.dialog.open(TbIotHubItemDetailDialogComponent, { - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - autoFocus: false, - data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData - }).afterClosed(); - } -``` - -Existing callers pass 5 or fewer args and remain valid — the new parameter is optional. - -- [ ] **Step 5: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 6: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts -git commit -m "feat(iot-hub): add unpublished preview badge to item detail dialog" -``` - ---- - -### Task 6: Resolver component - -The heart of the feature. Fetches the version, gates on warning for unpublished preview, redirects to the type-page with router state. - -**Files:** -- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` - -- [ ] **Step 1: Create the component** - -Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`: - -```ts -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { MatDialog } from '@angular/material/dialog'; -import { Store } from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; -import { AppState } from '@core/core.state'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { - DeepLinkOpenItem, - isPublished, - isUUID, - typeSegment -} from './iot-hub-deep-link.utils'; -import { - IotHubUnpublishedWarningDialogData, - TbIotHubUnpublishedWarningDialogComponent -} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component'; - -@Component({ - selector: 'tb-iot-hub-item-resolver', - standalone: false, - template: '' -}) -export class TbIotHubItemResolverComponent implements OnInit { - - constructor( - private route: ActivatedRoute, - private router: Router, - private dialog: MatDialog, - private store: Store, - private translate: TranslateService, - private iotHubApi: IotHubApiService - ) {} - - ngOnInit(): void { - const itemId = this.route.snapshot.paramMap.get('itemId'); - const preview = this.route.snapshot.data['preview'] === true; - - if (!isUUID(itemId)) { - this.failTo('iot-hub.deep-link-invalid-id'); - return; - } - - const fetch$ = preview - ? this.iotHubApi.getLatestVersion(itemId, { ignoreErrors: true }) - : this.iotHubApi.getPublishedVersion(itemId, { ignoreErrors: true }); - - fetch$.subscribe({ - next: v => this.handleResolved(v, preview), - error: err => { - const key = err?.status === 404 - ? 'iot-hub.deep-link-not-found' - : 'iot-hub.deep-link-fetch-failed'; - this.failTo(key); - } - }); - } - - private handleResolved(version: MpItemVersionView, preview: boolean): void { - const segment = typeSegment(version.type); - if (!segment) { - this.failTo('iot-hub.deep-link-fetch-failed'); - return; - } - - const showWarning = preview && !isPublished(version); - - if (showWarning) { - this.dialog.open< - TbIotHubUnpublishedWarningDialogComponent, - IotHubUnpublishedWarningDialogData, - boolean - >(TbIotHubUnpublishedWarningDialogComponent, { - panelClass: ['tb-dialog'], - disableClose: true, - autoFocus: false, - data: { item: version } - }).afterClosed().subscribe(confirmed => { - if (confirmed) { - this.openOnTypePage(version, segment, true); - } else { - this.router.navigate(['/iot-hub'], { replaceUrl: true }); - } - }); - } else { - this.openOnTypePage(version, segment, false); - } - } - - private openOnTypePage(version: MpItemVersionView, segment: string, preview: boolean): void { - const openItem: DeepLinkOpenItem = { version, preview }; - this.router.navigate(['/iot-hub', segment], { - state: { openItem }, - replaceUrl: true - }); - } - - private failTo(messageKey: string): void { - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant(messageKey), - type: 'error', - duration: 5000 - })); - this.router.navigate(['/iot-hub'], { replaceUrl: true }); - } -} -``` - -- [ ] **Step 2: Verify TypeScript compiles (the module wiring comes in Task 7)** - -The component is not yet declared in any module, so a full `ng build` will fail. Run a type-check only on this file: - -```bash -cd ui-ngx && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "iot-hub-item-resolver|iot-hub-deep-link" || echo "no type errors in new files" -``` -Expected: `no type errors in new files`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts -git commit -m "feat(iot-hub): add item resolver component for deep links" -``` - ---- - -### Task 7: Routing + module registration - -Register the resolver component in `IotHubModule` and add the two new child routes to `iot-hub-routing.module.ts`. Reserved words must be matched first, so the `:itemId` wildcard routes go **last** in the children array. - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` -- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` - -- [ ] **Step 1: Declare resolver in `IotHubModule`** - -In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`, add the import: - -```ts -import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; -``` - -In the `declarations` array, add `TbIotHubItemResolverComponent` after `TbIotHubSearchPageComponent`. - -- [ ] **Step 2: Add routes** - -In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, add the import: - -```ts -import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; -``` - -In the `children` array of the `/iot-hub` route, **immediately before** the closing `]` (after the existing `creator/:creatorId` entry), insert: - -```ts - { - path: ':itemId', - component: TbIotHubItemResolverComponent, - data: { - auth: [Authority.TENANT_ADMIN], - title: 'iot-hub.item-detail' - } - }, - { - path: ':itemId/preview', - component: TbIotHubItemResolverComponent, - data: { - auth: [Authority.TENANT_ADMIN], - title: 'iot-hub.item-preview', - preview: true - } - } -``` - -Make sure there is a comma after the preceding `creator/:creatorId` entry. - -- [ ] **Step 3: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts \ - ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts -git commit -m "feat(iot-hub): wire resolver component into routing and module" -``` - ---- - -### Task 8: Type-page handoff - -`TbIotHubItemsPageComponent` consumes `history.state.openItem` on init: resolves installed state, opens the detail dialog with the `preview` flag, then clears the state entry so refresh does not re-open. - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` - -- [ ] **Step 1: Extend imports + constructor** - -Open `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`. - -Replace the import block (top of file, currently lines 17-20) with: - -```ts -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; -import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; -import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { PageLink } from '@shared/models/page/page-link'; -import { DeepLinkOpenItem } from './iot-hub-deep-link.utils'; -``` - -Replace the constructor with: - -```ts - constructor( - private route: ActivatedRoute, - private router: Router, - private iotHubApiService: IotHubApiService, - private iotHubActions: IotHubActionsService - ) {} -``` - -- [ ] **Step 2: Extend `ngOnInit` and add handoff method** - -Replace the existing `ngOnInit` body with: - -```ts - ngOnInit(): void { - const itemType = this.route.snapshot.data['itemType'] as string; - this.config = PAGE_CONFIGS[itemType]; - this.loadInstalledCount(); - this.maybeOpenDeepLinkedItem(); - } -``` - -Add these two new methods anywhere in the class (e.g. after `loadInstalledCount`): - -```ts - private maybeOpenDeepLinkedItem(): void { - const openItem = history.state?.openItem as DeepLinkOpenItem | undefined; - if (!openItem || openItem.version.type !== this.config.type) { - return; - } - history.replaceState({ ...history.state, openItem: undefined }, ''); - this.resolveInstalledItem(openItem.version).subscribe(installed => { - this.iotHubActions.openItemDetail( - openItem.version, - installed ?? undefined, - installed ? 1 : 0, - 'default', - true, - openItem.preview - ).subscribe(); - }); - } - - private resolveInstalledItem(version: MpItemVersionView): Observable { - return this.iotHubApiService - .getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true }) - .pipe(map(page => page.data[0] ?? null)); - } -``` - -- [ ] **Step 3: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts -git commit -m "feat(iot-hub): open deep-linked item from history state on type-page init" -``` - ---- - -### Task 9: Lint, build, and manual smoke test - -Final verification gate. Runs the lint and production build, then walks each user-facing flow in a dev server to confirm behavior. - -**Files:** none (verification only) - -- [ ] **Step 1: Lint** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng lint 2>&1 | tail -30 -``` -Expected: no new errors introduced by files created/modified in tasks 1–8. Warnings in unrelated files are acceptable. - -- [ ] **Step 2: Production build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 -``` -Expected: build completes without errors. - -- [ ] **Step 3: Start dev server** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 -``` -Dev server should be reachable at `http://localhost:4200`. Log in as a TENANT_ADMIN user. (A running ThingsBoard backend at the default proxy target is required per `proxy.conf.js`.) - -- [ ] **Step 4: Manual smoke test — published deep link** - -Pick an itemId from `/iot-hub/widgets` (the card's click handler logs it, or inspect the dialog URL via browser dev tools; alternatively, query `iotHubBaseUrl/api/versions/published` and copy an `itemId`). - -Navigate to `http://localhost:4200/iot-hub/{that-itemId}`. Verify: -- URL bar ends at `/iot-hub/widgets` (or the correct type page for the item) after resolution -- Detail dialog opens with the item's info, no "Unpublished preview" badge, and Install/Update actions behave normally -- Closing the dialog leaves the user on the type-page - -Navigate to `http://localhost:4200/iot-hub/00000000-0000-0000-0000-000000000000` (a valid-shaped but non-existent UUID). Verify: -- URL bar ends at `/iot-hub` -- Red toast: "This IoT Hub item doesn't exist or was removed." - -Navigate to `http://localhost:4200/iot-hub/not-a-uuid`. Verify: -- URL bar ends at `/iot-hub` -- Red toast: "Invalid IoT Hub item link." - -- [ ] **Step 5: Manual smoke test — preview deep link (requires IoT Hub `/latest` endpoint)** - -**If the IoT Hub-side endpoints are not yet deployed**, skip this step and record that end-to-end preview verification is deferred until the IoT Hub PR lands. The per-URL flow is exercised in Step 4; the preview URL hitting a non-existent endpoint will trigger the "fetch failed" toast, which is the correct fallback behavior. - -**If the endpoints are available**, navigate to `http://localhost:4200/iot-hub/{unpublished-itemId}/preview`. Verify: -- Warning dialog opens immediately with the item's name/version, red warning icon, and two buttons -- "Cancel" → closes dialog, lands on `/iot-hub` (home) -- Revisit the URL, click "I understand the risk, continue" → URL advances to `/iot-hub/{typePage}`, detail dialog opens with the red "Unpublished preview" badge in the meta bar -- Install button still works end-to-end (calls the TB install endpoint, which proxies to IoT Hub using the unpublished versionId) - -Navigate to `http://localhost:4200/iot-hub/{published-only-itemId}/preview`. Verify: -- No warning shown (preview fell back to the published version) -- Detail dialog opens with **no** preview badge -- Behavior matches the regular published URL - -- [ ] **Step 6: Commit the final verification (if any lint/build fixes were needed)** - -If the smoke test uncovered issues that required fixes, stage and commit them with a message like `fix(iot-hub): address smoke-test findings for deep-link flow`. Otherwise, this step is a no-op — all previous commits already capture the work. - ---- - -## IoT Hub-side changes required (recap) - -One new endpoint plus behavior + CORS contracts on the existing by-versionId family: - -1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`; `404` if none. Anonymous cross-origin. Powers the `/iot-hub/{itemId}` URL. -2. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate. Powers the `/iot-hub/version/{itemVersionId}` URL. -3. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today. -4. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link): - - `GET /api/versions/{versionId}/readme` - - `GET /api/versions/{versionId}/fileData` - - `POST /api/versions/{versionId}/install` -5. **Install counter policy** for unpublished versions — recommended: skip counting. -6. **CORS** on `/api/items/{itemId}/published` and the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin. - -These live in the IoT Hub repository, not ThingsBoard CE. diff --git a/docs/superpowers/plans/2026-04-27-install-method-sync.md b/docs/superpowers/plans/2026-04-27-install-method-sync.md deleted file mode 100644 index 717ae910bb..0000000000 --- a/docs/superpowers/plans/2026-04-27-install-method-sync.md +++ /dev/null @@ -1,685 +0,0 @@ -# Install-Method Sync with IoT Hub Marketplace — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Sync CE's `InstallMethod` enum + labels with the IoT Hub marketplace's expanded 50-value allow-list, and add a "PE only" gate in the device install dialog so users selecting a PE-only integration get a clear message instead of a silently-broken wizard. - -**Architecture:** Frontend-only. CE has no backend install-method allow-list (`DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String`), so all work lives in `ui-ngx`. The `GATEWAY_CONNECTOR` step type already handles connector config generically (line 761 of `device-install-dialog.component.ts`), so the 12 new `GATEWAY_*` install methods need no new install logic — only enum + label entries. The 26 new `INTEGRATION_*` methods are PE-only on CE: today CE silently skips `CONVERTER`/`INTEGRATION` steps (line 553), running an empty-progress wizard — this plan replaces that silent skip with an explicit "PE only — upgrade required" panel shown right after connectivity selection. - -**Tech Stack:** Angular 20, TypeScript, Material Design (existing `TbDeviceInstallDialogComponent`). - ---- - -## Source of Truth - -The marketplace allow-list lives at: -`/home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java`, constant `VALID_INSTALL_METHODS`. - -CE's enum must be a strict subset of the marketplace's allow-list, including every entry. Diverging causes "marketplace accepts the package, CE wizard rejects it / shows raw constant" failures. - -PE/CE applicability — extracted from `/home/ashvayka/git/iot-hub/device-library-contribution.md`: - -| Group | Constants | CE-supported install behavior | -|-------|-----------|-------------------------------| -| Direct (5) | `DIRECT_HTTP`, `DIRECT_MQTT`, `DIRECT_COAP`, `DIRECT_LWM2M`, `DIRECT_SNMP` | Yes — runs through `DEVICE` + standard steps | -| Gateway (15) | `GATEWAY_MQTT`, `GATEWAY_MODBUS`, `GATEWAY_OPCUA`, `GATEWAY_BACNET`, `GATEWAY_BLE`, `GATEWAY_CAN`, `GATEWAY_FTP`, `GATEWAY_KNX`, `GATEWAY_OCPP`, `GATEWAY_ODBC`, `GATEWAY_REQUEST`, `GATEWAY_REST`, `GATEWAY_SNMP`, `GATEWAY_SOCKET`, `GATEWAY_XMPP` | Yes — generic `GATEWAY_CONNECTOR` step writes connector config to gateway shared attributes regardless of connector type | -| ChirpStack CE (1) | `CHIRPSTACK` | Yes | -| Integrations (29) | `INTEGRATION_*` (all values listed in step 4 of `VALID_INSTALL_METHODS`) | **No — PE only**. Show "PE only" panel, do not start wizard | - ---- - -## File Structure - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` | Extend `InstallMethod` enum to 50 values; extend `installMethodLabels` to 50 labels; add new `peOnlyInstallMethods` set | -| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` | Add `peOnlySelected` getter; route `confirmConnectivity()` to a new "PE only" view-state instead of `startWizard()` when user picks a PE-only method | -| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` | Add a new conditional panel that renders when `peOnlySelected` is true: title, body copy, "Learn about ThingsBoard PE" link, Close button | -| Modify | `ui-ngx/src/assets/locale/locale.constant-en_US.json` | Add three translation keys for the PE-only panel: `iot-hub.device-install-pe-only-title`, `iot-hub.device-install-pe-only-message`, `iot-hub.device-install-pe-only-learn-more` | - ---- - -## Task 1: Sync `InstallMethod` enum to all 50 values - -**Files:** -- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` - -- [ ] **Step 1: Replace the `InstallMethod` enum block** - -Find (lines 17–30): -```typescript -export enum InstallMethod { - DIRECT_HTTP = 'DIRECT_HTTP', - DIRECT_MQTT = 'DIRECT_MQTT', - DIRECT_COAP = 'DIRECT_COAP', - DIRECT_LWM2M = 'DIRECT_LWM2M', - DIRECT_SNMP = 'DIRECT_SNMP', - GATEWAY_MQTT = 'GATEWAY_MQTT', - GATEWAY_MODBUS = 'GATEWAY_MODBUS', - GATEWAY_OPCUA = 'GATEWAY_OPCUA', - CHIRPSTACK = 'CHIRPSTACK', - INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', - INTEGRATION_TTN = 'INTEGRATION_TTN', - INTEGRATION_LORIOT = 'INTEGRATION_LORIOT' -} -``` - -Replace with (preserve grouping comments — they mirror the marketplace validator's grouping and make drift visible at review time): -```typescript -export enum InstallMethod { - // Direct device-to-platform transports - DIRECT_HTTP = 'DIRECT_HTTP', - DIRECT_MQTT = 'DIRECT_MQTT', - DIRECT_COAP = 'DIRECT_COAP', - DIRECT_LWM2M = 'DIRECT_LWM2M', - DIRECT_SNMP = 'DIRECT_SNMP', - // ThingsBoard IoT Gateway connectors - GATEWAY_MQTT = 'GATEWAY_MQTT', - GATEWAY_MODBUS = 'GATEWAY_MODBUS', - GATEWAY_OPCUA = 'GATEWAY_OPCUA', - GATEWAY_BACNET = 'GATEWAY_BACNET', - GATEWAY_BLE = 'GATEWAY_BLE', - GATEWAY_CAN = 'GATEWAY_CAN', - GATEWAY_FTP = 'GATEWAY_FTP', - GATEWAY_KNX = 'GATEWAY_KNX', - GATEWAY_OCPP = 'GATEWAY_OCPP', - GATEWAY_ODBC = 'GATEWAY_ODBC', - GATEWAY_REQUEST = 'GATEWAY_REQUEST', - GATEWAY_REST = 'GATEWAY_REST', - GATEWAY_SNMP = 'GATEWAY_SNMP', - GATEWAY_SOCKET = 'GATEWAY_SOCKET', - GATEWAY_XMPP = 'GATEWAY_XMPP', - // ChirpStack (CE-compatible LoRaWAN integration) - CHIRPSTACK = 'CHIRPSTACK', - // ThingsBoard PE integrations (CE shows "PE only" gate) - INTEGRATION_APACHE_PULSAR = 'INTEGRATION_APACHE_PULSAR', - INTEGRATION_AWS_IOT = 'INTEGRATION_AWS_IOT', - INTEGRATION_AWS_KINESIS = 'INTEGRATION_AWS_KINESIS', - INTEGRATION_AWS_SQS = 'INTEGRATION_AWS_SQS', - INTEGRATION_AZURE_EVENT_HUB = 'INTEGRATION_AZURE_EVENT_HUB', - INTEGRATION_AZURE_IOT_HUB = 'INTEGRATION_AZURE_IOT_HUB', - INTEGRATION_AZURE_SERVICE_BUS = 'INTEGRATION_AZURE_SERVICE_BUS', - INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', - INTEGRATION_COAP = 'INTEGRATION_COAP', - INTEGRATION_CUSTOM = 'INTEGRATION_CUSTOM', - INTEGRATION_HTTP = 'INTEGRATION_HTTP', - INTEGRATION_IOT_CREATORS = 'INTEGRATION_IOT_CREATORS', - INTEGRATION_KAFKA = 'INTEGRATION_KAFKA', - INTEGRATION_KPN_THINGS = 'INTEGRATION_KPN_THINGS', - INTEGRATION_LORIOT = 'INTEGRATION_LORIOT', - INTEGRATION_MQTT = 'INTEGRATION_MQTT', - INTEGRATION_OPC_UA = 'INTEGRATION_OPC_UA', - INTEGRATION_PARTICLE = 'INTEGRATION_PARTICLE', - INTEGRATION_PUB_SUB = 'INTEGRATION_PUB_SUB', - INTEGRATION_RABBITMQ = 'INTEGRATION_RABBITMQ', - INTEGRATION_REMOTE = 'INTEGRATION_REMOTE', - INTEGRATION_SIGFOX = 'INTEGRATION_SIGFOX', - INTEGRATION_TCP = 'INTEGRATION_TCP', - INTEGRATION_THINGPARK = 'INTEGRATION_THINGPARK', - INTEGRATION_THINGPARK_ENTERPRISE = 'INTEGRATION_THINGPARK_ENTERPRISE', - INTEGRATION_TTI = 'INTEGRATION_TTI', - INTEGRATION_TTN = 'INTEGRATION_TTN', - INTEGRATION_TUYA = 'INTEGRATION_TUYA', - INTEGRATION_UDP = 'INTEGRATION_UDP' -} -``` - -- [ ] **Step 2: Verify enum count is 50** - -Run: -```bash -grep -c '^ [A-Z][A-Z_]* = ' /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts -``` -Expected: `50` - -- [ ] **Step 3: Verify CE values are a subset of marketplace `VALID_INSTALL_METHODS`** - -Run: -```bash -diff <(grep -oP "(?<=^ )[A-Z][A-Z_]+(?= = )" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts | sort -u) \ - <(grep -oP '"[A-Z][A-Z_]+"' /home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java | sed 's/^"//; s/"$//' | head -50 | sort -u) -``` -Expected: no output (sets identical). - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts -git commit -m "feat(iot-hub): expand InstallMethod enum to marketplace's 50-value allow-list" -``` - ---- - -## Task 2: Sync `installMethodLabels` map - -**Files:** -- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` - -- [ ] **Step 1: Replace the `installMethodLabels` map** - -Find (lines 32–47, after Task 1 the line numbers shift — locate by the `export const installMethodLabels = new Map(` declaration): -```typescript -export const installMethodLabels = new Map( - [ - [InstallMethod.DIRECT_HTTP, 'HTTP'], - [InstallMethod.DIRECT_MQTT, 'MQTT'], - [InstallMethod.DIRECT_COAP, 'CoAP'], - [InstallMethod.DIRECT_LWM2M, 'LwM2M'], - [InstallMethod.DIRECT_SNMP, 'SNMP'], - [InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], - [InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], - [InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], - [InstallMethod.CHIRPSTACK, 'ChirpStack'], - [InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'], - [InstallMethod.INTEGRATION_TTN, 'The Things Stack'], - [InstallMethod.INTEGRATION_LORIOT, 'LORIOT'] - ] -); -``` - -Replace with (labels mirror the brief's table and the "Description" column of the marketplace contributor doc; gateway labels keep the existing "X Gateway" pattern; integration labels keep the brand name without a "(PE)" suffix because the dialog's PE-only panel already conveys that): -```typescript -export const installMethodLabels = new Map( - [ - // Direct - [InstallMethod.DIRECT_HTTP, 'HTTP'], - [InstallMethod.DIRECT_MQTT, 'MQTT'], - [InstallMethod.DIRECT_COAP, 'CoAP'], - [InstallMethod.DIRECT_LWM2M, 'LwM2M'], - [InstallMethod.DIRECT_SNMP, 'SNMP'], - // Gateway connectors - [InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], - [InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], - [InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], - [InstallMethod.GATEWAY_BACNET, 'BACnet Gateway'], - [InstallMethod.GATEWAY_BLE, 'BLE Gateway'], - [InstallMethod.GATEWAY_CAN, 'CAN Gateway'], - [InstallMethod.GATEWAY_FTP, 'FTP Gateway'], - [InstallMethod.GATEWAY_KNX, 'KNX Gateway'], - [InstallMethod.GATEWAY_OCPP, 'OCPP Gateway'], - [InstallMethod.GATEWAY_ODBC, 'ODBC Gateway'], - [InstallMethod.GATEWAY_REQUEST, 'Request Gateway'], - [InstallMethod.GATEWAY_REST, 'REST Gateway'], - [InstallMethod.GATEWAY_SNMP, 'SNMP Gateway'], - [InstallMethod.GATEWAY_SOCKET, 'Socket Gateway'], - [InstallMethod.GATEWAY_XMPP, 'XMPP Gateway'], - // ChirpStack - [InstallMethod.CHIRPSTACK, 'ChirpStack'], - // PE integrations - [InstallMethod.INTEGRATION_APACHE_PULSAR, 'Apache Pulsar'], - [InstallMethod.INTEGRATION_AWS_IOT, 'AWS IoT'], - [InstallMethod.INTEGRATION_AWS_KINESIS, 'AWS Kinesis'], - [InstallMethod.INTEGRATION_AWS_SQS, 'AWS SQS'], - [InstallMethod.INTEGRATION_AZURE_EVENT_HUB, 'Azure Event Hub'], - [InstallMethod.INTEGRATION_AZURE_IOT_HUB, 'Azure IoT Hub'], - [InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, 'Azure Service Bus'], - [InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (Integration)'], - [InstallMethod.INTEGRATION_COAP, 'CoAP Integration'], - [InstallMethod.INTEGRATION_CUSTOM, 'Custom Integration'], - [InstallMethod.INTEGRATION_HTTP, 'HTTP Integration'], - [InstallMethod.INTEGRATION_IOT_CREATORS, 'IoT Creators'], - [InstallMethod.INTEGRATION_KAFKA, 'Apache Kafka'], - [InstallMethod.INTEGRATION_KPN_THINGS, 'KPN Things'], - [InstallMethod.INTEGRATION_LORIOT, 'LORIOT'], - [InstallMethod.INTEGRATION_MQTT, 'MQTT Integration'], - [InstallMethod.INTEGRATION_OPC_UA, 'OPC-UA Integration'], - [InstallMethod.INTEGRATION_PARTICLE, 'Particle'], - [InstallMethod.INTEGRATION_PUB_SUB, 'Google Pub/Sub'], - [InstallMethod.INTEGRATION_RABBITMQ, 'RabbitMQ'], - [InstallMethod.INTEGRATION_REMOTE, 'Remote Integration'], - [InstallMethod.INTEGRATION_SIGFOX, 'Sigfox'], - [InstallMethod.INTEGRATION_TCP, 'TCP Integration'], - [InstallMethod.INTEGRATION_THINGPARK, 'ThingPark Wireless'], - [InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, 'ThingPark Enterprise'], - [InstallMethod.INTEGRATION_TTI, 'The Things Industries'], - [InstallMethod.INTEGRATION_TTN, 'The Things Stack'], - [InstallMethod.INTEGRATION_TUYA, 'Tuya'], - [InstallMethod.INTEGRATION_UDP, 'UDP Integration'] - ] -); -``` - -- [ ] **Step 2: Verify label count matches enum count** - -Run: -```bash -node -e " -const src = require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts', 'utf8'); -const enumMatches = (src.match(/^ [A-Z][A-Z_]* = '/gm) || []).length; -const labelMatches = (src.match(/\[InstallMethod\./g) || []).length; -console.log('enum:', enumMatches, 'labels:', labelMatches); -process.exit(enumMatches === 50 && labelMatches === 50 ? 0 : 1); -" -``` -Expected: `enum: 50 labels: 50` and exit code 0. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts -git commit -m "feat(iot-hub): add human-readable labels for all 50 install methods" -``` - ---- - -## Task 3: Add `peOnlyInstallMethods` set - -**Files:** -- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` - -- [ ] **Step 1: Append the PE-only set after `installMethodLabels`** - -After the `installMethodLabels` declaration (and the closing `);`), insert a new exported set. Use the actual enum members so a typo causes a compile error: - -```typescript - -export const peOnlyInstallMethods: ReadonlySet = new Set([ - InstallMethod.INTEGRATION_APACHE_PULSAR, - InstallMethod.INTEGRATION_AWS_IOT, - InstallMethod.INTEGRATION_AWS_KINESIS, - InstallMethod.INTEGRATION_AWS_SQS, - InstallMethod.INTEGRATION_AZURE_EVENT_HUB, - InstallMethod.INTEGRATION_AZURE_IOT_HUB, - InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, - InstallMethod.INTEGRATION_CHIRPSTACK, - InstallMethod.INTEGRATION_COAP, - InstallMethod.INTEGRATION_CUSTOM, - InstallMethod.INTEGRATION_HTTP, - InstallMethod.INTEGRATION_IOT_CREATORS, - InstallMethod.INTEGRATION_KAFKA, - InstallMethod.INTEGRATION_KPN_THINGS, - InstallMethod.INTEGRATION_LORIOT, - InstallMethod.INTEGRATION_MQTT, - InstallMethod.INTEGRATION_OPC_UA, - InstallMethod.INTEGRATION_PARTICLE, - InstallMethod.INTEGRATION_PUB_SUB, - InstallMethod.INTEGRATION_RABBITMQ, - InstallMethod.INTEGRATION_REMOTE, - InstallMethod.INTEGRATION_SIGFOX, - InstallMethod.INTEGRATION_TCP, - InstallMethod.INTEGRATION_THINGPARK, - InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, - InstallMethod.INTEGRATION_TTI, - InstallMethod.INTEGRATION_TTN, - InstallMethod.INTEGRATION_TUYA, - InstallMethod.INTEGRATION_UDP -]); -``` - -Note: this set has 29 entries (every `INTEGRATION_*`). `CHIRPSTACK` (no `INTEGRATION_` prefix) is NOT in this set — it's the CE-compatible LoRaWAN install method. - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: -```bash -cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | tail -10 -``` -Expected: no errors mentioning `device-package.models.ts`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts -git commit -m "feat(iot-hub): add peOnlyInstallMethods set covering 29 PE-only integrations" -``` - ---- - -## Task 4: Add `peOnlySelected` state to install dialog component - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` - -- [ ] **Step 1: Import `peOnlyInstallMethods`** - -Locate the existing import block (lines 36–47) that destructures from `@shared/models/iot-hub/device-package.models`: - -```typescript -import { - installMethodLabels as INSTALL_METHOD_LABELS, - DeviceInstallStep, - DevicePackageInfo, - ENTITY_STEP_TYPES, - EntityStepOutput, - EntityStepProgress, - FormFieldDefinition, - FormFieldType, - InstallStepType, - stepTypeAliasMap -} from '@shared/models/iot-hub/device-package.models'; -``` - -Replace the destructure list to add `peOnlyInstallMethods`: - -```typescript -import { - installMethodLabels as INSTALL_METHOD_LABELS, - peOnlyInstallMethods, - DeviceInstallStep, - DevicePackageInfo, - ENTITY_STEP_TYPES, - EntityStepOutput, - EntityStepProgress, - FormFieldDefinition, - FormFieldType, - InstallStepType, - stepTypeAliasMap -} from '@shared/models/iot-hub/device-package.models'; -``` - -- [ ] **Step 2: Add `peOnlySelected` view-state flag and getter** - -Locate the connectivity-state block in the class (around lines 92–96): - -```typescript - // Connectivity - showConnectivitySelector = false; - availableInstallMethods: string[] = []; - selectedInstallMethod: string | null = null; - installMethodLabels = INSTALL_METHOD_LABELS; -``` - -Replace with: - -```typescript - // Connectivity - showConnectivitySelector = false; - showPeOnlyPanel = false; - availableInstallMethods: string[] = []; - selectedInstallMethod: string | null = null; - installMethodLabels = INSTALL_METHOD_LABELS; - - get isSelectedPeOnly(): boolean { - return this.selectedInstallMethod !== null && peOnlyInstallMethods.has(this.selectedInstallMethod); - } -``` - -- [ ] **Step 3: Route auto-selected single install method to PE-only panel when applicable** - -Locate the `ngOnInit` branching block (around lines 172–178): - -```typescript - } else if (this.availableInstallMethods.length === 1) { - this.selectedInstallMethod = this.availableInstallMethods[0]; - this.showConnectivitySelector = false; - this.startWizard(); - } else { - this.showConnectivitySelector = true; - } -``` - -Replace with: - -```typescript - } else if (this.availableInstallMethods.length === 1) { - this.selectedInstallMethod = this.availableInstallMethods[0]; - this.showConnectivitySelector = false; - if (this.isSelectedPeOnly) { - this.showPeOnlyPanel = true; - } else { - this.startWizard(); - } - } else { - this.showConnectivitySelector = true; - } -``` - -- [ ] **Step 4: Route `confirmConnectivity()` to PE-only panel when user picks a PE-only method** - -Locate `confirmConnectivity()` at line 200: - -```typescript - confirmConnectivity(): void { - if (!this.selectedInstallMethod) { - return; - } - this.startWizard(); - } -``` - -Replace with: - -```typescript - confirmConnectivity(): void { - if (!this.selectedInstallMethod) { - return; - } - if (this.isSelectedPeOnly) { - this.showPeOnlyPanel = true; - this.cdr.detectChanges(); - return; - } - this.startWizard(); - } -``` - -The connectivity selector branch is already hidden once `showPeOnlyPanel` flips true — see the template change in Task 5 — so no separate `showConnectivitySelector` flip is needed. - -- [ ] **Step 5: Verify build** - -Run: -```bash -cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E 'device-install-dialog|device-package' | head -10 -``` -Expected: no output (file compiles). - -- [ ] **Step 6: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts -git commit -m "feat(iot-hub): gate PE-only install methods with dedicated panel state" -``` - ---- - -## Task 5: Render the PE-only panel in the install dialog template - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` - -- [ ] **Step 1: Insert the PE-only panel after the connectivity selector block** - -Locate the connectivity selector block (lines 213–238 — starts with `@if (!wizardStarted) {` and contains ``). The structure is: - -```html - @if (!wizardStarted) { - -
- ... -
- - ... - - - } @else if (reviewMode) { -``` - -The `@if (!wizardStarted)` branch must be tightened so the connectivity selector only renders when `!showPeOnlyPanel`, and a new branch must render the PE-only panel. - -Replace the `@if (!wizardStarted) { ... }` outer block (the entire connectivity-selector branch including the `` content and the closing ``) with: - -```html - @if (!wizardStarted && !showPeOnlyPanel) { - -
-
-

{{ 'iot-hub.device-install-select-connectivity' | translate }}

-
- @for (ct of availableInstallMethods; track ct) { - - } -
-
-
- - - - - - } @else if (showPeOnlyPanel) { - -
-
- workspace_premium -

- {{ 'iot-hub.device-install-pe-only-title' | translate }} -

-

- {{ 'iot-hub.device-install-pe-only-message' | translate:{ method: installMethodLabels.get(selectedInstallMethod) || selectedInstallMethod } }} -

- - {{ 'iot-hub.device-install-pe-only-learn-more' | translate }} - -
-
- - - - - } @else if (reviewMode) { -``` - -(The `@else if (reviewMode) {` line was already there — just leave it as the start of the next branch.) - -- [ ] **Step 2: Verify Angular template compiles** - -Run: -```bash -cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -8 -``` -Expected: ends with `Application bundle generation complete.` and no template errors. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html -git commit -m "feat(iot-hub): render PE-only panel when user selects a PE-only install method" -``` - ---- - -## Task 6: Add translation keys for the PE-only panel - -**Files:** -- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` - -- [ ] **Step 1: Locate the `iot-hub` section's `device-install-*` keys** - -Run: -```bash -grep -n 'device-install-select-connectivity\|device-install-title' /home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json -``` -This identifies the lines where the existing `device-install-*` keys live so the new keys can be added alongside them. - -- [ ] **Step 2: Add the three new keys** - -Add these three key/value pairs immediately after the existing `device-install-select-connectivity` key, preserving the surrounding JSON structure (commas, indentation): - -```json -"device-install-pe-only-title": "ThingsBoard PE required", -"device-install-pe-only-message": "The {{method}} install method requires ThingsBoard Professional Edition. Upgrade your installation to use this device package.", -"device-install-pe-only-learn-more": "Learn about ThingsBoard PE", -``` - -`{{method}}` is the MessageFormat parameter passed from the template (`installMethodLabels.get(selectedInstallMethod)`). - -- [ ] **Step 3: Verify JSON is valid** - -Run: -```bash -node -e "JSON.parse(require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json','utf8')); console.log('OK');" -``` -Expected: `OK`. - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/assets/locale/locale.constant-en_US.json -git commit -m "feat(iot-hub): add translation keys for PE-only install panel" -``` - ---- - -## Task 7: Manual smoke verification - -**Files:** none (verification only) - -- [ ] **Step 1: Production build sanity check** - -Run: -```bash -cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -3 -``` -Expected: ends with `Application bundle generation complete.` - -- [ ] **Step 2: Verify CE enum is a strict superset of the previous 12-value enum** - -Run: -```bash -for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ - GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ - CHIRPSTACK INTEGRATION_CHIRPSTACK INTEGRATION_TTN INTEGRATION_LORIOT; do - grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ - && echo "OK: $c" || echo "MISSING: $c" -done -``` -Expected: 12 lines starting with `OK:` and zero `MISSING:` lines. - -- [ ] **Step 3: Verify all 50 marketplace constants are present** - -Run: -```bash -for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ - GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ - GATEWAY_BACNET GATEWAY_BLE GATEWAY_CAN GATEWAY_FTP GATEWAY_KNX \ - GATEWAY_OCPP GATEWAY_ODBC GATEWAY_REQUEST GATEWAY_REST \ - GATEWAY_SNMP GATEWAY_SOCKET GATEWAY_XMPP \ - CHIRPSTACK \ - INTEGRATION_APACHE_PULSAR INTEGRATION_AWS_IOT INTEGRATION_AWS_KINESIS \ - INTEGRATION_AWS_SQS INTEGRATION_AZURE_EVENT_HUB INTEGRATION_AZURE_IOT_HUB \ - INTEGRATION_AZURE_SERVICE_BUS INTEGRATION_CHIRPSTACK INTEGRATION_COAP \ - INTEGRATION_CUSTOM INTEGRATION_HTTP INTEGRATION_IOT_CREATORS \ - INTEGRATION_KAFKA INTEGRATION_KPN_THINGS INTEGRATION_LORIOT \ - INTEGRATION_MQTT INTEGRATION_OPC_UA INTEGRATION_PARTICLE \ - INTEGRATION_PUB_SUB INTEGRATION_RABBITMQ INTEGRATION_REMOTE \ - INTEGRATION_SIGFOX INTEGRATION_TCP INTEGRATION_THINGPARK \ - INTEGRATION_THINGPARK_ENTERPRISE INTEGRATION_TTI INTEGRATION_TTN \ - INTEGRATION_TUYA INTEGRATION_UDP; do - grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ - && grep -q "InstallMethod.${c}, '" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ - || echo "INCOMPLETE: $c" -done -``` -Expected: no output (every constant has both an enum entry and a label). - -- [ ] **Step 4: Browser smoke test (manual)** - -1. Start dev server: `cd /home/ashvayka/git/ce/ui-ngx && npm start` -2. Log in as a TENANT_ADMIN. Open IoT Hub → Browse. -3. Pick any device package that lists multiple install methods. Open install dialog. -4. **Direct/Gateway/CHIRPSTACK methods:** label renders correctly (no raw constant), Next button starts the wizard normally — no regression. -5. **PE-only method (e.g., `INTEGRATION_AWS_IOT` if the package supports it; otherwise temporarily edit any local test package's `device-info.json` to add a PE-only method):** Next button shows the PE-only panel, the title says "ThingsBoard PE required", the body interpolates the method label (e.g., "The AWS IoT install method requires…"), the "Learn about ThingsBoard PE" link opens `thingsboard.io/products/thingsboard-pe/` in a new tab, the Close button dismisses the dialog. -6. **Auto-selected single PE-only method:** if a package lists only one method and it's PE-only, the dialog should open directly on the PE-only panel (no connectivity selector). - -If any check fails, mark this task incomplete and address before merging. - ---- - -## Out of scope - -- **No backend allow-list to update.** CE's `DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String` field; the marketplace validator at `ItemDataServiceImpl.VALID_INSTALL_METHODS` is the single source of truth. -- **No new install behavior for the 12 new gateway connectors.** The existing `GATEWAY_CONNECTOR` step (`device-install-dialog.component.ts:761`) writes any connector config object to the gateway's `active_connectors` shared attribute regardless of connector type. Connector-specific schemas (BACnet, BLE, KNX, etc.) are creator concerns, not wizard concerns — the creator's connector JSON config is opaque to the wizard. -- **No connector-specific `${gateway.*}` variable additions.** Existing gateway outputs (`${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}`) are sufficient. Per-connector values like ports or hostnames are already supplied via `SHOW_FORM` fields and resolved through the standard variable mechanism. -- **No CE PE→install conversion.** PE integrations stay PE-only; CE shows the gate. -- **No automated unit test parity.** `ui-ngx` has no spec runner configured (no `*.spec.ts` files outside `node_modules`). The bash verifications in Task 7 act as the parity check. diff --git a/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md b/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md deleted file mode 100644 index b3fb150346..0000000000 --- a/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md +++ /dev/null @@ -1,974 +0,0 @@ -# IoT Hub `${item-link:uuid}` Markdown Component — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `${item-link:}` markdown placeholder that renders an IoT Hub marketplace item card (thumbnail + name + creator) inline in readmes and install instructions, opening the linked item in a new tab. - -**Architecture:** A pure-string utility rewrites `${item-link:}` to a `` Angular component tag before the markdown reaches `tb-markdown`. The component is declared in a small `IotHubItemLinkModule` that each `tb-markdown` instance receives as `additionalCompileModules`. The component owns its own fetch (via `IotHubApiService.getPublishedVersion`), state (`loading` / `loaded` / `unavailable`), and click target (`/iot-hub/{itemId}` with `target="_blank"`). - -**Tech Stack:** Angular 20 (TypeScript, Material), `tb-markdown` (NgModule-based dynamic compilation), `IotHubApiService` (existing), Tailwind for utility classes, component-scoped SCSS. - -**Spec:** `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` - -**File structure:** - -New files: -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` — `replaceItemLinkPlaceholders` + regex -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` - -Modified files: -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — import `IotHubItemLinkModule` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — call utility in `loadReadme()`, expose compile modules -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — bind `[additionalCompileModules]` on readme `` -- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` — add `^item-link:(uuid)$` branch in `resolveVariables()`, expose compile modules -- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` — bind `[additionalCompileModules]` -- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` — pre-process `details`, expose compile modules -- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` — bind `[additionalCompileModules]` -- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — add `iot-hub.item-link-unavailable` - ---- - -## Task 1: Create placeholder utility - -**Files:** -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` - -- [ ] **Step 1: Create the utility file** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts`: - -```typescript -/// -/// Copyright © 2016-2026 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. -/// - -export const ITEM_LINK_PLACEHOLDER_REGEX = - /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; - -export const ITEM_LINK_KEY_REGEX = - /^item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; - -export function itemLinkCardTag(itemId: string): string { - return ``; -} - -export function replaceItemLinkPlaceholders(markdown: string): string { - if (!markdown) { - return markdown; - } - return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); -} -``` - -- [ ] **Step 2: Sanity-check the regex** - -Write a temporary script `/tmp/check-item-link-regex.js` with this exact content: - -```javascript -const re = /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; -const cases = [ - ['${item-link:11111111-2222-3333-4444-555555555555}', 'valid'], - ['${item-link:not-a-uuid}', 'invalid'], - ['Two: ${item-link:11111111-2222-3333-4444-555555555555} and ${item-link:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}', 'two valid'], - ['```\n${item-link:11111111-2222-3333-4444-555555555555}\n```', 'inside code fence (still replaced — documented behavior)'] -]; -for (const [input, label] of cases) { - console.log(label + ':', input.replace(re, (_m, u) => '')); -} -``` - -Run it: -```bash -node /tmp/check-item-link-regex.js -``` - -Expected output: -``` -valid: -invalid: ${item-link:not-a-uuid} -two valid: Two: and -inside code fence (still replaced — documented behavior): ``` - -``` -``` - -Then delete the script: `rm /tmp/check-item-link-regex.js`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts -git commit -m "feat(iot-hub): add item-link placeholder utility for markdown" -``` - ---- - -## Task 2: Create item-link card component (skeleton + loaded states, image-thumbnail items) - -**Files:** -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` - -- [ ] **Step 1: Create the directory** - -```bash -mkdir -p ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card -``` - -- [ ] **Step 2: Create `iot-hub-item-link-card.component.ts`** - -```typescript -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, Input, OnInit } from '@angular/core'; -import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; - -type CardState = 'loading' | 'loaded' | 'unavailable'; - -const COMPACT_TYPES: ReadonlySet = new Set([ - ItemType.CALCULATED_FIELD, - ItemType.ALARM_RULE, - ItemType.RULE_CHAIN -]); - -@Component({ - selector: 'tb-iot-hub-item-link-card', - standalone: false, - templateUrl: './iot-hub-item-link-card.component.html', - styleUrls: ['./iot-hub-item-link-card.component.scss'] -}) -export class TbIotHubItemLinkCardComponent implements OnInit { - - @Input() itemId!: string; - - state: CardState = 'loading'; - item: MpItemVersionView | null = null; - - constructor(private iotHubApiService: IotHubApiService) {} - - ngOnInit(): void { - if (!this.itemId) { - this.state = 'unavailable'; - return; - } - this.iotHubApiService - .getPublishedVersion(this.itemId, { ignoreErrors: true, ignoreLoading: true }) - .subscribe({ - next: item => { - this.item = item; - this.state = item ? 'loaded' : 'unavailable'; - }, - error: () => { - this.state = 'unavailable'; - } - }); - } - - isCompact(): boolean { - return !!this.item && COMPACT_TYPES.has(this.item.type); - } - - getImageUrl(): string | null { - return this.item?.image - ? this.iotHubApiService.resolveResourceUrl(this.item.image) - : null; - } - - getCompactIcon(): string { - if (!this.item) { - return 'category'; - } - if (this.item.icon) { - return this.item.icon; - } - switch (this.item.type) { - case ItemType.CALCULATED_FIELD: return 'functions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.RULE_CHAIN: return 'account_tree'; - default: return 'category'; - } - } - - getTypeIcon(): string { - if (!this.item) { - return 'category'; - } - switch (this.item.type) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboard'; - case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; - case ItemType.CALCULATED_FIELD: return 'functions'; - case ItemType.ALARM_RULE: return 'notification_important'; - case ItemType.RULE_CHAIN: return 'account_tree'; - case ItemType.DEVICE: return 'memory'; - default: return 'category'; - } - } - - getCompactColor(): string { - return this.item?.color || '#048ad3'; - } - - getHref(): string { - return `/iot-hub/${this.itemId}`; - } -} -``` - -- [ ] **Step 3: Create `iot-hub-item-link-card.component.html`** - -```html - -@switch (state) { - @case ('loading') { - - } - @case ('loaded') { -
- @if (isCompact()) { - - } @else { - - } - - - } - @case ('unavailable') { - - } -} -``` - -- [ ] **Step 4: Create `iot-hub-item-link-card.component.scss`** - -```scss -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -:host { - display: block; - margin: 12px 0; -} - -.tb-iot-hub-item-link-card { - display: inline-flex; - align-items: center; - gap: 12px; - width: 320px; - max-width: 100%; - padding: 8px 12px; - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 8px; - background: #fff; - text-decoration: none; - color: inherit; - transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; - - &:hover:not(.tb-iot-hub-item-link-card--unavailable):not(.tb-iot-hub-item-link-card--skeleton) { - background: rgba(4, 138, 211, 0.04); - border-color: rgba(4, 138, 211, 0.32); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - } - - &--unavailable { - opacity: 0.6; - cursor: default; - } - - &--skeleton { - cursor: default; - } -} - -.tb-iot-hub-item-link-thumb { - flex: 0 0 48px; - width: 48px; - height: 48px; - border-radius: 6px; - background: #f4f6f8; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - &--compact { - color: #fff; - } - - &--unavailable { - color: rgba(0, 0, 0, 0.45); - } -} - -.tb-iot-hub-item-link-thumb-icon { - font-size: 26px; - width: 26px; - height: 26px; - line-height: 26px; - color: #fff; -} - -.tb-iot-hub-item-link-thumb-fallback { - font-size: 26px; - width: 26px; - height: 26px; - color: rgba(0, 0, 0, 0.45); -} - -.tb-iot-hub-item-link-text { - display: flex; - flex-direction: column; - min-width: 0; - gap: 2px; -} - -.tb-iot-hub-item-link-name { - font-size: 14px; - font-weight: 500; - line-height: 1.3; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.tb-iot-hub-item-link-author { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - line-height: 1.3; - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - } -} - -.tb-iot-hub-item-link-skeleton-block, -.tb-iot-hub-item-link-skeleton-line { - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.06) 0%, - rgba(0, 0, 0, 0.12) 50%, - rgba(0, 0, 0, 0.06) 100% - ); - background-size: 200% 100%; - animation: tb-iot-hub-item-link-shimmer 1.4s ease-in-out infinite; - border-radius: 4px; -} - -.tb-iot-hub-item-link-skeleton-line { - display: block; - height: 12px; - width: 180px; - margin: 4px 0; - - &--short { - width: 96px; - } -} - -@keyframes tb-iot-hub-item-link-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} -``` - -- [ ] **Step 5: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card -git commit -m "feat(iot-hub): add TbIotHubItemLinkCardComponent for markdown item links" -``` - ---- - -## Task 3: Create the wrapper module and register it - -**Files:** -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` - -- [ ] **Step 1: Create `iot-hub-item-link.module.ts`** - -```typescript -/// -/// Copyright © 2016-2026 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 { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { SharedModule } from '@shared/shared.module'; -import { TbIotHubItemLinkCardComponent } from './iot-hub-item-link-card.component'; - -@NgModule({ - declarations: [TbIotHubItemLinkCardComponent], - imports: [CommonModule, SharedModule], - exports: [TbIotHubItemLinkCardComponent] -}) -export class IotHubItemLinkModule {} -``` - -- [ ] **Step 2: Register module in `IotHubComponentsModule`** - -Modify `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — add the import and add `IotHubItemLinkModule` to the `imports` array. - -After the existing imports near line 32, add: - -```typescript -import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; -``` - -Then update the `imports` array of the `@NgModule` decorator from: - -```typescript - imports: [ - CommonModule, - SharedModule - ], -``` - -to: - -```typescript - imports: [ - CommonModule, - SharedModule, - IotHubItemLinkModule - ], -``` - -- [ ] **Step 3: Build to verify the module wires up** - -```bash -cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -40 -``` - -Expected: build succeeds (warnings allowed; no errors mentioning `TbIotHubItemLinkCardComponent` or `IotHubItemLinkModule`). - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts -git commit -m "feat(iot-hub): wrap item-link card in IotHubItemLinkModule" -``` - ---- - -## Task 4: Add translation key - -**Files:** -- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` - -- [ ] **Step 1: Add the translation key** - -Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`. Locate the `"iot-hub": { ... }` block that begins around line 3701 (the one that opens with `"iot-hub": "IoT Hub",`). Add a new entry next to similar one-liner keys (e.g., right after `"installed-from-iot-hub": "Installed from IoT Hub",` near line 3719): - -```json - "item-link-unavailable": "Item unavailable", -``` - -Make sure the surrounding commas are correct — the new line ends with a comma, and the line above also ends with a comma. - -- [ ] **Step 2: Verify JSON is valid** - -```bash -node -e "JSON.parse(require('fs').readFileSync('ui-ngx/src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK');" -``` -Expected output: `OK` - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/assets/locale/locale.constant-en_US.json -git commit -m "feat(iot-hub): add item-link-unavailable translation key" -``` - ---- - -## Task 5: Wire into item detail dialog (readme) - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` - -- [ ] **Step 1: Update the .ts file to pre-process readme and expose compile modules** - -In `iot-hub-item-detail-dialog.component.ts`: - -Add to the imports near the existing `resolveDocLinkPlaceholders` import: - -```typescript -import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils'; -import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; -import { Type } from '@angular/core'; -``` - -(`Type` may already be imported via Angular core; merge into the existing `@angular/core` import if so.) - -Inside the class, near the other readonly fields (after `readonly ItemType = ItemType;`), add: - -```typescript - readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; -``` - -Update the existing `loadReadme()` method body (currently at line 307-312): - -```typescript - private loadReadme(): void { - const versionId = this.item.id as string; - this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( - content => this.readmeContent = replaceItemLinkPlaceholders( - this.resolveDocLinks(this.prefixResourceUrls(content || '')) - ) - ); - } -``` - -- [ ] **Step 2: Update the template to pass compile modules** - -In `iot-hub-item-detail-dialog.component.html` at line 302, change: - -```html - -``` - -to: - -```html - -``` - -Leave the changelog `` (line 306) unchanged — changelog is out of scope per the spec. - -- [ ] **Step 3: Build** - -```bash -cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 -``` - -Expected: build succeeds. - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ - ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html -git commit -m "feat(iot-hub): render \${item-link} cards in item readme" -``` - ---- - -## Task 6: Wire into device install dialog (instructions) - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` - -- [ ] **Step 1: Add imports and compile-modules field** - -In `device-install-dialog.component.ts`, add to the imports: - -```typescript -import { IotHubItemLinkModule } from '../iot-hub-item-link-card/iot-hub-item-link.module'; -import { ITEM_LINK_KEY_REGEX, itemLinkCardTag } from '../iot-hub-markdown.utils'; -import { Type } from '@angular/core'; -``` - -(Merge `Type` into the existing `@angular/core` import line.) - -Inside the class, alongside other readonly fields, add: - -```typescript - readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; -``` - -- [ ] **Step 2: Add `^item-link:(uuid)$` branch inside `resolveVariables`** - -In `resolveVariables()` (currently at line 427-477 in the same file), add a new clause **immediately after** the `gateway.downloadButton` clause (around line 444-446) and before the callout matcher. - -Change this section: - -```typescript - // Special action placeholders - if (key === 'gateway.downloadButton') { - return '⬇ Download docker-compose.yml'; - } - // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} -``` - -to: - -```typescript - // Special action placeholders - if (key === 'gateway.downloadButton') { - return '⬇ Download docker-compose.yml'; - } - // IoT Hub item link card: ${item-link:} - const itemLinkMatch = key.match(ITEM_LINK_KEY_REGEX); - if (itemLinkMatch) { - return itemLinkCardTag(itemLinkMatch[1]); - } - // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} -``` - -- [ ] **Step 3: Update the template** - -In `device-install-dialog.component.html` at line 26-28, change: - -```html - - -``` - -to: - -```html - - -``` - -- [ ] **Step 4: Build** - -```bash -cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 -``` - -Expected: build succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts \ - ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html -git commit -m "feat(iot-hub): render \${item-link} cards in device install instructions" -``` - ---- - -## Task 7: Wire into solution install dialog (instructions) - -**Files:** -- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` -- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` - -- [ ] **Step 1: Update the .ts file** - -Replace the contents of `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` with: - -```typescript -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, Inject, Type } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; -import { - IotHubItemLinkModule -} from '@home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module'; -import { - replaceItemLinkPlaceholders -} from '@home/components/iot-hub/iot-hub-markdown.utils'; - -export interface SolutionInstallDialogData { - descriptor: SolutionTemplateInstalledItemDescriptor; - instructions?: boolean; -} - -@Component({ - selector: 'tb-solution-install-dialog', - templateUrl: './solution-install-dialog.component.html', - styleUrls: ['./solution-install-dialog.component.scss'], - standalone: false -}) -export class SolutionInstallDialogComponent { - - details: string; - dashboardId: string | null; - instructions: boolean; - - readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData, - private dialogRef: MatDialogRef, - private router: Router - ) { - this.details = replaceItemLinkPlaceholders(data.descriptor.details || ''); - this.dashboardId = data.descriptor.dashboardId?.id || null; - this.instructions = !!data.instructions; - } - - gotoMainDashboard(): void { - if (this.dashboardId) { - this.dialogRef.close(); - this.router.navigateByUrl(`/dashboards/${this.dashboardId}`); - } - } - - close(): void { - this.dialogRef.close(); - } -} -``` - -- [ ] **Step 2: Update the template** - -In `solution-install-dialog.component.html` at line 29, change: - -```html - -``` - -to: - -```html - -``` - -- [ ] **Step 3: Build** - -```bash -cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 -``` - -Expected: build succeeds. - -- [ ] **Step 4: Commit** - -```bash -git add ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts \ - ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html -git commit -m "feat(iot-hub): render \${item-link} cards in solution install instructions" -``` - ---- - -## Task 8: Manual visual QA - -**No file changes — verify the feature end-to-end in a browser.** - -- [ ] **Step 1: Start the dev server** - -```bash -cd ui-ngx && npm start -``` - -Wait for `Compiled successfully` and `Application bundle generation complete`. - -Open `http://localhost:4200` in a browser and log in as a tenant administrator. - -- [ ] **Step 2: Verify item readme rendering** - -In a separate terminal, query the configured IoT Hub for an item ID you can use as a test target: - -```bash -# Use the same baseUrl that your TB instance uses (typically https://iot-hub.thingsboard.io). -# Pick any published item you can find via the marketplace UI's network requests in DevTools, -# e.g. by opening the IoT Hub home page and copying an itemId from a response. -echo "Pick a known itemId from network responses in the IoT Hub UI" -``` - -Then, for the TEST PASS: -1. Open IoT Hub in the running app, find an item with a non-empty readme (e.g., a Solution Template), open the detail dialog. -2. In DevTools, intercept the readme response by editing it (Network → block + replay, or temporarily edit `readmeContent` via the Angular DevTools), and inject `${item-link:}` somewhere in the readme markdown. - - Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:}\n'` to the fetched content, just for this QA pass. Revert before committing. -3. Reload the dialog. Expected: - - Skeleton card appears briefly with shimmer. - - Card resolves to: 48-px square thumbnail (image or colored compact icon), item name, person icon + creator name. - - Hover shows light blue tint and subtle shadow. - - Click opens `/iot-hub/` in a **new tab**, which lands on the item type page with the detail dialog open. - - Middle-click also opens new tab; ctrl-click does the same. - -Revert any hardcoded test data before continuing. - -- [ ] **Step 3: Verify unavailable state** - -Repeat Step 2 but inject a placeholder with a UUID that does not exist (e.g., `${item-link:00000000-0000-0000-0000-000000000000}`). Expected: -- Card briefly shows skeleton. -- Resolves to disabled card: dim opacity, `link_off` icon in the thumbnail slot, "Item unavailable" label. -- Card is not clickable (no ``, no hover blue). - -- [ ] **Step 4: Verify invalid placeholder is left untouched** - -Inject `${item-link:not-a-uuid}` into the readme. Expected: the rendered markdown shows the literal text `${item-link:not-a-uuid}` (no card, no error). This proves the regex is strict. - -- [ ] **Step 5: Verify device install instructions** - -Open any IoT Hub Device item, click Install, and walk to a step whose markdown is dynamically rendered. Inject `${item-link:}` into one of the markdown templates served by the device package (or temporarily prepend it to `step.markdown` in `device-install-dialog.component.ts` near the existing `resolveVariables` call). Expected: same skeleton → loaded card behavior; click opens `/iot-hub/` in a new tab. - -Revert temporary edits. - -- [ ] **Step 6: Verify solution install instructions** - -Install (or reopen the install instructions for) a Solution Template that has a `details` markdown. Inject `${item-link:}` into `details` (temporarily, in the constructor or via a known solution template whose details you control). Expected: same skeleton → loaded behavior; click opens `/iot-hub/` in a new tab. - -Revert temporary edits. - -- [ ] **Step 7: Confirm no regressions in existing markdown** - -In each of the three render sites, verify after the QA edits are reverted: -- Existing `${gateway.downloadButton}` placeholder still renders correctly in the device install instructions (unchanged behavior). -- Existing callout boxes (`${note(...)}`, `${warn(...)}`, `${error(...)}`) still render in the device install instructions. -- Existing `prefixResourceUrls` and `resolveDocLinks` continue to resolve image URLs and doc-link placeholders in readmes. -- Changelog tab in the item detail dialog still renders without an item-link card (it does not bind `additionalCompileModules`). - -- [ ] **Step 8: Stop the dev server** - -`Ctrl-C` in the terminal running `npm start`. - -- [ ] **Step 9: Final commit (if any QA-driven fix-ups were made)** - -If QA surfaced any issues that required code changes, commit them with a focused message. Otherwise this step is a no-op. - -```bash -git status -# If clean, no commit needed. -``` - ---- - -## Self-review checklist (for the implementing agent) - -Before declaring complete, verify: - -1. **Spec coverage** — every decision in `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` has a corresponding task above. -2. **No leftover test scaffolding** — temporary hardcoded `${item-link:...}` placeholders used for QA are reverted. -3. **License headers** — every new `.ts` file uses `///` style, every new `.html` uses ``, every new `.scss` uses `/** */`. -4. **Build clean** — `npx ng build --configuration=development` finishes without errors. -5. **Three sites work, fourth doesn't** — readme, device install, solution install all render the card; changelog tab does **not** (by design — out of scope per spec). diff --git a/docs/superpowers/specs/2026-04-02-gateway-support-design.md b/docs/superpowers/specs/2026-04-02-gateway-support-design.md deleted file mode 100644 index db94c4b1af..0000000000 --- a/docs/superpowers/specs/2026-04-02-gateway-support-design.md +++ /dev/null @@ -1,267 +0,0 @@ -# Gateway Support in Device Install Framework — Design Spec - -## Goal - -Extend the device install wizard to support gateway provisioning: creating a gateway device, configuring one or more connectors via shared attributes, and providing the gateway launch command (docker-compose download URL) in post-install instructions. - -## New Step Types - -### GATEWAY - -Creates a gateway device — a device with `additionalInfo: { gateway: true }`. - -- **Template:** device JSON (same format as DEVICE step), must include `"additionalInfo": {"gateway": true}` -- **After creation:** fetches device credentials (same as DEVICE) -- **Find-or-create:** no — always creates new (same as DEVICE) -- **Output variables:** - - `${gateway.id}` — device UUID - - `${gateway.name}` — device name - - `${gateway.token}` — access token - - `${gateway.dockerComposeUrl}` — `/api/device-connectivity/gateway-launch/${gateway.id}/docker-compose/download` - -### GATEWAY_CONNECTOR - -Configures a connector on a previously created gateway by saving connector config as shared attributes. - -- **Template:** connector config JSON — the object with `name`, `type`, `configurationJson`, `logLevel`, etc. -- **Behavior:** - 1. Fetch current `active_connectors` shared attribute from the gateway (may be empty/missing — default to `[]`) - 2. Append connector name to the array - 3. Save updated `active_connectors` as shared attribute on the gateway - 4. Save `{connectorName}: connectorConfig` as shared attribute on the gateway -- **Target entity:** uses `${gateway.id}` from the preceding GATEWAY step -- **Output variables:** `${gatewayConnector.name}` — the connector name from the template -- **Multiple steps:** each GATEWAY_CONNECTOR step appends to `active_connectors`. Two connectors → two steps → `active_connectors = ["Modbus Connector", "MQTT Connector"]` - -## Extension: Optional Attributes on Entity Steps - -All entity creation steps (DEVICE, GATEWAY, DEVICE_PROFILE, DASHBOARD, RULE_CHAIN) gain two optional fields in the step definition: - -```json -{ - "type": "GATEWAY", - "name": "${deviceName}", - "template": "gateway.json", - "serverAttributes": "server-attributes.json", - "sharedAttributes": "shared-attributes.json" -} -``` - -- `serverAttributes` — optional, path to JSON file in ZIP. After entity creation, file is read, variables resolved, and saved as `SERVER_SCOPE` attributes on the created entity. -- `sharedAttributes` — optional, path to JSON file in ZIP. Same, saved as `SHARED_SCOPE`. - -Both files contain a flat JSON object of key-value pairs: -```json -{ - "firmwareVersion": "1.2.3", - "configUrl": "${http.host}:${http.port}/config" -} -``` - -## Changes to Existing Models - -### DeviceInstallStep interface (device-package.models.ts) - -Add fields: -```typescript -export interface DeviceInstallStep { - type: InstallStepType; - name: string; - file?: string; - template?: string; - serverAttributes?: string; // NEW - sharedAttributes?: string; // NEW -} -``` - -### InstallStepType enum - -Add: -```typescript -GATEWAY = 'GATEWAY', -GATEWAY_CONNECTOR = 'GATEWAY_CONNECTOR' -``` - -### ENTITY_STEP_TYPES set - -Add `GATEWAY` and `GATEWAY_CONNECTOR`. - -### stepTypeAliasMap - -Add: -```typescript -GATEWAY: 'gateway', -GATEWAY_CONNECTOR: 'gatewayConnector' -``` - -## Variable Resolution Updates - -### New named entity outputs - -| Step type | Variables | -|-----------|-----------| -| GATEWAY | `${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}` | -| GATEWAY_CONNECTOR | `${gatewayConnector.name}` | - -### EntityStepOutput interface - -Add optional `dockerComposeUrl` field: -```typescript -export interface EntityStepOutput { - id: string; - name: string; - token?: string; - dockerComposeUrl?: string; // NEW -} -``` - -## Frontend Implementation (createEntity) - -### GATEWAY case - -Same as DEVICE: -1. Save device via `deviceService.saveDevice(template, {ignoreErrors: true})` -2. Fetch credentials via `deviceService.getDeviceCredentials(id)` -3. Return output with `dockerComposeUrl` computed from the device ID - -```typescript -case InstallStepType.GATEWAY: { - const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); - const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true})); - return { - id: result.id.id, - name: result.name, - token: creds.credentialsId, - dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` - }; -} -``` - -### GATEWAY_CONNECTOR case - -1. Read and resolve the connector template from ZIP -2. Extract `name` from the connector config -3. Fetch current `active_connectors` from gateway's shared attributes (or default to `[]`) -4. Append connector name -5. Save both attributes to gateway via `attributeService.saveEntityAttributes()` - -```typescript -case InstallStepType.GATEWAY_CONNECTOR: { - const gatewayOutput = this.entityOutputs.get('gateway'); - if (!gatewayOutput) throw new Error('GATEWAY step must precede GATEWAY_CONNECTOR'); - const gatewayEntityId = { entityType: 'DEVICE', id: gatewayOutput.id }; - - // Fetch current active_connectors - const attrs = await firstValueFrom(this.attributeService.getEntityAttributes( - gatewayEntityId, AttributeScope.SHARED_SCOPE, ['active_connectors'], {ignoreErrors: true} - )); - const activeConnectors: string[] = attrs.find(a => a.key === 'active_connectors')?.value || []; - - // Add this connector - const connectorName = template.name; - if (!activeConnectors.includes(connectorName)) { - activeConnectors.push(connectorName); - } - - // Save attributes - await firstValueFrom(this.attributeService.saveEntityAttributes( - gatewayEntityId, AttributeScope.SHARED_SCOPE, - [ - { key: 'active_connectors', value: activeConnectors }, - { key: connectorName, value: template } - ], - {ignoreErrors: true} - )); - - return { id: gatewayOutput.id, name: connectorName }; -} -``` - -### Attribute saving after any entity step - -After `createEntity()` returns, check if the step has `serverAttributes` or `sharedAttributes`. If so, read the file, resolve variables, and save: - -```typescript -if (step.serverAttributes) { - const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.serverAttributes))); - const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); - await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true})); -} -if (step.sharedAttributes) { - const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.sharedAttributes))); - const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); - await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true})); -} -``` - -## Translation Keys - -Add: -``` -"iot-hub.device-install-step-type-GATEWAY": "Gateway", -"iot-hub.device-install-step-type-GATEWAY_CONNECTOR": "Gateway Connector" -``` - -## Example Gateway Package - -``` -modbus-sensor.zip/ -├── device-info.json -├── prerequisites.md -├── form.json -├── gateway.json -├── modbus-connector.json -├── dashboard.json -└── post-install.md -``` - -`device-info.json`: -```json -{ - "name": "Modbus Sensor", - "vendor": "Example", - "hardwareType": "SENSOR", - "connectivityTypes": ["GATEWAY_MODBUS"], - "installSteps": { - "GATEWAY_MODBUS": [ - {"type": "SHOW_INSTRUCTION", "name": "Prerequisites", "file": "prerequisites.md"}, - {"type": "SHOW_FORM", "name": "Configuration", "file": "form.json"}, - {"type": "GATEWAY", "name": "${deviceName} Gateway", "template": "gateway.json"}, - {"type": "GATEWAY_CONNECTOR", "name": "Modbus Connector", "template": "modbus-connector.json"}, - {"type": "DASHBOARD", "name": "Modbus Monitor", "template": "dashboard.json"}, - {"type": "SHOW_INSTRUCTION", "name": "Launch Gateway", "file": "post-install.md"} - ] - } -} -``` - -`gateway.json`: -```json -{ - "name": "${deviceName} Gateway", - "type": "Gateway", - "additionalInfo": {"gateway": true} -} -``` - -`post-install.md`: -```markdown -## Launch Gateway - -1. [Download docker-compose.yml](${gateway.dockerComposeUrl}) -2. Place the file in a directory and run: - -\`\`\`bash -docker compose up -\`\`\` - -The gateway will connect to ThingsBoard at `${mqtt.host}:${mqtt.port}` using access token `${gateway.token}`. -``` - -## No Backend Changes - -All new logic is frontend-only: -- GATEWAY uses existing `saveDevice` API (same as DEVICE) -- GATEWAY_CONNECTOR uses existing `saveEntityAttributes` API -- Attribute saving uses existing `saveEntityAttributes` API -- `dockerComposeUrl` is a constructed URL string, not a new endpoint diff --git a/docs/superpowers/specs/2026-04-02-transport-variables-design.md b/docs/superpowers/specs/2026-04-02-transport-variables-design.md deleted file mode 100644 index e1cfe4bbc4..0000000000 --- a/docs/superpowers/specs/2026-04-02-transport-variables-design.md +++ /dev/null @@ -1,72 +0,0 @@ -# Transport Variables in Device Install Templates — Design Spec - -## Goal - -Add transport host/port variables to the device install wizard's template resolution so that post-install instructions and entity templates can reference the platform's configured transport endpoints (e.g., `${mqtt.host}`, `${coap.port}`). - -## Variables - -All 6 transport protocols are supported: - -| Variable | Example value | Source | -|----------|--------------|--------| -| `${http.host}` | `demo.thingsboard.io` | Admin settings `connectivity.http.host` | -| `${http.port}` | `8080` | Admin settings `connectivity.http.port` | -| `${https.host}` | `demo.thingsboard.io` | Admin settings `connectivity.https.host` | -| `${https.port}` | `443` | Admin settings `connectivity.https.port` | -| `${mqtt.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtt.host` | -| `${mqtt.port}` | `1883` | Admin settings `connectivity.mqtt.port` | -| `${mqtts.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtts.host` | -| `${mqtts.port}` | `8883` | Admin settings `connectivity.mqtts.port` | -| `${coap.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coap.host` | -| `${coap.port}` | `5683` | Admin settings `connectivity.coap.port` | -| `${coaps.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coaps.host` | -| `${coaps.port}` | `5684` | Admin settings `connectivity.coaps.port` | - -These variables are available in all template and instruction files, alongside form values and entity outputs. - -## Data Source - -Frontend fetches from existing admin settings API: -``` -GET /api/admin/settings/connectivity -``` - -Returns `DeviceConnectivitySettings` — a `Record` where each entry has `{ enabled, host, port }`. - -The `AdminService.getAdminSettings('connectivity')` method and `DeviceConnectivitySettings` model already exist in the codebase. - -## Resolution Priority - -The `resolveVariables()` function checks sources in this order: -1. Form field values (`formValues[key]`) — e.g., `${deviceName}` -2. Transport connectivity (`transportVars[key]`) — e.g., `${mqtt.host}` -3. Named entity outputs (`entityOutputs[alias].prop`) — e.g., `${device.id}` - -Transport variables use dot notation (`mqtt.host`) which currently falls through to entity output resolution. By adding a transport lookup before entity outputs, we ensure `${mqtt.host}` resolves to the transport config rather than looking for a non-existent entity alias called `mqtt`. - -## Changes - -**Single file:** `ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts` - -1. Inject `AdminService` (from `@core/http/admin.service`) -2. In `ngOnInit`, after ZIP parsing, fetch connectivity settings and flatten to `Record`: - ``` - { 'http.host': '...', 'http.port': '8080', 'mqtt.host': '...', 'mqtt.port': '1883', ... } - ``` -3. In `resolveVariables()`, check the transport map for dot-notation keys before falling through to entity outputs - -**No backend changes. No new models. No new endpoints.** - -## Example Usage in Templates - -Post-install instruction (`post-install.md`): -```markdown -#define THINGSBOARD_SERVER "${mqtt.host}" -#define THINGSBOARD_PORT ${mqtt.port} -``` - -Integration template (`integration.json`): -```json -{"baseUrl": "${http.host}:${http.port}"} -``` diff --git a/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md b/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md deleted file mode 100644 index 25251e7e3f..0000000000 --- a/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md +++ /dev/null @@ -1,102 +0,0 @@ -# Provisioning Conflict Resolution — Design Spec - -## Goal - -When an entity with the same name already exists during device package provisioning, show a conflict resolution UI instead of an error. The user chooses how to proceed (use existing, overwrite, or create copy) and provisioning continues. - -## Conflict Detection - -Before creating each entity, pre-check by name. If an entity with the same name exists, show the conflict UI instead of attempting creation. - -| Entity type | Pre-check | Conflict options | -|-------------|-----------|-----------------| -| DEVICE_PROFILE | `findDeviceProfileByName` | Use existing / Overwrite | -| RULE_CHAIN | `findRuleChainByName` | Use existing / Overwrite | -| DEVICE | `findDeviceByName` (new) | Use existing / Overwrite | -| GATEWAY | `findDeviceByName` (new, same API) | Use existing / Overwrite | -| DASHBOARD | `findDashboardByName` (new) | Overwrite / Create copy | -| GATEWAY_CONNECTOR | no pre-check | — | - -## New Status: `conflict` - -```typescript -export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; -``` - -`EntityStepProgress` gains: -```typescript -existingEntity?: EntityStepOutput; // the found entity, for resolution -conflictType?: 'use-or-overwrite' | 'overwrite-or-copy'; // which buttons to show -``` - -## Resolution Actions - -**Use existing**: take the existing entity's ID/name/url, store as output, continue to next step. No API call. - -**Overwrite**: fetch existing entity by ID, merge template data (keeping the ID), save. For rule chains, also save metadata. Store result as output. - -**Create copy**: create new entity with the template as-is (TB allows duplicate dashboard names). Store result as output. - -## UI - -Conflict row styling: -- Amber/yellow background (not red — it's a decision, not an error) -- Warning icon (amber `⚠`) -- Description: "Device profile with this name already exists" -- Two buttons on the right - -For `use-or-overwrite`: -``` -⚠ Device Profile — Modbus TH Sensor - Entity with this name already exists - [Use existing] [Overwrite] -``` - -For `overwrite-or-copy`: -``` -⚠ Dashboard — Modbus TH Monitor - Dashboard with this name already exists - [Overwrite] [Create copy] -``` - -Button styling: -- Use existing / Create copy: outlined primary (safe option) -- Overwrite: outlined warning/amber (destructive) - -## Provisioning Step Flow - -1. For each entity step: - a. Set status = 'running' - b. Pre-check: search for existing entity by name - c. If found: set status = 'conflict', store existing entity, pause (return from loop) - d. If not found: create entity → success → continue - e. If creation fails: set status = 'error' → pause (existing behavior) -2. When user clicks a conflict resolution button: - a. Execute resolution (use existing / overwrite / create copy) - b. Set status = 'success' - c. Resume `runEntitySteps` from the next step - -## Read-Only Review Dialog - -No changes needed — provisioning step shows all entities as success with "Done" label. No conflict UI in review mode. - -## Changes - -### Models (`device-package.models.ts`) -- Add `'conflict'` to `EntityStepStatus` -- Add `existingEntity?: EntityStepOutput` and `conflictType?: string` to `EntityStepProgress` - -### Dialog Component (`device-install-dialog.component.ts`) -- Add `findDeviceByName` and `findDashboardByName` methods -- Refactor `createEntity` to pre-check before creating -- Add `resolveConflict(ep, resolution)` method -- Resume loop after conflict resolution - -### Dialog Template (`device-install-dialog.component.html`) -- Add conflict row rendering with amber styling and resolution buttons - -### Dialog Styles (`device-install-dialog.component.scss`) -- Add `.tb-progress-conflict` amber styles - -### Translations -- Add conflict-related translation keys diff --git a/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md b/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md deleted file mode 100644 index fda5f3c451..0000000000 --- a/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md +++ /dev/null @@ -1,289 +0,0 @@ -# IoT Hub item deep link — design - -**Status:** approved -**Date:** 2026-04-22 -**Author:** Andrii Shvaika -**Scope:** ThingsBoard CE frontend + IoT Hub backend contract (external) - -## Summary - -Allow two shareable deep-link URL shapes: -- `http:///iot-hub/{itemId}` — opens the detail view for the latest published version of the item. -- `http:///iot-hub/version/{itemVersionId}` — opens a specific version snapshot. Published versions open directly; unpublished versions are gated behind a security warning. - -The first URL is the canonical "share this marketplace item" link. The second targets creator review workflows — stable snapshots of an exact draft. - -## Goals - -- Two shareable, bookmarkable deep link shapes with distinct semantics: - - `/iot-hub/{itemId}` — always latest published version of the item (404 if none published). - - `/iot-hub/version/{itemVersionId}` — specific version; warning gate when unpublished, then "Unpublished preview" badge in the detail dialog. -- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline. - -## Non-goals - -- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the UUID can fetch the content. -- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it. -- Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes). -- A "latest including drafts" shorthand shape (`/iot-hub/{itemId}/preview`). If creators want to preview a specific draft, they use the version URL with the exact versionId. - -## User flows - -### Published link: `/iot-hub/{itemId}` - -1. User pastes or clicks `/iot-hub/{itemId}`. -2. Angular mounts `TbIotHubItemResolverComponent`. -3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published` (new endpoint). -4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. -5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`. -6. On 404 (no published version exists) or other error, resolver shows a toast and redirects to `/iot-hub`. - -### Version link: `/iot-hub/version/{itemVersionId}` - -1. User pastes or clicks `/iot-hub/version/{itemVersionId}`. -2. Angular mounts `TbIotHubItemResolverComponent`. -3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint). -4. If the returned version is published → resolver navigates directly to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. -5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`). - - Cancel → `router.navigate(['/iot-hub'])`. - - "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge. -6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end. -7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`. - -## Angular routing - -Two routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). The literal `version/:itemVersionId` must come **before** the `:itemId` wildcard so the router matches the literal first: - -```ts -{ path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent, - data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } }, -{ path: ':itemId', component: TbIotHubItemResolverComponent, - data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } }, -``` - -The resolver branches on which param is present — `paramMap.get('itemVersionId')` signals the version-URL flow; `paramMap.get('itemId')` signals the published-URL flow. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page. - -## Components - -### `TbIotHubItemResolverComponent` (new) - -Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`. - -- Standalone: `false`. Declared in `IotHubModule`. -- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller. -- `ngOnInit`: - 1. Read `itemVersionId` and `itemId` from route params. Set `byVersion = itemVersionId != null`; pick the relevant id accordingly. - 2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. - 3. Dispatch to `getVersionInfo(id)` (byVersion) or `getPublishedVersion(id)` (published), both with `{ ignoreErrors: true }`. - 4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect. - 5. On success, call `handleResolved(version, byVersion)`: - - `byVersion` + version unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`. - - Otherwise → route directly to type-page with state (`preview: false`). -- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history. -- The published URL (`/iot-hub/{itemId}`) cannot surface unpublished content — `getPublishedVersion` only returns PUBLISHED versions — so the warning branch is unreachable for that flow. The `byVersion` gate in `handleResolved` makes this explicit. - -### `iot-hub-deep-link.utils.ts` (new) - -Shared helpers: - -```ts -export const isUUID = (s: string | null): s is string => !!s && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); - -export function typeSegment(t: ItemType): string | undefined { - switch (t) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboards'; - case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; - case ItemType.CALCULATED_FIELD: return 'calculated-fields'; - case ItemType.RULE_CHAIN: return 'rule-chains'; - case ItemType.DEVICE: return 'devices'; - default: return undefined; - } -} - -export function isPublished(v: MpItemVersionView): boolean { - return !!v.publishedTime && v.publishedTime > 0; -} -``` - -A `typeSegment` returning `undefined` (future `ItemType` values) surfaces as `iot-hub.deep-link-fetch-failed` in the resolver. - -### `TbIotHubUnpublishedWarningDialogComponent` (new) - -Location: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.{ts,html,scss}`. Declared in `IotHubComponentsModule`. - -- Title: translated `iot-hub.unpublished-warning-title` ("Unpublished content") with a red `warning` Material icon. -- Body: `iot-hub.unpublished-warning-text` paragraph ("This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator."). -- Secondary line: `{item.name} • v {item.version}` so the user sees what they are acknowledging. -- Buttons: - - `Cancel` — returns `false`. - - `iot-hub.unpublished-warning-confirm` ("I understand the risk, continue") — returns `true`, styled with the project's danger accent. -- Dialog config: `panelClass: ['tb-dialog']`, `disableClose: true`, `autoFocus: false`. -- `MAT_DIALOG_DATA` payload: `{ item: MpItemVersionView }`. - -Structural sibling of `TbIotHubDeleteDialogComponent`. - -### `TbIotHubItemDetailDialogComponent` (modified) - -- `IotHubItemDetailDialogData` gains optional `preview?: boolean`. -- Component stores `this.preview = data.preview === true` and exposes it to the template. -- Template adds a preview badge next to the existing version chip in the sticky meta bar: - - ```html -
- warning - {{ 'iot-hub.unpublished-preview' | translate }} -
- ``` - -- No other template or behavior changes. Install / Update / Remove / Open-entity actions are untouched. -- SCSS adds `.tb-unpublished-preview-badge` (red-on-light-red, matching the warning dialog accent). - -### `IotHubActionsService` (modified) - -```ts -openItemDetail( - item: MpItemVersionView, - installedItem?: IotHubInstalledItem, - installedItemsCount?: number, - mode?: IotHubItemDetailDialogMode, - showCreator?: boolean, - preview?: boolean -): Observable -``` - -`preview` is forwarded into `IotHubItemDetailDialogData`. All existing callers ignore the new parameter (undefined → non-preview). - -### `TbIotHubItemsPageComponent` (modified) - -`ngOnInit` is extended to consume `history.state.openItem` exactly once per navigation: - -```ts -private maybeOpenDeepLinkedItem(): void { - const openItem = history.state?.openItem as - { version: MpItemVersionView; preview?: boolean } | undefined; - if (!openItem || openItem.version.type !== this.config.type) return; - - history.replaceState({ ...history.state, openItem: undefined }, ''); - - this.resolveInstalledItem(openItem.version).subscribe(installed => { - this.iotHubActions.openItemDetail( - openItem.version, - installed, - installed ? 1 : 0, - 'default', - true, - openItem.preview - ).subscribe(result => this.handleDetailResult(result)); - }); -} - -private resolveInstalledItem(v: MpItemVersionView): Observable { - return this.iotHubApiService - .getInstalledItems(new PageLink(1), undefined, v.itemId) - .pipe(map(page => page.data[0] ?? null)); -} -``` - -`handleDetailResult` delegates to the same installed-count refresh logic already used by card clicks. The `history.replaceState` call clears `openItem` so a page refresh does not re-open the dialog from stale state. - -## API contract - -### `IotHubApiService` — one new method - -```ts -public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { - return this.http.get( - `${this.baseUrl}/api/items/${itemId}/published`, - { params: this.buildParams(config) } - ); -} -``` - -The version URL reuses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}`. Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast. - -### ThingsBoard backend - -Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; both deep-link flows funnel into them without modification. - -## i18n - -Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales): - -- `iot-hub.item-detail` — "IoT Hub item" -- `iot-hub.unpublished-warning-title` — "Unpublished content" -- `iot-hub.unpublished-warning-text` — "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator." -- `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue" -- `iot-hub.unpublished-preview` — "Unpublished preview" -- `iot-hub.deep-link-invalid-id` — "Invalid IoT Hub item link." -- `iot-hub.deep-link-not-found` — "This IoT Hub item doesn't exist or was removed." -- `iot-hub.deep-link-fetch-failed` — "Couldn't load IoT Hub item. Please try again." - -## Edge cases - -- **Invalid UUID shape** for either `itemId` or `itemVersionId` → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. -- **404 from IoT Hub**: - - Published URL: item has no published version, or item doesn't exist. Toast `iot-hub.deep-link-not-found` + redirect. - - Version URL: version doesn't exist or was removed. Same toast + redirect. -- **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect. -- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version. -- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`. -- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog. -- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed. -- **Deep link for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Both URL shapes produce identical behavior once the dialog is open. -- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected). - -## Testing - -- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`: - - published URL happy path (no warning, no badge) - - version URL happy path, version is published (no warning, no badge) - - version URL happy path, version is unpublished (warning → confirm → dialog with badge) - - version URL, warning cancel → redirect to `/iot-hub` - - invalid UUID (either param) - - 404 (both URL shapes) - - 5xx / network error - - unsupported `ItemType` -- `isPublished()` and `typeSegment()` unit tests. -- `TbIotHubUnpublishedWarningDialogComponent` component tests: renders item name/version; Cancel returns `false`; Confirm returns `true`. -- `TbIotHubItemsPageComponent.maybeOpenDeepLinkedItem` integration test with seeded `history.state`: asserts dialog opens, state is cleared, type mismatch is ignored. -- No ThingsBoard backend tests (no backend changes). - -## Files touched - -New: -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` - -Modified: -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` -- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` -- `ui-ngx/src/assets/locale/locale.constant-*.json` - -## IoT Hub-side changes required - -These live in the IoT Hub repository, not ThingsBoard CE. One new endpoint plus behavior/CORS contracts on the existing by-versionId family. - -1. **New endpoint** `GET /api/items/{itemId}/published` - - Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state. - - `404` when the item has no published version or doesn't exist. - - Anonymous cross-origin access (same CORS policy as `/api/versions/published`). - - Powers the `/iot-hub/{itemId}` deep link. -2. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate. -3. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today. -4. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB): - - `GET /api/versions/{versionId}/readme` - - `GET /api/versions/{versionId}/fileData` - - `POST /api/versions/{versionId}/install` -5. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests. -6. **CORS**: ensure `/api/items/{itemId}/published` and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin. diff --git a/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md b/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md deleted file mode 100644 index 38f7fe7895..0000000000 --- a/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md +++ /dev/null @@ -1,145 +0,0 @@ -# IoT Hub `${item-link:uuid}` Markdown Component — Design - -**Date:** 2026-05-06 -**Status:** Approved -**Branch:** `feature/iot-hub` - -## Summary - -Add an `${item-link:}` markdown placeholder that renders a small card -(thumbnail + name + creator) for any IoT Hub marketplace item, modeled on -the cards already shown in the home-page search popup. Used by IoT Hub -content authors to cross-link items from readme and install instructions. - -## Decisions - -| Question | Decision | -|---|---| -| UUID identifies | Marketplace **item ID** (stable across versions) | -| Render scope | Item readme + device install instructions + solution install instructions | -| Click behavior | Plain anchor → `/iot-hub/{itemId}` with `target="_blank"` | -| Syntax | `${item-link:}` — ID only, type derived from API response | -| Unavailable item handling | Render disabled card labeled "Item unavailable" | -| Resolution strategy | Render skeleton, fetch async, swap when ready (per card) | - -## Architecture - -Three building blocks, scoped tightly: - -1. **`replaceItemLinkPlaceholders(markdown: string): string`** — pure - string transform. Rewrites `${item-link:}` to - ``. -2. **`TbIotHubItemLinkCardComponent`** — Angular component that owns - fetch, state (`loading` / `loaded` / `unavailable`), and visual. -3. **`IotHubItemLinkModule`** — declares the component; passed as - `additionalCompileModules` on every `tb-markdown` instance that needs it. - -`tb-markdown` already supports compile-time injection of additional -modules; placeholder rewriting + module registration is all that is -needed to make the component render inside markdown. - -## Placeholder syntax - -- Format: `${item-link:}` -- Regex: strict 36-char UUID match - (`/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g`) -- Non-UUID payloads (typos) are left as-is in rendered output, so the - author sees the issue during preview. -- Placeholders inside fenced code blocks are still replaced — matches - the existing `prefixResourceUrls` / `resolveDocLinks` precedent in the - same files. - -## Integration points - -| File | Hook | -|---|---| -| `iot-hub-item-detail-dialog.component.ts` `loadReadme()` | Add as a step in the existing pipeline next to `prefixResourceUrls` and `resolveDocLinks` | -| `device-install-dialog.component.ts` `resolveVariables()` | Add an `^item-link:(uuid)$` branch alongside `gateway.downloadButton` and the callout matchers | -| `solution-install-dialog.component.ts` constructor | New step before assigning `this.details` | - -All three call the same backing helper. Each `tb-markdown` instance -in those templates gets `[additionalCompileModules]="[IotHubItemLinkModule]"`. - -## Component spec - -**Location:** -`ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/` - -**Inputs:** -- `@Input() itemId: string` - -**Lifecycle:** -- `ngOnInit()` calls - `iotHubApiService.getPublishedVersion(itemId, { ignoreErrors: true, ignoreLoading: true })`. -- Success → `state = 'loaded'`, store item. -- Error / 404 / network failure → `state = 'unavailable'`. - -**Template — three states:** - -- **loading:** skeleton card matching the loaded layout (gray thumb + - two text shimmer lines). -- **loaded:** anchor to `/iot-hub/{itemId}` (`target="_blank"`, - `rel="noopener noreferrer"`), thumbnail slot + name + creator row. -- **unavailable:** non-clickable card, 60 % opacity, `link_off` icon - in the thumbnail slot, label "Item unavailable" (translated). - -**Thumbnail rules** (mirror the search popup at -`iot-hub-home.component.html:103-115`): -- Compact types (`CALCULATED_FIELD`, `ALARM_RULE`, `RULE_CHAIN`): - colored square + `getCompactIcon()` + item color. -- Other types: image via `iotHubApiService.resolveResourceUrl(item.image)`, - fallback to a `mat-icon` of the type when no image is present. -- The same branching exists in `iot-hub-home.component.ts` (search popup) - and `iot-hub-item-detail-dialog.component.ts`. The new card duplicates - it locally — extracting a shared helper is out of scope for this PR. - -**Styling:** -- Component-scoped SCSS. -- Fixed width ~320 px, block-level (each card on its own line in markdown). -- Subtle CSS-keyframe shimmer for the skeleton state. -- Hover: matches search-popup card hover. - -**Click:** -- Plain anchor — middle-click / ctrl-click / "open in new tab" all work - natively. Existing route `/iot-hub/:itemId` - (`TbIotHubItemResolverComponent`) handles resolution, the - unpublished-version warning, and opening the detail dialog on the - type page. - -## Translations - -Add the following keys (and propagate to other locale files): - -| Key | English | -|---|---| -| `iot-hub.item-link-unavailable` | "Item unavailable" | - -## Out of scope - -- Changelog rendering (explicitly excluded). -- IoT Hub creator-side authoring helpers (placeholder is plain text in - raw markdown; nothing required on the IoT Hub backend). -- Batch endpoint for resolving multiple items in one request — N parallel - requests is fine for the expected 0–5 references per page. -- A non-block (inline) variant of the card. -- Hover preview / tooltip / type chip on the card itself. - -## Testing - -- Component unit tests: state transitions (loading → loaded, loading - → unavailable), thumbnail logic for compact vs. non-compact types, - fallback when image missing. -- Regex unit test for `replaceItemLinkPlaceholders` covering: valid - UUID, invalid UUID (left untouched), placeholder inside fenced code - (still replaced — documented behavior), multiple placeholders in one - document. -- Manual visual QA in all three render sites. - -## Risks / open items - -- `tb-markdown` recompiles on every `data` change; if a parent toggles - the markdown rapidly, the card re-fetches. Acceptable: readmes / - instructions don't churn during normal viewing. -- `getPublishedVersion` returns the **current** published version. If a - newer published version changes the name/thumbnail, links update - automatically — that is the documented behavior of the ID-only syntax.