diff --git a/.gitignore b/.gitignore index ee8c78dad3..833038e151 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ rebuild-docker.sh .run .claude .planning +docs/superpowers/ \ No newline at end of file diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql index 443b54ed78..b52d8ee14e 100644 --- a/application/src/main/data/upgrade/lts/schema_update.sql +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -32,4 +32,10 @@ CREATE TABLE IF NOT EXISTS iot_hub_installed_item ( descriptor JSONB NOT NULL ); +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_tenant_id ON iot_hub_installed_item(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_type ON iot_hub_installed_item(tenant_id, item_type); + +CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_id ON iot_hub_installed_item(tenant_id, item_id); + -- IOT HUB INSTALLED ITEM END diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java index 6b61c05a7a..201e48001e 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -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(); diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java index 2de1fc8635..c67fef55a8 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java @@ -16,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 response = restTemplate.getForEntity(url, byte[].class); - return response.getBody(); + // Stream the response so we can reject oversized payloads before + // they get loaded fully into memory: + // 1) refuse early if the server advertises a Content-Length + // larger than the configured cap; + // 2) refuse mid-stream once we have already buffered more than + // the cap (for chunked transfers without Content-Length). + final long limit = maxFileDataSizeBytes; + return restTemplate.execute(url, HttpMethod.GET, null, response -> { + long contentLength = response.getHeaders().getContentLength(); + if (contentLength > limit) { + throw new IllegalStateException("IoT Hub file data size " + contentLength + + " bytes exceeds the configured limit of " + limit + " bytes"); + } + int initialCapacity = contentLength > 0 && contentLength <= Integer.MAX_VALUE + ? (int) contentLength : 8192; + try (InputStream in = response.getBody(); + ByteArrayOutputStream out = new ByteArrayOutputStream(initialCapacity)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > limit) { + throw new IllegalStateException("IoT Hub file data stream exceeded the configured limit of " + + limit + " bytes"); + } + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read IoT Hub file data", e); + } + }); } public void reportVersionInstalled(String versionId) { diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java index b68fe5515c..9d13f15b96 100644 --- a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -269,7 +269,7 @@ public class DefaultSolutionService implements SolutionService { tsService.remove(tenantId, tenantId, queries).get(); } List attrKeys = descriptor.getTenantAttributeKeys(); - if (tsKeys != null && !tsKeys.isEmpty()) { + if (attrKeys != null && !attrKeys.isEmpty()) { attributesService.removeAll(tenantId, tenantId, AttributeScope.SERVER_SCOPE, attrKeys).get(); } } catch (Exception e) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index bfbb211c5c..97f14f7969 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2167,3 +2167,6 @@ mqtt: jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}" iot-hub: base-url: "${IOT_HUB_BASE_URL:https://iot-hub.thingsboard.io}" # IoT Hub base URL for fetching published items, resources, etc. + connect-timeout-sec: "${IOT_HUB_CONNECT_TIMEOUT_SEC:5}" # timeout in seconds to connect to IoT Hub server. + read-timeout-sec: "${IOT_HUB_READ_TIMEOUT_SEC:10}" # timeout in seconds to read from IoT Hub server. + max-file-data-size-bytes: "${IOT_HUB_MAX_FILE_DATA_SIZE_BYTES:104857600}" # maximum size in bytes of a file-data payload fetched from IoT Hub. Requests advertising a larger Content-Length, or streams that exceed this size, are rejected to avoid out-of-memory issues on the platform side. diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java index 2ad7d1d01a..22692f6ee4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.iot_hub; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; import org.thingsboard.server.common.data.page.PageData; @@ -23,10 +24,13 @@ import org.thingsboard.server.dao.Dao; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; public interface IotHubInstalledItemDao extends Dao { + Optional findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink); List findInstalledItemIdsByTenantId(TenantId tenantId); @@ -37,4 +41,6 @@ public interface IotHubInstalledItemDao extends Dao { void deleteByTenantId(TenantId tenantId); + boolean deleteByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java index c98f03d104..5bb857ca2c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -43,7 +43,7 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { @Override public IotHubInstalledItem findById(TenantId tenantId, IotHubInstalledItemId id) { - return iotHubInstalledItemDao.findById(tenantId, id.getId()); + return iotHubInstalledItemDao.findByTenantIdAndId(tenantId, id).orElse(null); } @Override @@ -68,7 +68,7 @@ class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { @Override public void deleteById(TenantId tenantId, IotHubInstalledItemId id) { - iotHubInstalledItemDao.removeById(tenantId, id.getId()); + iotHubInstalledItemDao.deleteByTenantIdAndId(tenantId, id); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java index 69f93cd5e7..8f06beaa39 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java @@ -25,10 +25,14 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.UUID; interface IotHubInstalledItemRepository extends JpaRepository { + Optional findByTenantIdAndId(UUID tenantId, UUID id); + @Query("SELECT DISTINCT item.itemId FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId") List findInstalledItemIdsByTenantId(@Param("tenantId") UUID tenantId); @@ -64,4 +68,9 @@ interface IotHubInstalledItemRepository extends JpaRepository ids); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java index b0db650ae0..9d61b13209 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; import org.thingsboard.server.common.data.page.PageData; @@ -35,6 +36,8 @@ import org.thingsboard.server.dao.util.SqlDao; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; @SqlDao @@ -44,6 +47,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao findByTenantIdAndId(TenantId tenantId, IotHubInstalledItemId installedItemId) { + return repository.findByTenantIdAndId(tenantId.getId(), installedItemId.getId()).map(DaoUtil::getData); + } + @Override public PageData findByTenantId(TenantId tenantId, List itemTypes, UUID itemId, PageLink pageLink) { return DaoUtil.toPageData(repository.findByTenantId( @@ -80,6 +88,11 @@ class JpaIotHubInstalledItemDao extends JpaAbstractDao 0; + } + private static PageRequest toPageRequest(PageLink pageLink) { Sort sort; SortOrder sortOrder = pageLink.getSortOrder(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index e7939ea32e..0045a1042e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -39,6 +39,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -83,7 +84,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog. - -**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. - -**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode. - -**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task. - -**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract. - ---- - -## File structure - -**Create:** -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles - -**Modify:** -- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail` -- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last) -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component -- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff -- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block - ---- - -### Task 1: Add i18n keys - -Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later). - -**Files:** -- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` - -- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block** - -Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key): - -```json - "item-detail": "IoT Hub item", - "item-preview": "IoT Hub item preview", - "unpublished-warning-title": "Unpublished content", - "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", - "unpublished-warning-confirm": "I understand the risk, continue", - "unpublished-preview": "Unpublished preview", - "deep-link-invalid-id": "Invalid IoT Hub item link.", - "deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", - "deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." -``` - -- [ ] **Step 2: Validate JSON** - -Run: -```bash -cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')" -``` -Expected output: `OK`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/assets/locale/locale.constant-en_US.json -git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning" -``` - ---- - -### Task 2: Deep-link utility module - -A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog. - -**Files:** -- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` - -- [ ] **Step 1: Create the file** - -Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with: - -```ts -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function isUUID(s: string | null | undefined): s is string { - return !!s && UUID_RE.test(s); -} - -export function typeSegment(t: ItemType): string | undefined { - switch (t) { - case ItemType.WIDGET: return 'widgets'; - case ItemType.DASHBOARD: return 'dashboards'; - case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; - case ItemType.CALCULATED_FIELD: return 'calculated-fields'; - case ItemType.RULE_CHAIN: return 'rule-chains'; - case ItemType.DEVICE: return 'devices'; - default: return undefined; - } -} - -export function isPublished(v: MpItemVersionView): boolean { - return !!v.publishedTime && v.publishedTime > 0; -} - -export interface DeepLinkOpenItem { - version: MpItemVersionView; - preview: boolean; -} -``` - -- [ ] **Step 2: Verify TypeScript compiles** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 -``` -Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts -git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)" -``` - ---- - -### Task 3: IoT Hub API service — two new methods - -Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`). - -**Files:** -- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts` - -- [ ] **Step 1: Add the two methods** - -Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100): - -```ts - public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { - return this.http.get( - `${this.baseUrl}/api/items/${itemId}/published`, - { params: this.buildParams(config) } - ); - } - - public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable { - return this.http.get( - `${this.baseUrl}/api/items/${itemId}/latest`, - { params: this.buildParams(config) } - ); - } -``` - -Use the exact `Edit` tool call: `old_string` should be the line ` public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable {` (and enough context around it), and `new_string` should prepend the two new methods above it. - -- [ ] **Step 2: Verify build** - -Run: -```bash -cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 -``` -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add ui-ngx/src/app/core/http/iot-hub-api.service.ts -git commit -m "feat(iot-hub): add getPublishedVersion and getLatestVersion API methods" -``` - ---- - -### Task 4: Unpublished warning dialog component - -New dialog styled after `TbIotHubDeleteDialogComponent`: title, description, item summary, Cancel + danger-accented confirm button. Returns `boolean` from `afterClosed`. - -**Files:** -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` -- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` -- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` - -- [ ] **Step 1: Create the component .ts** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`: - -```ts -/// -/// Copyright © 2016-2026 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { DialogComponent } from '@shared/components/dialog.component'; -import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; - -export interface IotHubUnpublishedWarningDialogData { - item: MpItemVersionView; -} - -@Component({ - selector: 'tb-iot-hub-unpublished-warning-dialog', - standalone: false, - templateUrl: './iot-hub-unpublished-warning-dialog.component.html', - styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss'] -}) -export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent { - - constructor( - protected store: Store, - protected router: Router, - protected dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData - ) { - super(store, router, dialogRef); - } - - confirm(): void { - this.dialogRef.close(true); - } - - cancel(): void { - this.dialogRef.close(false); - } -} -``` - -- [ ] **Step 2: Create the component .html** - -Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`: - -```html - -
-
- warning -

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

-
-

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

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

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

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

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

-

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

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