Browse Source

Merge branch 'feature/iot-hub' into add-related-items

pull/15682/head
dpinkevych 3 months ago
parent
commit
5e06d3fe5b
  1. 1
      .gitignore
  2. 6
      application/src/main/data/upgrade/lts/schema_update.sql
  3. 30
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  4. 65
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
  5. 2
      application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java
  6. 3
      application/src/main/resources/thingsboard.yml
  7. 6
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java
  8. 4
      dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java
  9. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java
  10. 13
      dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java
  11. 3
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  12. 6
      dao/src/main/resources/sql/schema-entities-idx.sql
  13. 939
      docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md
  14. 685
      docs/superpowers/plans/2026-04-27-install-method-sync.md
  15. 974
      docs/superpowers/plans/2026-05-06-iot-hub-item-link.md
  16. 267
      docs/superpowers/specs/2026-04-02-gateway-support-design.md
  17. 72
      docs/superpowers/specs/2026-04-02-transport-variables-design.md
  18. 102
      docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md
  19. 289
      docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md
  20. 145
      docs/superpowers/specs/2026-05-06-iot-hub-item-link.md
  21. 19
      ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts
  22. 2
      ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts
  23. 4
      ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts
  24. 50
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts
  25. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  26. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html
  27. 20
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss
  28. 55
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts
  29. 22
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts
  30. 6
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  31. 50
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  32. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html
  33. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss
  34. 24
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts
  35. 2
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss
  36. 27
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts
  37. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts
  38. 8
      ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts
  39. 8
      ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts
  40. 8
      ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts
  41. 8
      ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts
  42. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  43. 21
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  44. 80
      ui-ngx/src/app/shared/directives/autocomplete-auto-scroll-reposition.directive.ts
  45. 23
      ui-ngx/src/app/shared/directives/chip-overflow.directive.ts
  46. 53
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts
  47. 17
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts
  48. 7
      ui-ngx/src/app/shared/shared.module.ts

1
.gitignore

@ -39,3 +39,4 @@ rebuild-docker.sh
.run
.claude
.planning
docs/superpowers/

6
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

30
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -115,6 +115,12 @@ public class DefaultIotHubService implements IotHubService {
public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) {
TenantId tenantId = user.getTenantId();
log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId);
JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
if (versionInfo == null) {
throw new IllegalArgumentException("Failed to get version info from IoT Hub");
}
try {
IotHubInstalledItem installedItem = doInstallVersion(user, versionId, data, request);
return InstallItemVersionResult.success(installedItem.getDescriptor());
@ -346,6 +352,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);
@ -361,6 +370,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();
@ -386,6 +405,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());
@ -482,10 +503,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) {
@ -575,6 +592,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();

65
application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java

@ -16,25 +16,49 @@
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.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
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);
@ -61,8 +85,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<byte[]> 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) {

2
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<String> 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) {

3
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.

6
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<IotHubInstalledItem> {
Optional<IotHubInstalledItem> findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId);
PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink);
List<UUID> findInstalledItemIdsByTenantId(TenantId tenantId);
@ -37,4 +41,6 @@ public interface IotHubInstalledItemDao extends Dao<IotHubInstalledItem> {
void deleteByTenantId(TenantId tenantId);
boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId);
}

4
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

9
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<IotHubInstalledItemEntity, UUID> {
Optional<IotHubInstalledItemEntity> findByTenantIdAndId(UUID tenantId, UUID id);
@Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId")
List<UUID> findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId);
@ -64,4 +68,9 @@ interface IotHubInstalledItemRepository extends JpaRepository<IotHubInstalledIte
@Query(value = "DELETE FROM iot_hub_installed_item WHERE tenant_id = :tenantId", nativeQuery = true)
void deleteByTenantId(@Param("tenantId") UUID tenantId);
@Transactional
@Modifying
@Query("DELETE FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId AND item.id IN (:ids)")
int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set<UUID> ids);
}

13
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<IotHubInstalledItemEntity
private final IotHubInstalledItemRepository repository;
@Override
public Optional<IotHubInstalledItem> findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) {
return repository.findByTenantIdAndId(tenantId.getId(), installedItemId.getId()).map(DaoUtil::getData);
}
@Override
public PageData<IotHubInstalledItem> findByTenantId(TenantId tenantId, List<String> itemTypes, UUID itemId, PageLink pageLink) {
return DaoUtil.toPageData(repository.findByTenantId(
@ -80,6 +88,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao<IotHubInstalledItemEntity
repository.deleteByTenantId(tenantId.getId());
}
@Override
public boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) {
return repository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(installedItemId.getId())) > 0;
}
private static PageRequest toPageRequest(PageLink pageLink) {
Sort sort;
SortOrder sortOrder = pageLink.getSortOrder();

3
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<TenantId, Ten
@Autowired
private TrendzSettingsService trendzSettingsService;
@Autowired
private org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService iotHubInstalledItemService;
private IotHubInstalledItemService iotHubInstalledItemService;
@Autowired
private TenantDataValidator tenantValidator;
@Autowired

6
dao/src/main/resources/sql/schema-entities-idx.sql

@ -117,3 +117,9 @@ CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tena
CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id);
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);

939
docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md

@ -1,939 +0,0 @@
# IoT Hub item deep link — 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:** 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<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${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<string> {` (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<TbIotHubUnpublishedWarningDialogComponent, boolean> {
constructor(
protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TbIotHubUnpublishedWarningDialogComponent, boolean>,
@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
<!--
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.
-->
<div class="tb-iot-hub-warning-content">
<div class="tb-iot-hub-warning-title-row">
<mat-icon class="tb-iot-hub-warning-icon">warning</mat-icon>
<h2 class="tb-iot-hub-warning-title">{{ 'iot-hub.unpublished-warning-title' | translate }}</h2>
</div>
<p class="tb-iot-hub-warning-text">{{ 'iot-hub.unpublished-warning-text' | translate }}</p>
<div class="tb-iot-hub-warning-item">
<span class="tb-iot-hub-warning-item-name">{{ data.item.name }}</span>
<span class="tb-iot-hub-warning-item-version">v {{ data.item.version }}</span>
</div>
</div>
<div class="tb-iot-hub-warning-actions">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="warn" (click)="confirm()">{{ 'iot-hub.unpublished-warning-confirm' | translate }}</button>
</div>
```
- [ ] **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 `<span class="dlg-subtitle-group">` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `</span>` of that version group (just before the `@if (item.publishedTime)` block), insert:
```html
@if (preview) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group tb-unpublished-preview-badge">
<mat-icon class="dlg-subtitle-icon">warning</mat-icon>
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span>
</span>
}
```
- [ ] **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<any> {
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<AppState>,
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<IotHubInstalledItem | null> {
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.

685
docs/superpowers/plans/2026-04-27-install-method-sync.md

@ -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<string, string>(` declaration):
```typescript
export const installMethodLabels = new Map<string, string>(
[
[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<string, string>(
[
// 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<string> = new Set<string>([
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 `<!-- Connectivity selector -->`). The structure is:
```html
@if (!wizardStarted) {
<!-- Connectivity selector -->
<div mat-dialog-content>
...
</div>
<mat-dialog-actions align="end">
...
</mat-dialog-actions>
} @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 `<!-- Connectivity selector -->` content and the closing `</mat-dialog-actions>`) with:
```html
@if (!wizardStarted && !showPeOnlyPanel) {
<!-- Connectivity selector -->
<div mat-dialog-content>
<div class="tb-device-install-connectivity flex flex-col gap-3">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
<div class="flex flex-wrap gap-2">
@for (ct of availableInstallMethods; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
[class.selected]="selectedInstallMethod === ct"
(click)="selectConnectivity(ct)">
{{ installMethodLabels.get(ct) || ct }}
</button>
}
</div>
</div>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-stroked-button color="primary"
[disabled]="!selectedInstallMethod"
(click)="confirmConnectivity()">
{{ 'action.next' | translate }}
</button>
</mat-dialog-actions>
} @else if (showPeOnlyPanel) {
<!-- PE-only gate -->
<div mat-dialog-content>
<div class="tb-device-install-pe-only flex flex-col items-center gap-3 py-6 text-center">
<mat-icon class="tb-device-install-pe-only-icon">workspace_premium</mat-icon>
<h3 class="tb-device-install-pe-only-title">
{{ 'iot-hub.device-install-pe-only-title' | translate }}
</h3>
<p class="tb-device-install-pe-only-message">
{{ 'iot-hub.device-install-pe-only-message' | translate:{ method: installMethodLabels.get(selectedInstallMethod) || selectedInstallMethod } }}
</p>
<a mat-stroked-button color="primary"
href="https://thingsboard.io/products/thingsboard-pe/"
target="_blank" rel="noopener">
{{ 'iot-hub.device-install-pe-only-learn-more' | translate }}
</a>
</div>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">{{ 'action.close' | translate }}</button>
</mat-dialog-actions>
} @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.

974
docs/superpowers/plans/2026-05-06-iot-hub-item-link.md

@ -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:<uuid>}` 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:<uuid>}` to a `<tb-iot-hub-item-link-card itemId="...">` 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 `<tb-markdown>`
- `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 `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`;
}
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) => '<TAG itemId=' + u + '>'));
}
```
Run it:
```bash
node /tmp/check-item-link-regex.js
```
Expected output:
```
valid: <TAG itemId=11111111-2222-3333-4444-555555555555>
invalid: ${item-link:not-a-uuid}
two valid: Two: <TAG itemId=11111111-2222-3333-4444-555555555555> and <TAG itemId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee>
inside code fence (still replaced — documented behavior): ```
<TAG itemId=11111111-2222-3333-4444-555555555555>
```
```
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<ItemType> = 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
<!--
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.
-->
@switch (state) {
@case ('loading') {
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--skeleton" aria-busy="true">
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-skeleton-block"></div>
<div class="tb-iot-hub-item-link-text">
<span class="tb-iot-hub-item-link-skeleton-line"></span>
<span class="tb-iot-hub-item-link-skeleton-line tb-iot-hub-item-link-skeleton-line--short"></span>
</div>
</div>
}
@case ('loaded') {
<a [href]="getHref()"
target="_blank"
rel="noopener noreferrer"
class="tb-iot-hub-item-link-card">
@if (isCompact()) {
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--compact"
[style.background]="getCompactColor()">
<tb-icon class="tb-iot-hub-item-link-thumb-icon">{{ getCompactIcon() }}</tb-icon>
</div>
} @else {
<div class="tb-iot-hub-item-link-thumb">
@if (getImageUrl(); as imgUrl) {
<img [src]="imgUrl" alt="">
} @else {
<mat-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</mat-icon>
}
</div>
}
<div class="tb-iot-hub-item-link-text">
<span class="tb-iot-hub-item-link-name">{{ item?.name }}</span>
<span class="tb-iot-hub-item-link-author">
<mat-icon>person</mat-icon>
{{ item?.creatorDisplayName }}
</span>
</div>
</a>
}
@case ('unavailable') {
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--unavailable">
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--unavailable">
<mat-icon>link_off</mat-icon>
</div>
<div class="tb-iot-hub-item-link-text">
<span class="tb-iot-hub-item-link-name">{{ 'iot-hub.item-link-unavailable' | translate }}</span>
</div>
</div>
}
}
```
- [ ] **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<any>[] = [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
<tb-markdown [data]="readmeContent"></tb-markdown>
```
to:
```html
<tb-markdown [data]="readmeContent"
[additionalCompileModules]="itemLinkCompileModules"></tb-markdown>
```
Leave the changelog `<tb-markdown>` (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<any>[] = [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 '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>';
}
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)}
```
to:
```typescript
// Special action placeholders
if (key === 'gateway.downloadButton') {
return '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>';
}
// IoT Hub item link card: ${item-link:<uuid>}
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
<tb-markdown [data]="ws.markdown"
(ready)="onMarkdownReady(instructionContainer)">
</tb-markdown>
```
to:
```html
<tb-markdown [data]="ws.markdown"
[additionalCompileModules]="itemLinkCompileModules"
(ready)="onMarkdownReady(instructionContainer)">
</tb-markdown>
```
- [ ] **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<any>[] = [IotHubItemLinkModule];
constructor(
@Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData,
private dialogRef: MatDialogRef<SolutionInstallDialogComponent>,
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
<tb-markdown [data]="details" lineNumbers fallbackToPlainMarkdown></tb-markdown>
```
to:
```html
<tb-markdown [data]="details"
[additionalCompileModules]="itemLinkCompileModules"
lineNumbers
fallbackToPlainMarkdown></tb-markdown>
```
- [ ] **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:<knownItemId>}` somewhere in the readme markdown.
- Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:<knownItemId>}\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/<itemId>` 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 `<a>`, 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:<knownItemId>}` 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/<itemId>` 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:<knownItemId>}` 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/<itemId>` 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).

267
docs/superpowers/specs/2026-04-02-gateway-support-design.md

@ -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

72
docs/superpowers/specs/2026-04-02-transport-variables-design.md

@ -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<DeviceConnectivityProtocol, DeviceConnectivityInfo>` where each entry has `{ enabled, host, port }`.
The `AdminService.getAdminSettings<DeviceConnectivitySettings>('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<string, string>`:
```
{ '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}"}
```

102
docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md

@ -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

289
docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md

@ -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://<tb-host>/iot-hub/{itemId}` — opens the detail view for the latest published version of the item.
- `http://<tb-host>/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
<div class="tb-unpublished-preview-badge" *ngIf="preview">
<mat-icon>warning</mat-icon>
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span>
</div>
```
- 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<any>
```
`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<IotHubInstalledItem | null> {
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<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${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.

145
docs/superpowers/specs/2026-05-06-iot-hub-item-link.md

@ -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:<uuid>}` 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:<uuid>}` — 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:<uuid>}` to
`<tb-iot-hub-item-link-card itemId="<uuid>"></tb-iot-hub-item-link-card>`.
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:<uuid>}`
- 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.

19
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;
}
}));
}

2
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;
}

4
ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts

@ -117,6 +117,10 @@ export abstract class EntityTabsComponent<T extends BaseData<HasId>,
);
}
resolveTabIndex(tab: string): number {
return 0;
}
protected setEntity(entity: T) {
this.entityValue = entity;
}

50
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.models.ts

@ -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<string, EntityType> = {
'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;
}

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts

@ -25,6 +25,7 @@ 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 {
getInstalledItemUrl,
InstallPlan,
InstallPlanEntry,
InstallPlanEntryStatus,
@ -36,7 +37,6 @@ 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';
@ -369,7 +369,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
}
return;
}
this.entityDetailsUrl = resolveEntityDetailsUrl(descriptor, this.item.type);
this.entityDetailsUrl = getInstalledItemUrl(descriptor);
this.state = partial ? 'partial' : 'success';
}

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html

@ -29,7 +29,7 @@
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-type' | translate }}</th>
<td mat-cell *matCellDef="let item">
<span class="tb-installed-type" [ngClass]="getItemTypeChipClass(item.itemType)">
<mat-icon class="tb-installed-type-icon">{{ getItemTypeIcon(item.itemType) }}</mat-icon>
<tb-icon class="tb-installed-type-icon">{{ getItemTypeIcon(item.itemType) }}</tb-icon>
{{ getItemTypeLabel(item.itemType) }}
</span>
</td>
@ -44,7 +44,7 @@
<!-- Installed date -->
<ng-container matColumnDef="createdTime">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th>
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'mediumDate' }}</td>
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'MMM d, y, h:mm a' }}</td>
</ng-container>
<!-- Updates -->

20
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 {
@ -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 {

55
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts

@ -40,13 +40,12 @@ import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
import {
DeviceInstalledItemDescriptor,
getInstalledItemUrl,
IotHubInstalledItem,
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 { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils';
import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
@Component({
@ -153,16 +152,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 {
@ -218,42 +208,11 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
});
}
getEntityId(item: IotHubInstalledItem): string | null {
const descriptor = item.descriptor;
switch (descriptor.type) {
case 'WIDGET': return descriptor.widgetTypeId?.id;
case 'DASHBOARD': return descriptor.dashboardId?.id;
case 'CALCULATED_FIELD': return descriptor.entityId?.id;
case 'ALARM_RULE': return descriptor.entityId?.id;
case 'RULE_CHAIN': return descriptor.ruleChainId?.id;
case 'DEVICE': return descriptor.dashboardId?.id ?? null;
case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id;
default: return null;
}
}
getEntityType(item: IotHubInstalledItem): EntityType | null {
const descriptor = item.descriptor;
switch (descriptor.type) {
case 'WIDGET': return EntityType.WIDGET_TYPE;
case 'DASHBOARD': return EntityType.DASHBOARD;
case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType;
case 'ALARM_RULE': return descriptor.entityId?.entityType as EntityType;
case 'RULE_CHAIN': return EntityType.RULE_CHAIN;
case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null;
case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD;
default: return null;
}
}
openEntity(item: IotHubInstalledItem): void {
const entityType = this.getEntityType(item);
const entityId = this.getEntityId(item);
if (entityType && entityId) {
const url = getEntityDetailsPageURL(entityId, entityType);
if (url) {
window.open(this.router.serializeUrl(this.router.parseUrl(url)), '_blank');
}
const url = getInstalledItemUrl(item?.descriptor);
if (url) {
const urlTree = this.router.parseUrl(url);
window.open(this.router.serializeUrl(urlTree), '_blank');
}
}

22
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);
}
}

6
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) {
<div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.connectivity' | translate }}</span>
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2">
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2" showOverflowedTitle>
@for (conn of item.dataDescriptor.connectivity; track conn) {
<span class="dlg-chip dlg-chip-category">{{ conn }}</span>
}
@ -214,7 +214,7 @@
@if (item.categories?.length) {
<div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.category' | translate }}</span>
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2">
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-category" [gap]="4" [minChips]="2" showOverflowedTitle>
@for (cat of item.categories; track cat) {
<span class="dlg-chip dlg-chip-category">{{ cat }}</span>
}
@ -225,7 +225,7 @@
@if (item.useCases?.length) {
<div class="dlg-meta-group dlg-meta-details-section">
<span class="dlg-meta-label">{{ 'iot-hub.use-cases' | translate }}</span>
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-usecase" [gap]="4">
<div class="dlg-meta-chips" tb-chip-overflow chipSelector=".dlg-chip" overflowClass="dlg-chip dlg-chip-usecase" [gap]="4" showOverflowedTitle>
@for (uc of item.useCases; track uc) {
<span class="dlg-chip dlg-chip-usecase">{{ uc }}</span>
}

50
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts

@ -21,12 +21,10 @@ 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 { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { getItemTypeIcon, ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { getInstalledItemUrl, 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';
import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils';
import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
@ -103,16 +101,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
getTypeIcon(): 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 '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);
}
getCompactIcon(): string {
@ -121,15 +110,15 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
if (this.item.type === ItemType.CALCULATED_FIELD) {
const cfType = this.item.dataDescriptor?.cfType;
return cfTypeIcons.get(cfType) || 'functions';
return cfTypeIcons.get(cfType) || getItemTypeIcon(ItemType.CALCULATED_FIELD);
}
if (this.item.type === ItemType.ALARM_RULE) {
return 'notification_important';
return getItemTypeIcon(ItemType.ALARM_RULE);
}
switch (this.item.dataDescriptor?.ruleChainType) {
case 'CORE': return 'device_hub';
case 'EDGE': return 'router';
default: return 'account_tree';
default: return getItemTypeIcon(ItemType.RULE_CHAIN);
}
}
@ -208,29 +197,10 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
openEntityDetails(): void {
const descriptor = this.installedItem?.descriptor;
if (!descriptor) {
return;
}
let entityId: string | null = null;
let entityType: EntityType | 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;
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) {
const url = getEntityDetailsPageURL(entityId, entityType);
if (url) {
this.dialogRef.close();
void this.router.navigateByUrl(url);
}
const url = getInstalledItemUrl(this.installedItem?.descriptor);
if (url) {
this.dialogRef.close();
void this.router.navigateByUrl(url);
}
}

8
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html

@ -40,14 +40,18 @@
@if (getImageUrl(); as imgUrl) {
<img [src]="imgUrl" alt="">
} @else {
<mat-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</mat-icon>
<tb-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</tb-icon>
}
</div>
}
<div class="tb-iot-hub-item-link-text">
<span class="tb-iot-hub-item-link-name">{{ item.name }}</span>
<span class="tb-iot-hub-item-link-author">
<mat-icon>person</mat-icon>
@if (item.creatorVerified) {
<mat-icon class="tb-iot-hub-item-link-verified-icon">verified</mat-icon>
} @else {
<mat-icon>person</mat-icon>
}
{{ item.creatorDisplayName }}
</span>
</div>

8
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,

24
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 {

2
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;

27
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 <a>. Skip links whose
// text already ends with the suffix and skip image syntax
// (`![alt](url)`).
// 2) For raw <a ...> 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(
/(?<!!)\[([^\]]*?)]\(([^)]+)\)/g,
(match, text: string, url: string) =>
text.endsWith(TARGET_BLANK_BLOCK) ? match : `[${text}${TARGET_BLANK_BLOCK}](${url})`
);
content = content.replace(
/<a\b([^>]*)>/gi,
(match, attrs: string) =>
/\btarget\s*=/i.test(attrs) ? match : `<a${attrs} target="_blank">`
);
return content;
}
private prefixResourceUrls(markdown: string): string {
const baseUrl = this.iotHubApiService.baseUrl;
return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`);

8
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts

@ -21,11 +21,13 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DialogComponent } from '@shared/components/dialog.component';
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 { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { resolveEntityDetailsUrl } from './iot-hub-components.models';
import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component';
export interface IotHubUpdateDialogData {
@ -86,7 +88,7 @@ export class TbIotHubUpdateDialogComponent extends DialogComponent<TbIotHubUpdat
});
} else {
this.state = 'success';
this.entityDetailsUrl = resolveEntityDetailsUrl(result.descriptor, this.data.itemType);
this.entityDetailsUrl = getInstalledItemUrl(result.descriptor);
}
} else if (result.entityModified) {
this.state = 'confirm';

8
ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts

@ -36,4 +36,12 @@ export class AssetProfileTabsComponent extends EntityTabsComponent<AssetProfile>
super.ngOnInit();
}
resolveTabIndex(tab: string): number {
if (tab === 'cf') {
return 1;
} else {
return super.resolveTabIndex(tab);
}
}
}

8
ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts

@ -36,4 +36,12 @@ export class AssetTabsComponent extends EntityTabsComponent<AssetInfo> {
super.ngOnInit();
}
resolveTabIndex(tab: string): number {
if (tab === 'cf') {
return 3;
} else {
return super.resolveTabIndex(tab);
}
}
}

8
ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts

@ -56,6 +56,14 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent<DeviceProfil
});
}
resolveTabIndex(tab: string): number {
if (tab === 'cf') {
return 2;
} else {
return super.resolveTabIndex(tab);
}
}
protected setEntity(entity: DeviceProfile) {
this.isTransportTypeChanged = false;
super.setEntity(entity);

8
ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts

@ -36,4 +36,12 @@ export class DeviceTabsComponent extends EntityTabsComponent<DeviceInfo> {
super.ngOnInit();
}
resolveTabIndex(tab: string): number {
if (tab === 'cf') {
return 3;
} else {
return super.resolveTabIndex(tab);
}
}
}

2
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html

@ -109,7 +109,7 @@
@if (getItemImage(item); as imgUrl) {
<img [src]="imgUrl" alt="">
} @else {
<mat-icon class="tb-search-popup-thumb-placeholder">{{ getItemTypeIcon(item.type) }}</mat-icon>
<tb-icon class="tb-search-popup-thumb-placeholder">{{ getItemTypeIcon(item.type) }}</tb-icon>
}
</div>
}

21
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 {

80
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<HTMLElement>);
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');
}
}
}
}

23
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');
}
}
}

53
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';
@ -128,3 +130,54 @@ export interface IotHubInstalledItem extends BaseData<{id: string}> {
version: string;
descriptor: IotHubInstalledItemDescriptor;
}
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 'DEVICE':
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;
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;
}

17
ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts

@ -36,6 +36,23 @@ export const itemTypeTranslations = new Map<ItemType, string>(
]
);
// 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<string, string> = {
[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).

7
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,

Loading…
Cancel
Save