From d8308be53aae5b882485571198faefefe0adb475 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 9 Mar 2026 18:49:29 +0200 Subject: [PATCH 001/219] Add IoT Hub browse page integration Propagate IoT Hub marketplace browse page to ThingsBoard, allowing TENANT_ADMIN users to discover and install community widgets, dashboards, rule chains, calculated fields, and device profiles. Backend: - Add iot-hub.base-url config (default: https://iot-hub.thingsboard.io) - Add IotHubController with POST /api/iot-hub/versions/{id}/install - Add IotHubService with per-type install logic (widget, dashboard, etc.) - Add IotHubRestClient for IoT Hub public API communication - Expose iotHubBaseUrl via SystemParams Frontend: - Add IoT Hub browse page with search, filtering, sorting, pagination - Add item detail dialog with readme rendering - Add install dialog calling backend install endpoint - Add creator profile page - Add IotHubApiService with dynamic base URL from NgRx auth state - Add IoT Hub menu entry for TENANT_ADMIN users - Add i18n translations for iot-hub, item, item-data sections --- .../server/controller/IotHubController.java | 48 ++ .../controller/SystemInfoController.java | 4 + .../service/iot_hub/DefaultIotHubService.java | 128 ++++++ .../service/iot_hub/IotHubRestClient.java | 54 +++ .../server/service/iot_hub/IotHubService.java | 24 + .../src/main/resources/thingsboard.yml | 2 + .../server/common/data/SystemParams.java | 1 + ui-ngx/src/app/core/auth/auth.models.ts | 1 + ui-ngx/src/app/core/auth/auth.reducer.ts | 3 +- .../src/app/core/http/iot-hub-api.service.ts | 126 ++++++ ui-ngx/src/app/core/services/menu.models.ts | 14 +- .../modules/home/pages/home-pages.module.ts | 2 + .../iot-hub/iot-hub-browse.component.html | 215 +++++++++ .../iot-hub/iot-hub-browse.component.scss | 400 +++++++++++++++++ .../pages/iot-hub/iot-hub-browse.component.ts | 388 ++++++++++++++++ .../iot-hub-creator-profile.component.html | 82 ++++ .../iot-hub-creator-profile.component.scss | 119 +++++ .../iot-hub-creator-profile.component.ts | 70 +++ .../iot-hub-install-dialog.component.ts | 123 +++++ .../iot-hub/iot-hub-item-card.component.html | 107 +++++ .../iot-hub/iot-hub-item-card.component.scss | 351 +++++++++++++++ .../iot-hub/iot-hub-item-card.component.ts | 204 +++++++++ .../iot-hub-item-detail-dialog.component.html | 188 ++++++++ .../iot-hub-item-detail-dialog.component.scss | 419 ++++++++++++++++++ .../iot-hub-item-detail-dialog.component.ts | 246 ++++++++++ .../pages/iot-hub/iot-hub-routing.module.ts | 63 +++ .../home/pages/iot-hub/iot-hub.module.ts | 41 ++ .../models/iot-hub/iot-hub-creator.models.ts | 29 ++ .../models/iot-hub/iot-hub-item.models.ts | 174 ++++++++ .../models/iot-hub/iot-hub-version.models.ts | 153 +++++++ .../assets/locale/locale.constant-en_US.json | 126 +++++- 31 files changed, 3902 insertions(+), 3 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/IotHubController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java create mode 100644 ui-ngx/src/app/core/http/iot-hub-api.service.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts create mode 100644 ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts create mode 100644 ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts create mode 100644 ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java new file mode 100644 index 0000000000..eef17d8c96 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -0,0 +1,48 @@ +/** + * 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. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.iot_hub.IotHubService; + +@Hidden +@RestController +@TbCoreComponent +@RequestMapping("/api/iot-hub") +@RequiredArgsConstructor +@Slf4j +public class IotHubController extends BaseController { + + private final IotHubService iotHubService; + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/versions/{versionId}/install") + @ResponseBody + public JsonNode installItemVersion(@PathVariable String versionId) throws Exception { + return iotHubService.installItemVersion(getCurrentUser(), versionId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index 61240d304e..40e88a4c6e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${iot-hub.base-url:https://iot-hub.thingsboard.io}") + private String iotHubBaseUrl; + @Autowired(required = false) private BuildProperties buildProperties; @@ -164,6 +167,7 @@ public class SystemInfoController extends BaseController { systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg()); systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId())); } + systemParams.setIotHubBaseUrl(iotHubBaseUrl); systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .orElse(false)); 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 new file mode 100644 index 0000000000..173ae79020 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -0,0 +1,128 @@ +/** + * 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. + */ +package org.thingsboard.server.service.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChainData; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; +import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; +import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; +import org.thingsboard.server.service.rule.TbRuleChainService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.dao.rule.RuleChainService; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultIotHubService implements IotHubService { + + private final IotHubRestClient iotHubRestClient; + private final TbWidgetTypeService tbWidgetTypeService; + private final TbDashboardService tbDashboardService; + private final TbCalculatedFieldService tbCalculatedFieldService; + private final RuleChainService ruleChainService; + private final TbRuleChainService tbRuleChainService; + private final TbDeviceProfileService tbDeviceProfileService; + + @Override + public JsonNode installItemVersion(SecurityUser user, String versionId) throws Exception { + TenantId tenantId = user.getTenantId(); + log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId); + + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemType = versionInfo.get("type").asText(); + String itemName = versionInfo.get("name").asText(); + log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType); + + byte[] fileData = iotHubRestClient.getVersionFileData(versionId); + log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); + + switch (itemType) { + case "WIDGET": + installWidget(user, tenantId, fileData); + break; + case "DASHBOARD": + installDashboard(user, tenantId, fileData); + break; + case "CALCULATED_FIELD": + installCalculatedField(user, tenantId, fileData); + break; + case "RULE_CHAIN": + installRuleChain(tenantId, fileData); + break; + case "DEVICE": + installDeviceProfile(user, tenantId, fileData); + break; + default: + throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType); + } + + iotHubRestClient.reportVersionInstalled(versionId); + log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); + + return versionInfo; + } + + private void installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + WidgetTypeDetails widgetTypeDetails = JacksonUtil.fromBytes(fileData, WidgetTypeDetails.class); + widgetTypeDetails.setId(null); + widgetTypeDetails.setTenantId(tenantId); + tbWidgetTypeService.save(widgetTypeDetails, true, user); + log.debug("[{}] Widget installed: {}", tenantId, widgetTypeDetails.getName()); + } + + private void installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + Dashboard dashboard = JacksonUtil.fromBytes(fileData, Dashboard.class); + dashboard.setId(null); + dashboard.setTenantId(tenantId); + tbDashboardService.save(dashboard, user); + log.debug("[{}] Dashboard installed: {}", tenantId, dashboard.getTitle()); + } + + private void installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + CalculatedField calculatedField = JacksonUtil.fromBytes(fileData, CalculatedField.class); + calculatedField.setId(null); + calculatedField.setTenantId(tenantId); + tbCalculatedFieldService.save(calculatedField, user); + log.debug("[{}] Calculated field installed: {}", tenantId, calculatedField.getName()); + } + + private void installRuleChain(TenantId tenantId, byte[] fileData) { + RuleChainData ruleChainData = JacksonUtil.fromBytes(fileData, RuleChainData.class); + ruleChainService.importTenantRuleChains(tenantId, ruleChainData, false, tbRuleChainService::updateRuleNodeConfiguration); + log.debug("[{}] Rule chain(s) installed", tenantId); + } + + private void installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + DeviceProfile deviceProfile = JacksonUtil.fromBytes(fileData, DeviceProfile.class); + deviceProfile.setId(null); + deviceProfile.setTenantId(tenantId); + tbDeviceProfileService.save(deviceProfile, user); + log.debug("[{}] Device profile installed: {}", tenantId, deviceProfile.getName()); + } +} 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 new file mode 100644 index 0000000000..1155bcf0ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java @@ -0,0 +1,54 @@ +/** + * 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. + */ +package org.thingsboard.server.service.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@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; + + public JsonNode getVersionInfo(String versionId) { + String url = baseUrl + "/api/versions/" + versionId; + log.debug("Fetching IoT Hub version info: {}", url); + return restTemplate.getForObject(url, JsonNode.class); + } + + 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(); + } + + public void reportVersionInstalled(String versionId) { + String url = baseUrl + "/api/versions/" + versionId + "/install"; + log.debug("Reporting IoT Hub version installed: {}", url); + restTemplate.postForObject(url, null, Void.class); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java new file mode 100644 index 0000000000..9a92825d82 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java @@ -0,0 +1,24 @@ +/** + * 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. + */ +package org.thingsboard.server.service.iot_hub; + +import com.fasterxml.jackson.databind.JsonNode; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface IotHubService { + + JsonNode installItemVersion(SecurityUser user, String versionId) throws Exception; +} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 751d1c0e0b..42fd314563 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2061,3 +2061,5 @@ mqtt: # The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor). # For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay. 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. diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index c31b49325e..1da7cfef68 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -39,4 +39,5 @@ public class SystemParams { long maxArgumentsPerCF; long maxDataPointsPerRollingArg; TrendzSettings trendzSettings; + String iotHubBaseUrl; } diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 344c44f6c7..b092f7dc5f 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -34,6 +34,7 @@ export interface SysParamsState { ruleChainDebugPerTenantLimitsConfiguration?: string; calculatedFieldDebugPerTenantLimitsConfiguration?: string; trendzSettings: TrendzSettings; + iotHubBaseUrl: string; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index ca46344bbb..5838bee5c6 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -36,7 +36,8 @@ const emptyUserAuthState: AuthPayload = { maxDataPointsPerRollingArg: 0, maxDebugModeDurationMinutes: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + iotHubBaseUrl: '' }; export const initialState: AuthState = { diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts new file mode 100644 index 0000000000..eac2492012 --- /dev/null +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -0,0 +1,126 @@ +/// +/// 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 { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; +import { InterceptorHttpParams } from '@core/interceptors/interceptor-http-params'; +import { InterceptorConfig } from '@core/interceptors/interceptor-config'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { environment as env } from '@env/environment'; + +export function tbVersionToInt(version: string): number { + const parts = version.replace(/[^0-9.]/g, '').split('.'); + const major = parseInt(parts[0], 10) || 0; + const minor = parseInt(parts[1], 10) || 0; + const patch = parseInt(parts[2], 10) || 0; + return major * 100 + minor * 10 + patch; +} + +export function iotHubResourceUrl(baseUrl: string, path: string): string { + if (!path) { + return path; + } + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + return `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; +} + +export interface IotHubRequestConfig { + ignoreLoading?: boolean; + ignoreErrors?: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class IotHubApiService { + + constructor( + private http: HttpClient, + private store: Store + ) {} + + get baseUrl(): string { + return getCurrentAuthState(this.store)?.iotHubBaseUrl || ''; + } + + public resolveResourceUrl(path: string): string { + return iotHubResourceUrl(this.baseUrl, path); + } + + public getPublishedVersions(query: MpItemVersionQuery, config?: IotHubRequestConfig): Observable> { + if (query.tbVersion == null) { + query.tbVersion = tbVersionToInt(env.tbVersion); + } + if (query.peOnly == null) { + query.peOnly = false; + } + return this.http.get>( + `${this.baseUrl}/api/versions/published${query.toQuery()}`, + { params: this.buildParams(config) } + ); + } + + public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, { + params: this.buildParams(config), + responseType: 'text' + }); + } + + public getVersionFileData(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.get(`${this.baseUrl}/api/versions/${versionId}/fileData`, { + params: this.buildParams(config), + responseType: 'blob' + }); + } + + public reportVersionInstalled(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.post( + `${this.baseUrl}/api/versions/${versionId}/install`, + null, + { params: this.buildParams(config) } + ); + } + + public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable { + return this.http.post( + `/api/iot-hub/versions/${versionId}/install`, + null, + { params: this.buildParams(config) } + ); + } + + public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/creators/${creatorId}/profile`, + { params: this.buildParams(config) } + ); + } + + private buildParams(config?: IotHubRequestConfig): HttpParams { + return new InterceptorHttpParams( + new InterceptorConfig(config?.ignoreLoading ?? false, config?.ignoreErrors ?? false) + ); + } +} diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index a3863f8b5e..6517e606b3 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -106,7 +106,8 @@ export enum MenuId { version_control = 'version_control', api_usage = 'api_usage', trendz_settings = 'trendz_settings', - ai_models = 'ai_models' + ai_models = 'ai_models', + iot_hub = 'iot_hub' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -707,6 +708,16 @@ export const menuSectionMap = new Map([ path: '/settings/trendz', icon: 'trendz-settings' } + ], + [ + MenuId.iot_hub, + { + id: MenuId.iot_hub, + name: 'iot-hub.iot-hub', + type: 'link', + path: '/iot-hub', + icon: 'store' + } ] ]); @@ -860,6 +871,7 @@ const defaultUserMenuMap = new Map([ ] }, {id: MenuId.api_usage}, + {id: MenuId.iot_hub}, { id: MenuId.settings, pages: [ diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 84412fabf4..fcc8298904 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -47,6 +47,7 @@ import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module' import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { MobileModule } from '@home/pages/mobile/mobile.module'; import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; +import { IotHubModule } from '@home/pages/iot-hub/iot-hub.module'; @NgModule({ exports: [ @@ -81,6 +82,7 @@ import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; AccountModule, ScadaSymbolModule, AiModelModule, + IotHubModule, ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html new file mode 100644 index 0000000000..b9f4bf4d75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html @@ -0,0 +1,215 @@ + +
+ + @if (!embedded) { +
+

{{ getTitle() | translate }}

+

{{ getSubtitle() | translate }}

+
+ } + + + + + + +
+ + search + + @if (textSearch) { + + } + + + + @for (option of sortOptions; track option.value; let i = $index) { + {{ option.label | translate }} + } + + +
+ + +
+ @if (getSubtypeMap(); as subtypeMap) { + + + @for (entry of subtypeMap | keyvalue; track entry.key) { + {{ entry.value | translate }} + } + + + } + @if (categories.size > 0) { + + + @for (entry of categories | keyvalue; track entry.key) { + {{ entry.value | translate }} + } + + + } + + + @for (entry of useCases | keyvalue; track entry.key) { + {{ entry.value | translate }} + } + + +
+ + + @if (hasActiveDropdownFilters()) { +
+ @for (key of getActiveSubtypesArray(); track key) { + + {{ getSubtypeLabel(key) }} + close + + } + @for (key of getActiveCategoriesArray(); track key) { + + {{ getCategoryLabel(key) }} + close + + } + @for (key of getActiveUseCasesArray(); track key) { + + {{ getUseCaseLabel(key) }} + close + + } + +
+ } + + + @if (!isLoading && !hasError) { +
+ {{ 'iot-hub.results-count' | translate:{ count: totalElements } }} +
+ } + + + @if (isLoading) { +
+ +
+ } + + + @if (hasError && !isLoading) { +
+ error_outline +

{{ 'iot-hub.unable-to-load' | translate }}

+

{{ 'iot-hub.unable-to-load-text' | translate }}

+ +
+ } + + + @if (!isLoading && !hasError && items.length === 0) { +
+ search_off +

{{ 'iot-hub.no-items-found' | translate }}

+

{{ 'iot-hub.no-items-found-text' | translate }}

+ @if (hasActiveFilters()) { + + } +
+ } + + + @if (!isLoading && !hasError && items.length > 0) { +
+ @for (item of items; track item.id) { + + + } +
+ + + + + } + + + @if (!creatorId) { + + } +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss new file mode 100644 index 0000000000..110133e5ff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss @@ -0,0 +1,400 @@ +/** + * 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: flex; + flex-direction: column; + height: 100%; + padding: 16px 24px; + overflow: auto; + + &.embedded { + padding: 0; + overflow: visible; + height: auto; + } +} + +.tb-iot-hub-browse-container { + max-width: 1400px; + margin: 0 auto; + width: 100%; + background: white; + padding: 24px; + border-radius: 4px; + box-shadow: 0 0 1px rgba(0, 0, 0, 0.14), 0 1px 1px rgba(0, 0, 0, 0.12); + + &.embedded { + max-width: none; + padding: 0; + border-radius: 0; + box-shadow: none; + background: transparent; + } +} + +.tb-iot-hub-browse-header { + margin-bottom: 24px; + + h1 { + font-size: 28px; + font-weight: 700; + margin: 0 0 4px; + color: rgba(0, 0, 0, 0.87); + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0; + } +} + +.tb-iot-hub-type-tabs { + margin-bottom: 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + + ::ng-deep { + .mat-mdc-tab-link-container { + flex-grow: 0; + } + + .mat-mdc-tab-link { + text-transform: none; + font-weight: 400; + min-width: auto; + padding: 0 16px; + letter-spacing: normal; + height: 40px; + flex: 0 0 auto; + } + + --mat-tab-active-indicator-color: #1976d2; + + .mdc-tab__text-label { + color: rgba(0, 0, 0, 0.6); + } + + .mdc-tab--active .mdc-tab__text-label { + font-weight: 500; + } + + --mat-tab-active-label-text-color: #1976d2; + --mat-tab-active-focus-label-text-color: #1976d2; + --mat-tab-active-hover-label-text-color: #1976d2; + --mat-tab-active-ripple-color: #1976d2; + --mat-tab-inactive-ripple-color: #1976d2; + --mat-tab-active-focus-indicator-color: #1976d2; + } +} + +// Shared field styles for search/filter/sort dropdowns +%tb-iot-hub-field-base { + ::ng-deep { + .mat-mdc-text-field-wrapper { + height: 42px; + padding: 0 12px; + } + + .mat-mdc-form-field-flex { + height: 42px; + align-items: center; + } + + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #d5dbe3; + } + + .mdc-notched-outline__leading { + border-radius: 8px 0 0 8px; + } + + .mdc-notched-outline__trailing { + border-radius: 0 8px 8px 0; + } + + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 0; + min-height: unset; + } + + .mdc-notched-outline__notch { + display: none; + } + + .mat-mdc-floating-label { + display: none; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + input.mat-mdc-input-element { + font-size: 14px; + color: #3e4a59; + + &::placeholder { + color: #8b95a2; + opacity: 1; + } + } + + .mat-mdc-select-value-text { + font-size: 14px; + color: #3e4a59; + } + + .mat-mdc-select-arrow-wrapper { + padding-left: 4px; + } + + .mat-icon { + color: #8b95a2; + font-size: 20px; + width: 20px; + height: 20px; + } + } + + &.mat-focused ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #8b95a2; + border-width: 1px; + } + } +} + +.tb-iot-hub-search-row { + display: flex; + gap: 12px; + margin-bottom: 12px; + align-items: center; + + .tb-iot-hub-search-field, + .tb-iot-hub-sort-field { + @extend %tb-iot-hub-field-base; + } + + .tb-iot-hub-search-field { + flex: 1; + + ::ng-deep { + .mat-mdc-form-field-icon-prefix { + padding: 0 8px 0 0; + + > .mat-icon { + padding: 0; + } + } + + .mat-mdc-form-field-icon-suffix { + padding: 0; + + .mat-mdc-icon-button { + width: 32px; + height: 32px; + padding: 4px; + } + } + } + } + + .tb-iot-hub-sort-field { + width: 140px; + flex-shrink: 0; + } +} + +.tb-iot-hub-filter-row { + display: flex; + gap: 12px; + margin-bottom: 16px; + padding-right: 152px; + + .tb-iot-hub-filter-select { + @extend %tb-iot-hub-field-base; + flex: 1; + min-width: 0; + } +} + +.tb-iot-hub-active-filters { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.tb-iot-hub-active-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 500; + background: #eef4ff; + color: #305ECA; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:hover { + background: #dde8fc; + } +} + +.tb-iot-hub-clear-filters { + border: none; + background: none; + font-size: 12px; + color: #8b95a2; + cursor: pointer; + padding: 4px 8px; + white-space: nowrap; + + &:hover { + color: #3e4a59; + text-decoration: underline; + } +} + +.tb-iot-hub-results-count { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin-bottom: 16px; +} + +.tb-iot-hub-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.tb-iot-hub-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + text-align: center; + color: rgba(0, 0, 0, 0.54); + + .tb-iot-hub-empty-icon { + font-size: 64px; + width: 64px; + height: 64px; + margin-bottom: 16px; + opacity: 0.4; + } + + h3 { + margin: 0 0 8px 0; + color: rgba(0, 0, 0, 0.87); + } + + p { + margin: 0 0 16px 0; + } +} + +.tb-iot-hub-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +::ng-deep mat-paginator { + .mat-mdc-form-field { + .mat-mdc-text-field-wrapper { + height: 42px; + padding: 0 12px; + border: 1px solid #d5dbe3; + border-radius: 8px; + background: white; + } + + .mat-mdc-form-field-flex { + height: 42px; + align-items: center; + } + + .mdc-notched-outline { + display: none; + } + + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 0; + min-height: unset; + border-top: none; + } + + .mat-mdc-floating-label { + display: none; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + .mat-mdc-select-value-text { + font-size: 14px; + color: #3e4a59; + } + + .mat-mdc-select-arrow-wrapper { + padding-left: 4px; + } + + &.mat-focused .mat-mdc-text-field-wrapper { + border-color: #8b95a2; + } + } +} + +.tb-iot-hub-browse-footer { + text-align: center; + padding: 24px 0; + border-top: 1px solid rgba(0, 0, 0, 0.08); + margin-top: 16px; + + .tb-iot-hub-become-creator { + color: rgba(0, 0, 0, 0.38); + font-size: 13px; + text-decoration: none; + + &:hover { + color: rgba(0, 0, 0, 0.54); + text-decoration: underline; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts new file mode 100644 index 0000000000..517e91ef8c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts @@ -0,0 +1,388 @@ +/// +/// 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, OnDestroy, Input } from '@angular/core'; +import { Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { PageData } from '@shared/models/page/page-data'; +import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + ItemType, + getCategoriesForType, useCaseTranslations +} from '@shared/models/iot-hub/iot-hub-item.models'; +import { cfTypeTranslations, widgetTypeTranslations, ruleChainTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; +import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; + +interface SortOption { + value: string; + label: string; + direction: Direction; +} + +const TYPE_TABS: ItemType[] = [ + ItemType.WIDGET, + ItemType.DASHBOARD, + ItemType.CALCULATED_FIELD, + ItemType.RULE_CHAIN, + ItemType.DEVICE +]; + +@Component({ + selector: 'tb-iot-hub-browse', + standalone: false, + templateUrl: './iot-hub-browse.component.html', + styleUrls: ['./iot-hub-browse.component.scss'], + host: { '[class.embedded]': 'embedded' } +}) +export class TbIotHubBrowseComponent implements OnInit, OnDestroy { + + readonly ItemType = ItemType; + + @Input() creatorId: string; + @Input() embedded = false; + + items: MpItemVersionView[] = []; + totalElements = 0; + pageSize = 12; + pageIndex = 0; + isLoading = false; + hasError = false; + + textSearch = ''; + activeType: ItemType = ItemType.WIDGET; + activeCategories = new Set(); + activeUseCases = new Set(); + activeCfTypes = new Set(); + activeWidgetTypes = new Set(); + activeRuleChainTypes = new Set(); + + sortOptions: SortOption[] = [ + { value: 'totalInstallCount', label: 'iot-hub.sort-most-installed', direction: Direction.DESC }, + { value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC }, + { value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC } + ]; + selectedSortIndex = 0; + + categories = new Map(); + useCases: Map = useCaseTranslations as Map; + cfTypes: Map = cfTypeTranslations; + widgetTypes: Map = widgetTypeTranslations; + ruleChainTypes: Map = ruleChainTypeTranslations; + + private searchSubject = new Subject(); + private destroy$ = new Subject(); + + constructor( + private iotHubApiService: IotHubApiService, + private dialog: MatDialog, + private translate: TranslateService, + private router: Router + ) {} + + ngOnInit(): void { + this.searchSubject.pipe( + debounceTime(300), + takeUntil(this.destroy$) + ).subscribe(() => { + this.pageIndex = 0; + this.loadItems(); + }); + this.updateCategories(); + this.loadItems(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSearchChange(value: string): void { + this.textSearch = value; + this.searchSubject.next(value); + } + + getTypeTabIndex(): number { + return TYPE_TABS.indexOf(this.activeType); + } + + onTypeTabIndexChange(index: number): void { + this.onTypeTabChange(TYPE_TABS[index]); + } + + onTypeTabChange(type: ItemType): void { + this.activeType = type; + this.activeCategories.clear(); + this.activeUseCases.clear(); + this.activeCfTypes.clear(); + this.activeWidgetTypes.clear(); + this.activeRuleChainTypes.clear(); + this.updateCategories(); + this.pageIndex = 0; + this.loadItems(); + } + + onCategoryToggle(category: string): void { + if (this.activeCategories.has(category)) { + this.activeCategories.delete(category); + } else { + this.activeCategories.add(category); + } + this.pageIndex = 0; + this.loadItems(); + } + + onSortChange(index: number): void { + this.selectedSortIndex = index; + this.pageIndex = 0; + this.loadItems(); + } + + onPageChange(event: { pageIndex: number; pageSize: number }): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.loadItems(); + } + + clearCategories(): void { + this.activeCategories.clear(); + this.pageIndex = 0; + this.loadItems(); + } + + onUseCaseToggle(useCase: string): void { + if (this.activeUseCases.has(useCase)) { + this.activeUseCases.delete(useCase); + } else { + this.activeUseCases.add(useCase); + } + this.pageIndex = 0; + this.loadItems(); + } + + clearUseCases(): void { + this.activeUseCases.clear(); + this.pageIndex = 0; + this.loadItems(); + } + + getSubtypeMap(): Map | null { + switch (this.activeType) { + case ItemType.WIDGET: return this.widgetTypes; + case ItemType.CALCULATED_FIELD: return this.cfTypes; + case ItemType.RULE_CHAIN: return this.ruleChainTypes; + default: return null; + } + } + + getActiveSubtypes(): Set { + switch (this.activeType) { + case ItemType.WIDGET: return this.activeWidgetTypes; + case ItemType.CALCULATED_FIELD: return this.activeCfTypes; + case ItemType.RULE_CHAIN: return this.activeRuleChainTypes; + default: return new Set(); + } + } + + onSubtypeToggle(subtype: string): void { + const active = this.getActiveSubtypes(); + if (active.has(subtype)) { + active.delete(subtype); + } else { + active.add(subtype); + } + this.pageIndex = 0; + this.loadItems(); + } + + clearSubtypes(): void { + this.getActiveSubtypes().clear(); + this.pageIndex = 0; + this.loadItems(); + } + + getActiveSubtypesArray(): string[] { + return Array.from(this.getActiveSubtypes()); + } + + getActiveCategoriesArray(): string[] { + return Array.from(this.activeCategories); + } + + getActiveUseCasesArray(): string[] { + return Array.from(this.activeUseCases); + } + + onSubtypesChange(values: string[]): void { + const active = this.getActiveSubtypes(); + active.clear(); + values.forEach(v => active.add(v)); + this.pageIndex = 0; + this.loadItems(); + } + + onCategoriesChange(values: string[]): void { + this.activeCategories.clear(); + values.forEach(v => this.activeCategories.add(v)); + this.pageIndex = 0; + this.loadItems(); + } + + onUseCasesChange(values: string[]): void { + this.activeUseCases.clear(); + values.forEach(v => this.activeUseCases.add(v)); + this.pageIndex = 0; + this.loadItems(); + } + + getSubtypeLabel(key: string): string { + const map = this.getSubtypeMap(); + const translationKey = map?.get(key); + return translationKey ? this.translate.instant(translationKey) : key; + } + + getCategoryLabel(key: string): string { + const translationKey = this.categories.get(key); + return translationKey ? this.translate.instant(translationKey) : key; + } + + getUseCaseLabel(key: string): string { + const translationKey = this.useCases.get(key); + return translationKey ? this.translate.instant(translationKey) : key; + } + + clearAllFilters(): void { + this.activeCategories.clear(); + this.activeUseCases.clear(); + this.activeCfTypes.clear(); + this.activeWidgetTypes.clear(); + this.activeRuleChainTypes.clear(); + this.textSearch = ''; + this.updateCategories(); + this.pageIndex = 0; + this.loadItems(); + } + + hasActiveDropdownFilters(): boolean { + return this.activeCategories.size > 0 || + this.activeUseCases.size > 0 || this.activeCfTypes.size > 0 || + this.activeWidgetTypes.size > 0 || this.activeRuleChainTypes.size > 0; + } + + hasActiveFilters(): boolean { + return this.hasActiveDropdownFilters() || this.textSearch.length > 0; + } + + getTitle(): string { + switch (this.activeType) { + case ItemType.WIDGET: return 'iot-hub.title-widgets'; + case ItemType.DASHBOARD: return 'iot-hub.title-dashboards'; + case ItemType.CALCULATED_FIELD: return 'iot-hub.title-calculated-fields'; + case ItemType.RULE_CHAIN: return 'iot-hub.title-rule-chains'; + case ItemType.DEVICE: return 'iot-hub.title-devices'; + } + } + + getSubtitle(): string { + switch (this.activeType) { + case ItemType.WIDGET: return 'iot-hub.subtitle-widgets'; + case ItemType.DASHBOARD: return 'iot-hub.subtitle-dashboards'; + case ItemType.CALCULATED_FIELD: return 'iot-hub.subtitle-calculated-fields'; + case ItemType.RULE_CHAIN: return 'iot-hub.subtitle-rule-chains'; + case ItemType.DEVICE: return 'iot-hub.subtitle-devices'; + } + } + + getSearchPlaceholder(): string { + switch (this.activeType) { + case ItemType.WIDGET: return 'iot-hub.search-widgets'; + case ItemType.DASHBOARD: return 'iot-hub.search-dashboards'; + case ItemType.CALCULATED_FIELD: return 'iot-hub.search-calculated-fields'; + case ItemType.RULE_CHAIN: return 'iot-hub.search-rule-chains'; + case ItemType.DEVICE: return 'iot-hub.search-devices'; + } + } + + openItemDetail(item: MpItemVersionView): void { + this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + autoFocus: false, + data: { + item, + iotHubApiService: this.iotHubApiService + } as IotHubItemDetailDialogData + }); + } + + installItem(item: MpItemVersionView): void { + this.dialog.open(TbIotHubInstallDialogComponent, { + panelClass: ['tb-dialog'], + data: { + item, + iotHubApiService: this.iotHubApiService + } as IotHubInstallDialogData + }); + } + + navigateToCreator(creatorId: string): void { + this.router.navigate(['/iot-hub/creator', creatorId]); + } + + private updateCategories(): void { + this.categories = getCategoriesForType(this.activeType); + } + + loadItems(): void { + this.isLoading = true; + this.hasError = false; + const sort = this.sortOptions[this.selectedSortIndex]; + const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); + const query = new MpItemVersionQuery( + pageLink, + this.activeType, + undefined, + this.creatorId || undefined, + this.activeCategories.size > 0 ? Array.from(this.activeCategories) : undefined, + this.activeUseCases.size > 0 ? Array.from(this.activeUseCases) : undefined, + this.activeCfTypes.size > 0 ? Array.from(this.activeCfTypes) : undefined, + this.activeWidgetTypes.size > 0 ? Array.from(this.activeWidgetTypes) : undefined, + this.activeRuleChainTypes.size > 0 ? Array.from(this.activeRuleChainTypes) : undefined + ); + this.iotHubApiService.getPublishedVersions( + query, + { ignoreLoading: true, ignoreErrors: true } + ).subscribe({ + next: (data: PageData) => { + this.items = data.data; + this.totalElements = data.totalElements; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.hasError = true; + this.items = []; + this.totalElements = 0; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html new file mode 100644 index 0000000000..16a31f5bc4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html @@ -0,0 +1,82 @@ + +
+ + + @if (creator) { + + + +
+
+ @if (getAvatarUrl(); as avatarUrl) { + + } @else { + account_circle + } +
+
+

{{ creator.displayName }}

+ @if (creator.description) { +

{{ creator.description }}

+ } + @if (creator.website) { + + {{ creator.website }} + + } + @if (creator.contactEmail) { +
{{ creator.contactEmail }}
+ } + +
+
+
+
+ + +
+

{{ 'iot-hub.published-items' | translate }} ({{ creator.publishedCount }})

+
+ + + + } +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss new file mode 100644 index 0000000000..55b9a48577 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss @@ -0,0 +1,119 @@ +/** + * 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: flex; + flex-direction: column; + height: 100%; + padding: 16px 24px; + overflow: auto; +} + +.tb-iot-hub-creator-profile-container { + max-width: 1200px; + margin: 0 auto; + width: 100%; + background: white; + padding: 24px; + border-radius: 4px; + box-shadow: 0 0 1px rgba(0, 0, 0, 0.14), 0 1px 1px rgba(0, 0, 0, 0.12); +} + +.tb-iot-hub-back-btn { + margin-bottom: 16px; +} + +.tb-iot-hub-creator-header { + margin-bottom: 24px; +} + +.tb-iot-hub-creator-info { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.tb-iot-hub-creator-avatar { + width: 80px; + height: 80px; + min-width: 80px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.06); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: rgba(0, 0, 0, 0.3); + } +} + +.tb-iot-hub-creator-details { + flex: 1; + + h2 { + margin: 0 0 4px 0; + font-size: 22px; + } + + .tb-iot-hub-creator-desc { + margin: 0 0 8px 0; + color: rgba(0, 0, 0, 0.6); + } + + .tb-iot-hub-creator-link { + color: #1976d2; + text-decoration: none; + font-size: 14px; + display: block; + margin-bottom: 4px; + + &:hover { + text-decoration: underline; + } + } + + .tb-iot-hub-creator-email { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin-bottom: 8px; + } + + .tb-iot-hub-social-links { + display: flex; + gap: 4px; + } +} + +.tb-iot-hub-creator-items-header { + margin-bottom: 16px; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts new file mode 100644 index 0000000000..5af791adbb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts @@ -0,0 +1,70 @@ +/// +/// 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, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { CreatorView } from '@shared/models/iot-hub/iot-hub-creator.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; + +@Component({ + selector: 'tb-iot-hub-creator-profile', + standalone: false, + templateUrl: './iot-hub-creator-profile.component.html', + styleUrls: ['./iot-hub-creator-profile.component.scss'] +}) +export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy { + + creator: CreatorView; + creatorId: string; + + private destroy$ = new Subject(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private iotHubApiService: IotHubApiService + ) {} + + ngOnInit(): void { + this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => { + this.creatorId = params['creatorId']; + this.loadCreator(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + getAvatarUrl(): string | null { + return this.creator?.avatarUrl ? this.iotHubApiService.resolveResourceUrl(this.creator.avatarUrl) : null; + } + + goBack(): void { + this.router.navigate(['/iot-hub']); + } + + private loadCreator(): void { + this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true }).subscribe({ + next: creator => this.creator = creator, + error: () => this.router.navigate(['/iot-hub']) + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts new file mode 100644 index 0000000000..f8132ec5c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts @@ -0,0 +1,123 @@ +/// +/// 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; + +export interface IotHubInstallDialogData { + item: MpItemVersionView; + iotHubApiService: IotHubApiService; +} + +export type InstallState = 'confirm' | 'installing' | 'success' | 'error'; + +@Component({ + selector: 'tb-iot-hub-install-dialog', + standalone: false, + template: ` +

{{ 'iot-hub.install-item-title' | translate }}

+ +

{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}

+

{{ 'iot-hub.install-type' | translate:{ type: (typeTranslations.get(item.type) | translate) } }}

+

{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}

+
+ + + @switch (state) { + @case ('confirm') { + + } + @case ('installing') { + + } + @case ('success') { + + } + } + + `, + styles: [` + .tb-iot-hub-install-meta { + margin: 4px 0; + color: rgba(0, 0, 0, 0.54); + font-size: 14px; + } + .tb-iot-hub-inline-spinner { + display: inline-block; + margin-right: 8px; + } + `] +}) +export class TbIotHubInstallDialogComponent { + + item: MpItemVersionView; + typeTranslations = itemTypeTranslations; + state: InstallState = 'confirm'; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: IotHubInstallDialogData, + private dialogRef: MatDialogRef, + private store: Store, + private translate: TranslateService + ) { + this.item = data.item; + } + + install(): void { + this.state = 'installing'; + const versionId = this.item.id as string; + this.data.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({ + next: () => { + this.state = 'success'; + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.install-success', { name: this.item.name }), + type: 'success', + duration: 3000 + })); + setTimeout(() => this.dialogRef.close(true), 1500); + }, + error: () => { + this.state = 'confirm'; + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.install-error', { name: this.item.name }), + type: 'error', + duration: 5000 + })); + } + }); + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html new file mode 100644 index 0000000000..8b99b4cbe0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html @@ -0,0 +1,107 @@ + +
+ @if (!isCompactLayout()) { +
+ @if (getPreviewUrl(); as previewUrl) { + + } @else { +
+ {{ getPlaceholderIcon() }} +
+ } + @if (item.peOnly) { + PE + } +
+ } +
+ @if (isCompactLayout()) { +
+
+ {{ getPlaceholderIcon() }} +
+
+
+

{{ item.name }}

+ v{{ item.version }} +
+ @if (getSubtypeLabel(); as subtypeLabel) { + {{ subtypeLabel }} + } +
+ @if (item.peOnly) { + PE + } +
+ } @else { +
+

{{ item.name }}

+ v{{ item.version }} +
+ @if (item.type === ItemType.WIDGET && getSubtypeLabel(); as subtypeLabel) { + {{ subtypeLabel }} + } + } + @if (item.description) { +

{{ item.description }}

+ } + @if (getNodes(); as nodes) { + @if (nodes.length) { +
+ @for (node of nodes | slice:0:maxVisibleNodes; track node.name) { + {{ node.name }} + } + @if (getNodeCount() > maxVisibleNodes) { + +{{ getNodeCount() - maxVisibleNodes }} + } +
+ } + } + @if (getCategoryLabels().length || getFirstUseCaseLabel()) { +
+ @if (getCategoryLabels(); as labels) { + @if (labels.length) { + {{ labels[0] }} + } + } + @if (getFirstUseCaseLabel(); as useCaseLabel) { + {{ useCaseLabel }} + } +
+ } + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss new file mode 100644 index 0000000000..0f16dd4fae --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss @@ -0,0 +1,351 @@ +/** + * 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; + height: 100%; +} + +.tb-iot-hub-item-card { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: box-shadow 0.2s, transform 0.2s; + background: white; + height: 100%; + display: flex; + flex-direction: column; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } + + &.tb-iot-hub-card-compact { + border-color: #e5e7eb; + + .tb-iot-hub-card-content { + padding: 16px 18px; + } + } +} + +// Preview area +.tb-iot-hub-card-preview { + height: 160px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + + &.preview-widget, &.preview-dashboard { background: #f5f7fa; border-bottom: 1px solid #e5e7eb; } + &.preview-calculated-field { background: linear-gradient(135deg, #e3f2fd, #bbdefb); } + &.preview-rule-chain { background: linear-gradient(135deg, #f3e5f5, #e1bee7); } + &.preview-device { background: linear-gradient(135deg, #eceff1, #cfd8dc); } +} + +.tb-iot-hub-preview-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + padding: 8px; +} + +.tb-iot-hub-preview-placeholder { + width: 62px; + height: 62px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + + tb-icon { + font-size: 36px; + width: 36px; + height: 36px; + } + + .preview-widget &, .preview-dashboard & { background: rgba(107, 114, 128, 0.1); tb-icon { color: #6b7280; } } + .preview-calculated-field & { background: rgba(25, 118, 210, 0.15); tb-icon { color: #1565c0; } } + .preview-rule-chain & { background: rgba(123, 31, 162, 0.15); tb-icon { color: #7b1fa2; } } + .preview-device & { background: rgba(69, 90, 100, 0.15); tb-icon { color: #455a64; } } +} + +.tb-iot-hub-pe-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +// Content area +.tb-iot-hub-card-content { + padding: 12px 16px 12px; + flex: 1; + display: flex; + flex-direction: column; +} + +// Compact header (CF / RC) +.tb-iot-hub-card-cf-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} + +.tb-iot-hub-card-cf-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + tb-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #fff; + } +} + +.tb-iot-hub-card-cf-title-area { + flex: 1; + min-width: 0; + + .tb-iot-hub-card-name-row { + margin-bottom: 2px; + } +} + +.tb-iot-hub-card-cf-type { + font-size: 11px; + font-weight: 500; + color: #6b7280; +} + +.tb-iot-hub-pe-badge-inline { + position: static; + flex-shrink: 0; + align-self: flex-start; +} + +// Name row +.tb-iot-hub-card-name-row { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.tb-iot-hub-card-name { + font-size: 16px; + font-weight: 700; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(0, 0, 0, 0.87); +} + +.tb-iot-hub-card-version { + font-size: 13px; + color: rgba(0, 0, 0, 0.4); + flex-shrink: 0; +} + +.tb-iot-hub-card-widget-type-label { + font-size: 11px; + font-weight: 500; + color: #6b7280; + margin-bottom: 4px; +} + +// Description +.tb-iot-hub-card-description { + font-size: 13px; + line-height: 1.4; + color: rgba(0, 0, 0, 0.55); + margin: 0 0 10px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// Node chips (Rule Chain) +.tb-iot-hub-card-nodes { + display: flex; + gap: 6px; + flex-wrap: nowrap; + overflow: hidden; + margin-bottom: 8px; +} + +.tb-iot-hub-card-node-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + line-height: 16px; + height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + max-width: 120px; + + &.node-enrichment { background: #f5fcd5; color: #4a7a00; } + &.node-filter { background: #fcfadb; color: #7a7200; } + &.node-transformation { background: #ddf2fc; color: #1a6d9e; } + &.node-action { background: #fde3e2; color: #9e2a27; } + &.node-analytics { background: #d5f6f4; color: #0e7a74; } + &.node-external { background: #fef1d8; color: #8a5d00; } + &.node-flow { background: #f3edfc; color: #5b3a9e; } + &.node-unknown { background: #f5f5f5; color: rgba(0, 0, 0, 0.54); } +} + +.tb-iot-hub-card-node-more { + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.45); + padding: 3px 4px; + flex-shrink: 0; +} + +// Category / use case tags +.tb-iot-hub-card-categories { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.tb-iot-hub-card-category-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: #eff6ff; + color: #1e6bb8; + white-space: nowrap; +} + +.tb-iot-hub-card-use-case-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: #f0fdf4; + color: #16a34a; + white-space: nowrap; +} + +// Footer +.tb-iot-hub-card-footer { + display: flex; + align-items: center; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: auto; + margin-left: -16px; + margin-right: -16px; + margin-bottom: -12px; + padding: 12px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + + .tb-iot-hub-card-author-icon { + font-size: 14px; + width: 14px; + height: 14px; + flex-shrink: 0; + margin-right: 4px; + } + + .tb-iot-hub-card-creator { + color: #1976d2; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + .tb-iot-hub-card-dot { + margin: 0 6px; + flex-shrink: 0; + } + + .tb-iot-hub-card-published { + flex-shrink: 0; + } + + .tb-iot-hub-card-stats { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + flex-shrink: 0; + } + + .tb-iot-hub-card-stat { + display: flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: #9ca3af; + + tb-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + .tb-iot-hub-card-install-btn { + padding: 5px 14px; + border-radius: 6px; + border: 1px solid #305680; + background: transparent; + color: #305680; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + + &:hover { + background: #305680; + color: #fff; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts new file mode 100644 index 0000000000..87f5502696 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts @@ -0,0 +1,204 @@ +/// +/// 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, EventEmitter, Input, Output } from '@angular/core'; +import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, NodeInfo, nodeComponentTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations, getCategoriesForType, useCaseTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { TranslateService } from '@ngx-translate/core'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; + +@Component({ + selector: 'tb-iot-hub-item-card', + standalone: false, + templateUrl: './iot-hub-item-card.component.html', + styleUrls: ['./iot-hub-item-card.component.scss'] +}) +export class TbIotHubItemCardComponent { + + readonly ItemType = ItemType; + readonly maxVisibleCategories = 3; + readonly maxVisibleNodes = 2; + + @Input() item: MpItemVersionView; + @Input() showCreator = true; + @Input() showTypeChip = true; + @Output() cardClick = new EventEmitter(); + @Output() creatorClick = new EventEmitter(); + @Output() installClick = new EventEmitter(); + + typeTranslations = itemTypeTranslations; + + constructor( + private translate: TranslateService, + private iotHubApiService: IotHubApiService + ) {} + + isCompactLayout(): boolean { + return this.item.type === ItemType.CALCULATED_FIELD || this.item.type === ItemType.RULE_CHAIN; + } + + getPreviewUrl(): string | null { + if (!this.item.image) { + return null; + } + const url = this.item.image.split('?')[0]; + const resolved = url.endsWith('/preview') ? this.item.image : `${url}/preview`; + return this.iotHubApiService.resolveResourceUrl(resolved); + } + + getPreviewClass(): string { + switch (this.item.type) { + case ItemType.WIDGET: return 'preview-widget'; + case ItemType.DASHBOARD: return 'preview-dashboard'; + case ItemType.CALCULATED_FIELD: return 'preview-calculated-field'; + case ItemType.RULE_CHAIN: return 'preview-rule-chain'; + case ItemType.DEVICE: return 'preview-device'; + default: return ''; + } + } + + getPlaceholderIcon(): string { + switch (this.item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.CALCULATED_FIELD: + return this.item.icon || cfTypeIcons.get(this.item.dataDescriptor?.cfType) || 'functions'; + case ItemType.RULE_CHAIN: + return this.item.icon || (this.item.dataDescriptor?.ruleChainType === 'EDGE' ? 'router' : 'device_hub'); + case ItemType.DEVICE: return 'memory'; + default: return 'extension'; + } + } + + getCustomIconColor(): string | null { + if ((this.item.type === ItemType.CALCULATED_FIELD || this.item.type === ItemType.RULE_CHAIN) && this.item.color) { + return this.item.color; + } + return null; + } + + getSubtypeLabel(): string { + switch (this.item.type) { + case ItemType.WIDGET: { + const wt = this.item.dataDescriptor?.widgetType; + const key = wt ? widgetTypeTranslations.get(wt) : null; + return key ? this.translate.instant(key) : wt || ''; + } + case ItemType.CALCULATED_FIELD: { + const cfType = this.item.dataDescriptor?.cfType; + const key = cfType ? cfTypeTranslations.get(cfType) : null; + return key ? this.translate.instant(key) : cfType || ''; + } + case ItemType.RULE_CHAIN: { + const rcType = this.item.dataDescriptor?.ruleChainType; + const key = rcType ? ruleChainTypeTranslations.get(rcType) : null; + return key ? this.translate.instant(key) : rcType || ''; + } + default: + return ''; + } + } + + getSubtypeColorClass(): string { + switch (this.item.type) { + case ItemType.WIDGET: + return 'wt-' + (this.item.dataDescriptor?.widgetType || 'static'); + case ItemType.CALCULATED_FIELD: + switch (this.item.dataDescriptor?.cfType) { + case 'SIMPLE': return 'cf-simple'; + case 'SCRIPT': return 'cf-script'; + case 'GEOFENCING': return 'cf-geofencing'; + case 'ALARM': return 'cf-alarm'; + case 'PROPAGATION': return 'cf-propagation'; + case 'RELATED_ENTITIES_AGGREGATION': return 'cf-related-agg'; + case 'ENTITY_AGGREGATION': return 'cf-entity-agg'; + default: return 'cf-simple'; + } + case ItemType.RULE_CHAIN: + return this.item.dataDescriptor?.ruleChainType === 'EDGE' ? 'rc-edge' : 'rc-core'; + default: + return ''; + } + } + + getCategoryLabels(): string[] { + if (!this.item.categories?.length) { + return []; + } + const categoryMap = getCategoriesForType(this.item.type); + return this.item.categories.map(c => { + const key = categoryMap.get(c); + return key ? this.translate.instant(key) : c; + }); + } + + getFirstUseCaseLabel(): string | null { + if (!this.item.useCases?.length) { + return null; + } + const key = useCaseTranslations.get(this.item.useCases[0] as any); + return key ? this.translate.instant(key) : this.item.useCases[0]; + } + + getNodes(): NodeInfo[] { + if (this.item.type !== ItemType.RULE_CHAIN) { + return []; + } + return this.item.dataDescriptor?.nodes || []; + } + + getNodeCount(): number { + return this.item.dataDescriptor?.nodeCount || 0; + } + + getNodeLabel(node: NodeInfo): string { + const key = nodeComponentTypeTranslations.get(node.type); + return key ? this.translate.instant(key) : node.type; + } + + getNodeColorClass(node: NodeInfo): string { + switch (node.type) { + case 'ENRICHMENT': return 'node-enrichment'; + case 'FILTER': return 'node-filter'; + case 'TRANSFORMATION': return 'node-transformation'; + case 'ACTION': return 'node-action'; + case 'ANALYTICS': return 'node-analytics'; + case 'EXTERNAL': return 'node-external'; + case 'FLOW': return 'node-flow'; + default: return 'node-unknown'; + } + } + + onCardClick(): void { + this.cardClick.emit(this.item); + } + + onInstallClick(event: MouseEvent): void { + event.stopPropagation(); + this.installClick.emit(this.item); + } + + onCreatorClick(event: MouseEvent): void { + event.stopPropagation(); + this.creatorClick.emit(this.item.creatorId); + } + + formatPublishedTime(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html new file mode 100644 index 0000000000..6d30c3c920 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html @@ -0,0 +1,188 @@ + +
+ +
+
+ @if (item.type === ItemType.CALCULATED_FIELD || item.type === ItemType.RULE_CHAIN) { +
+ {{ getCompactIcon() }} +
+ } +
+
+ {{ item.name }} + v{{ item.version }} + @if (getSubtypeLabel(); as subtypeLabel) { + {{ subtypeLabel }} + } +
+
+ {{ typeTranslations.get(item.type) | translate }} + · + {{ 'iot-hub.by-creator' | translate:{ name: '' } }} + {{ item.creatorDisplayName }} + @if (item.publishedTime) { + · + {{ item.publishedTime | date:'mediumDate' }} + } +
+
+ +
+
+ + +
+ + @if (item.type === ItemType.WIDGET) { +
+
+ @if (getPreviewUrl(); as url) { + + } @else { +
+ widgets +
+ } +
+ @if (item.description) { +
{{ item.description }}
+ } +
+ } + + + @if (item.type === ItemType.DASHBOARD) { +
+
+ @if (getPreviewUrl(); as url) { + + } @else { +
+ dashboard +
+ } +
+ @if (item.description) { +
{{ item.description }}
+ } +
+ } + + +
+ + download + {{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }} + + + {{ getTypeIcon() }} + {{ typeTranslations.get(item.type) | translate }} + + @if (item.peOnly) { + PE + } +
+ + + @if (item.type === ItemType.DEVICE) { +
+ @if (getPreviewUrl(); as url) { + + } @else { +
+ memory +
+ } +
+ } + + + @if (item.description && item.type !== ItemType.WIDGET && item.type !== ItemType.DASHBOARD) { +
{{ item.description }}
+ } + + + @if (item.type === ItemType.RULE_CHAIN && getNodes().length) { +
+ @for (node of getNodes(); track node.name) { + {{ node.name }} + } + @if (getNodeCount() > getNodes().length) { + +{{ getNodeCount() - getNodes().length }} {{ 'iot-hub.more' | translate }} + } +
+ } + + + @if (item.categories?.length || item.useCases?.length) { +
+ @if (item.categories?.length) { +
+ {{ 'iot-hub.categories' | translate }} +
+ @for (cat of item.categories; track cat) { + {{ getCategoryLabel(cat) }} + } +
+
+ } + @if (item.useCases?.length) { +
+ {{ 'iot-hub.use-cases' | translate }} +
+ @for (uc of item.useCases; track uc) { + {{ getUseCaseLabel(uc) }} + } +
+
+ } +
+ } + + + @if (readmeContent || item.changelog) { +
+ @if (readmeContent) { + + } + @if (item.changelog) { +

{{ 'iot-hub.changelog' | translate }}

+ + } +
+ } +
+ + + +
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss new file mode 100644 index 0000000000..23fd2d2c98 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -0,0 +1,419 @@ +/** + * 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-context(.tb-default .tb-dialog .mat-mdc-dialog-component-host) { + @media (min-width: 600px) { + .mat-mdc-dialog-content { + width: 800px; + max-height: 80vh; + } + } +} + +.tb-iot-hub-detail-dialog { + display: flex; + flex-direction: column; + padding: 0 !important; + --mat-dialog-supporting-text-line-height: 1.5; +} + +// Header +.dlg-header { + padding: 20px 24px 16px; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; +} + +.dlg-header-top { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.dlg-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + tb-icon { + font-size: 26px; + width: 26px; + height: 26px; + color: #fff; + } +} + +.dlg-header-info { + flex: 1; + min-width: 0; +} + +.dlg-title-row { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.dlg-title { + font-size: 18px; + font-weight: 700; + color: #1a1a2e; +} + +.dlg-version { + font-size: 12px; + font-weight: 500; + color: #9ca3af; +} + +.dlg-type-badge { + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + + &.wt-timeseries { background: #e3f2fd; color: #1565c0; } + &.wt-latest { background: #e8f5e9; color: #2e7d32; } + &.wt-rpc { background: #f3e5f5; color: #7b1fa2; } + &.wt-alarm { background: #ffebee; color: #c62828; } + &.wt-static { background: #eceff1; color: #455a64; } + &.cf-simple { background: #e3f2fd; color: #1565c0; } + &.cf-script { background: #f3e5f5; color: #6a1b9a; } + &.cf-geofencing { background: #e8f5e9; color: #2e7d32; } + &.cf-alarm { background: #ffebee; color: #c62828; } + &.cf-propagation { background: #e0f2f1; color: #00695c; } + &.cf-related-agg { background: #e8eaf6; color: #283593; } + &.cf-entity-agg { background: #fff3e0; color: #e65100; } + &.rc-core { background: #f3e5f5; color: #7b1fa2; } + &.rc-edge { background: #e0f2f1; color: #00695c; } +} + +.dlg-subtitle { + font-size: 12px; + color: #6b7280; + margin-top: 2px; +} + +.dlg-creator-link { + color: #1976d2; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.dlg-close { + flex-shrink: 0; + margin: -8px -8px 0 0; +} + +.dlg-sep { + color: #9ca3af; + padding: 0 2px; +} + +// Hero split (widget: image + description side by side) +.dlg-hero-split { + display: flex; + gap: 20px; + padding: 20px 24px; + border-bottom: 1px solid #f3f4f6; +} + +.dlg-hero-image { + flex: 0 0 48%; + background: #f5f7fa; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 8px; + } + + .tb-iot-hub-preview-placeholder tb-icon { + font-size: 64px; + width: 64px; + height: 64px; + opacity: 0.3; + } +} + +.dlg-hero-desc { + flex: 1; + font-size: 13px; + color: #4b5563; + line-height: 1.6; + + p { + margin: 0; + } +} + +// Dashboard: full-width image + description below +.dlg-hero-full { + padding: 20px 24px; + border-bottom: 1px solid #f3f4f6; +} + +.dlg-hero-full-image { + background: #f5f7fa; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + max-height: 300px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 8px; + } + + .tb-iot-hub-preview-placeholder tb-icon { + font-size: 64px; + width: 64px; + height: 64px; + opacity: 0.3; + } +} + +.dlg-hero-full-desc { + margin-top: 12px; + font-size: 13px; + color: #4b5563; + line-height: 1.6; +} + +// Stats bar +.dlg-stats-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 24px; + background: #f9fafb; + font-size: 12px; + color: #6b7280; + border-bottom: 1px solid #f3f4f6; +} + +.dlg-stat { + display: flex; + align-items: center; + gap: 4px; + + tb-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + strong { + color: #1a1a2e; + } +} + +.dlg-pe-label { + display: inline-block; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +// Device preview +.dlg-device-preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + background: #f5f7fa; + border-bottom: 1px solid #f3f4f6; + + img { + max-width: 100%; + max-height: 160px; + object-fit: contain; + padding: 8px; + } + + .tb-iot-hub-preview-placeholder tb-icon { + font-size: 64px; + width: 64px; + height: 64px; + opacity: 0.3; + } +} + +// Description block (CF/RC) +.dlg-desc-block { + padding: 16px 24px; + font-size: 13px; + color: #4b5563; + line-height: 1.6; + border-bottom: 1px solid #f3f4f6; +} + +// Node chips (Rule Chain) +.dlg-node-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 12px 24px; + border-bottom: 1px solid #f3f4f6; +} + +.dlg-node-chip { + padding: 4px 12px; + border-radius: 14px; + font-size: 12px; + font-weight: 500; + border: 1px solid; + + &.dlg-node-enrichment { background: #f5fcd5; color: #4a7a00; border-color: #d4e6a0; } + &.dlg-node-filter { background: #fcfadb; color: #7a7200; border-color: #e8e4a0; } + &.dlg-node-transformation { background: #ddf2fc; color: #1a6d9e; border-color: #a8d8f0; } + &.dlg-node-action { background: #fde3e2; color: #9e2a27; border-color: #f0b8b6; } + &.dlg-node-analytics { background: #d5f6f4; color: #0e7a74; border-color: #a0e0dc; } + &.dlg-node-external { background: #fef1d8; color: #8a5d00; border-color: #f0d8a0; } + &.dlg-node-flow { background: #f3edfc; color: #5b3a9e; border-color: #d0c4f0; } + &.dlg-node-unknown { background: #f5f5f5; color: rgba(0, 0, 0, 0.54); border-color: #e0e0e0; } +} + +.dlg-node-more { + font-size: 11px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + align-self: center; +} + +// Categories & Use Cases +.dlg-meta-section { + display: flex; + gap: 40px; + padding: 14px 24px; + border-bottom: 1px solid #e5e7eb; +} + +.dlg-meta-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dlg-meta-label { + font-size: 10px; + font-weight: 600; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dlg-meta-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.dlg-meta-chip { + padding: 3px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + border: 1px solid #e5e7eb; + color: #4b5563; + background: #fff; +} + +// Scrollable body +.dlg-body { + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.dlg-content { + padding: 0; + + h3 { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; + margin: 20px 0 8px; + } +} + +// Footer +.dlg-footer { + padding: 16px 24px; + border-top: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.dlg-install-btn { + width: 100%; + max-width: 400px; + padding: 12px 24px; + border: none; + border-radius: 8px; + background: #305680; + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #264466; + } +} + +.dlg-creator-info { + font-size: 12px; + color: #9ca3af; +} + +.dlg-become-creator { + color: #305680; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts new file mode 100644 index 0000000000..c262d32656 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -0,0 +1,246 @@ +/// +/// 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, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, nodeComponentTypeTranslations, NodeInfo } from '@shared/models/iot-hub/iot-hub-version.models'; +import { ItemType, itemTypeTranslations, getCategoriesForType, useCaseTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { TranslateService } from '@ngx-translate/core'; +import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-hub-install-dialog.component'; + +export interface IotHubItemDetailDialogData { + item: MpItemVersionView; + iotHubApiService: IotHubApiService; +} + +@Component({ + selector: 'tb-iot-hub-item-detail-dialog', + standalone: false, + templateUrl: './iot-hub-item-detail-dialog.component.html', + styleUrls: ['./iot-hub-item-detail-dialog.component.scss'] +}) +export class TbIotHubItemDetailDialogComponent { + + readonly ItemType = ItemType; + item: MpItemVersionView; + typeTranslations = itemTypeTranslations; + readmeContent: string = ''; + + private categoryMap: Map; + private useCaseMap = useCaseTranslations; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: IotHubItemDetailDialogData, + private dialogRef: MatDialogRef, + private dialog: MatDialog, + private router: Router, + private translate: TranslateService + ) { + this.item = data.item; + this.categoryMap = getCategoriesForType(this.item.type); + this.loadReadme(); + } + + getCategoryLabel(key: string): string { + const translationKey = this.categoryMap.get(key); + return translationKey ? this.translate.instant(translationKey) : key; + } + + getUseCaseLabel(key: string): string { + const translationKey = this.useCaseMap.get(key as any); + return translationKey ? this.translate.instant(translationKey) : key; + } + + getPreviewUrl(): string | null { + return this.item.image ? this.data.iotHubApiService.resolveResourceUrl(this.item.image) : null; + } + + getTypeChipClass(): string { + switch (this.item.type) { + case ItemType.WIDGET: return 'type-widget'; + case ItemType.DASHBOARD: return 'type-dashboard'; + case ItemType.CALCULATED_FIELD: return 'type-calc-field'; + case ItemType.RULE_CHAIN: return 'type-rule-chain'; + case ItemType.DEVICE: return 'type-device'; + default: return ''; + } + } + + getTypeLabel(): string { + const key = this.typeTranslations.get(this.item.type); + return key ? this.translate.instant(key) : ''; + } + + getTypeIcon(): string { + switch (this.item.type) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboard'; + case ItemType.CALCULATED_FIELD: return 'functions'; + case ItemType.RULE_CHAIN: return 'account_tree'; + case ItemType.DEVICE: return 'memory'; + default: return 'category'; + } + } + + getCompactIcon(): string { + if (this.item.icon) { + return this.item.icon; + } + if (this.item.type === ItemType.CALCULATED_FIELD) { + const cfType = this.item.dataDescriptor?.cfType; + return cfTypeIcons.get(cfType) || 'functions'; + } + switch (this.item.dataDescriptor?.ruleChainType) { + case 'CORE': return 'device_hub'; + case 'EDGE': return 'router'; + default: return 'account_tree'; + } + } + + getCustomColor(): string | null { + return this.item.color || null; + } + + getCompactIconColorClass(): string { + if (this.item.color) { + return ''; + } + if (this.item.type === ItemType.CALCULATED_FIELD) { + switch (this.item.dataDescriptor?.cfType) { + case 'SIMPLE': return 'cf-simple'; + case 'SCRIPT': return 'cf-script'; + case 'GEOFENCING': return 'cf-geofencing'; + case 'ALARM': return 'cf-alarm'; + case 'PROPAGATION': return 'cf-propagation'; + case 'RELATED_ENTITIES_AGGREGATION': return 'cf-related-agg'; + case 'ENTITY_AGGREGATION': return 'cf-entity-agg'; + default: return 'cf-entity-agg'; + } + } + switch (this.item.dataDescriptor?.ruleChainType) { + case 'CORE': return 'rc-core'; + case 'EDGE': return 'rc-edge'; + default: return 'rc-core'; + } + } + + getCompactSubtypeLabel(): string { + if (this.item.type === ItemType.CALCULATED_FIELD) { + const cfType = this.item.dataDescriptor?.cfType; + const key = cfType ? cfTypeTranslations.get(cfType) : null; + return key ? this.translate.instant(key) : cfType || ''; + } + const rcType = this.item.dataDescriptor?.ruleChainType; + const key = rcType ? ruleChainTypeTranslations.get(rcType) : null; + return key ? this.translate.instant(key) : rcType || ''; + } + + getSubtypeLabel(): string { + switch (this.item.type) { + case ItemType.CALCULATED_FIELD: + return this.getCompactSubtypeLabel(); + case ItemType.RULE_CHAIN: + return this.getCompactSubtypeLabel(); + case ItemType.WIDGET: { + const wt = this.item.dataDescriptor?.widgetType; + const key = wt ? widgetTypeTranslations.get(wt) : null; + return key ? this.translate.instant(key) : ''; + } + default: + return ''; + } + } + + getSubtypeColorClass(): string { + switch (this.item.type) { + case ItemType.CALCULATED_FIELD: + switch (this.item.dataDescriptor?.cfType) { + case 'SIMPLE': return 'cf-simple'; + case 'SCRIPT': return 'cf-script'; + case 'GEOFENCING': return 'cf-geofencing'; + case 'ALARM': return 'cf-alarm'; + case 'PROPAGATION': return 'cf-propagation'; + case 'RELATED_ENTITIES_AGGREGATION': return 'cf-related-agg'; + case 'ENTITY_AGGREGATION': return 'cf-entity-agg'; + default: return 'cf-simple'; + } + case ItemType.RULE_CHAIN: + return this.item.dataDescriptor?.ruleChainType === 'EDGE' ? 'rc-edge' : 'rc-core'; + case ItemType.WIDGET: + switch (this.item.dataDescriptor?.widgetType) { + case 'timeseries': return 'wt-timeseries'; + case 'latest': return 'wt-latest'; + case 'rpc': return 'wt-rpc'; + case 'alarm': return 'wt-alarm'; + case 'static': return 'wt-static'; + default: return 'wt-static'; + } + default: + return ''; + } + } + + getNodeLabel(node: NodeInfo): string { + const key = nodeComponentTypeTranslations.get(node.type); + const typeLabel = key ? this.translate.instant(key) : node.type; + return `${node.name} (${typeLabel})`; + } + + getNodes(): NodeInfo[] { + return this.item.dataDescriptor?.nodes || []; + } + + getNodeCount(): number { + return this.item.dataDescriptor?.nodeCount || 0; + } + + install(): void { + this.dialog.open(TbIotHubInstallDialogComponent, { + panelClass: ['tb-dialog'], + data: { + item: this.item, + iotHubApiService: this.data.iotHubApiService + } as IotHubInstallDialogData + }); + } + + openSignup(): void { + window.open('https://iothub.thingsboard.io/signup', '_blank'); + } + + navigateToCreator(): void { + this.dialogRef.close(); + this.router.navigate(['/iot-hub/creator', this.item.creatorId]); + } + + close(): void { + this.dialogRef.close(); + } + + private loadReadme(): void { + const versionId = this.item.id as string; + this.data.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( + content => this.readmeContent = this.prefixResourceUrls(content || '') + ); + } + + private prefixResourceUrls(markdown: string): string { + const baseUrl = this.data.iotHubApiService.baseUrl; + return markdown.replace(/(\(|")(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts new file mode 100644 index 0000000000..93a8724d77 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts @@ -0,0 +1,63 @@ +/// +/// 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 { RouterModule, Routes } from '@angular/router'; + +import { Authority } from '@shared/models/authority.enum'; +import { TbIotHubBrowseComponent } from './iot-hub-browse.component'; +import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; + +const routes: Routes = [ + { + path: 'iot-hub', + data: { + auth: [Authority.TENANT_ADMIN], + breadcrumb: { + label: 'iot-hub.iot-hub', + icon: 'store' + } + }, + children: [ + { + path: '', + component: TbIotHubBrowseComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.browse' + } + }, + { + path: 'creator/:creatorId', + component: TbIotHubCreatorProfileComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.creator-profile', + breadcrumb: { + label: 'iot-hub.creator-profile', + icon: 'person' + } + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class IotHubRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts new file mode 100644 index 0000000000..be58e93f2c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts @@ -0,0 +1,41 @@ +/// +/// 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 { IotHubRoutingModule } from './iot-hub-routing.module'; +import { TbIotHubBrowseComponent } from './iot-hub-browse.component'; +import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; +import { TbIotHubItemDetailDialogComponent } from './iot-hub-item-detail-dialog.component'; +import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component'; +import { TbIotHubItemCardComponent } from './iot-hub-item-card.component'; + +@NgModule({ + declarations: [ + TbIotHubBrowseComponent, + TbIotHubCreatorProfileComponent, + TbIotHubItemDetailDialogComponent, + TbIotHubInstallDialogComponent, + TbIotHubItemCardComponent + ], + imports: [ + CommonModule, + SharedModule, + IotHubRoutingModule + ] +}) +export class IotHubModule { } diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts new file mode 100644 index 0000000000..16c6a2c409 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts @@ -0,0 +1,29 @@ +/// +/// 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 interface CreatorView { + id: string; + createdTime: number; + displayName: string; + contactEmail: string; + website: string; + description: string; + avatarUrl: string; + githubUrl: string; + linkedinUrl: string; + twitterUrl: string; + publishedCount: number; +} 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 new file mode 100644 index 0000000000..d67736f9ae --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts @@ -0,0 +1,174 @@ +/// +/// 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 enum ItemType { + WIDGET = 'WIDGET', + DASHBOARD = 'DASHBOARD', + CALCULATED_FIELD = 'CALCULATED_FIELD', + RULE_CHAIN = 'RULE_CHAIN', + DEVICE = 'DEVICE' +} + +export const itemTypeTranslations = new Map( + [ + [ItemType.WIDGET, 'item.type-widget'], + [ItemType.DASHBOARD, 'item.type-dashboard'], + [ItemType.CALCULATED_FIELD, 'item.type-calculated-field'], + [ItemType.RULE_CHAIN, 'item.type-rule-chain'], + [ItemType.DEVICE, 'item.type-device'] + ] +); + +export enum WidgetCategory { + CHARTS_GRAPHS = 'CHARTS_GRAPHS', + GAUGES_INDICATORS = 'GAUGES_INDICATORS', + CONTROLS = 'CONTROLS', + MAPS_LOCATION = 'MAPS_LOCATION', + TABLES_LISTS = 'TABLES_LISTS', + CARDS_INFO = 'CARDS_INFO', + SCADA = 'SCADA', + INPUT_FORMS = 'INPUT_FORMS' +} + +export const widgetCategoryTranslations = new Map([ + [WidgetCategory.CHARTS_GRAPHS, 'iot-hub.category.charts-graphs'], + [WidgetCategory.GAUGES_INDICATORS, 'iot-hub.category.gauges-indicators'], + [WidgetCategory.CONTROLS, 'iot-hub.category.controls'], + [WidgetCategory.MAPS_LOCATION, 'iot-hub.category.maps-location'], + [WidgetCategory.TABLES_LISTS, 'iot-hub.category.tables-lists'], + [WidgetCategory.CARDS_INFO, 'iot-hub.category.cards-info'], + [WidgetCategory.SCADA, 'iot-hub.category.scada'], + [WidgetCategory.INPUT_FORMS, 'iot-hub.category.input-forms'] +]); + +export enum DashboardCategory { + MONITORING = 'MONITORING', + ANALYTICS = 'ANALYTICS', + DEVICE_MANAGEMENT = 'DEVICE_MANAGEMENT', + USER_MANAGEMENT = 'USER_MANAGEMENT', + ASSET_MANAGEMENT = 'ASSET_MANAGEMENT', + SCADA = 'SCADA', + REPORTING = 'REPORTING', + OVERVIEW = 'OVERVIEW', + OPERATIONS = 'OPERATIONS' +} + +export const dashboardCategoryTranslations = new Map([ + [DashboardCategory.MONITORING, 'iot-hub.category.monitoring'], + [DashboardCategory.ANALYTICS, 'iot-hub.category.analytics'], + [DashboardCategory.DEVICE_MANAGEMENT, 'iot-hub.category.device-management'], + [DashboardCategory.USER_MANAGEMENT, 'iot-hub.category.user-management'], + [DashboardCategory.ASSET_MANAGEMENT, 'iot-hub.category.asset-management'], + [DashboardCategory.SCADA, 'iot-hub.category.scada'], + [DashboardCategory.REPORTING, 'iot-hub.category.reporting'], + [DashboardCategory.OVERVIEW, 'iot-hub.category.overview'], + [DashboardCategory.OPERATIONS, 'iot-hub.category.operations'] +]); + +export enum CalcFieldCategory { + AGGREGATION = 'AGGREGATION', + GEOSPATIAL = 'GEOSPATIAL', + STATISTICAL = 'STATISTICAL', + ENERGY = 'ENERGY', + ENVIRONMENTAL = 'ENVIRONMENTAL', + PREDICTIVE = 'PREDICTIVE', + CUSTOM_FORMULA = 'CUSTOM_FORMULA' +} + +export const calcFieldCategoryTranslations = new Map([ + [CalcFieldCategory.AGGREGATION, 'iot-hub.category.aggregation'], + [CalcFieldCategory.GEOSPATIAL, 'iot-hub.category.geospatial'], + [CalcFieldCategory.STATISTICAL, 'iot-hub.category.statistical'], + [CalcFieldCategory.ENERGY, 'iot-hub.category.energy'], + [CalcFieldCategory.ENVIRONMENTAL, 'iot-hub.category.environmental'], + [CalcFieldCategory.PREDICTIVE, 'iot-hub.category.predictive'], + [CalcFieldCategory.CUSTOM_FORMULA, 'iot-hub.category.custom-formula'] +]); + +export enum RuleChainCategory { + DATA_PROCESSING = 'DATA_PROCESSING', + ALERTING = 'ALERTING', + DEVICE_CONNECTIVITY = 'DEVICE_CONNECTIVITY', + INTEGRATION = 'INTEGRATION', + ANALYTICS = 'ANALYTICS' +} + +export const ruleChainCategoryTranslations = new Map([ + [RuleChainCategory.DATA_PROCESSING, 'iot-hub.category.data-processing'], + [RuleChainCategory.ALERTING, 'iot-hub.category.alerting'], + [RuleChainCategory.DEVICE_CONNECTIVITY, 'iot-hub.category.device-connectivity'], + [RuleChainCategory.INTEGRATION, 'iot-hub.category.integration'], + [RuleChainCategory.ANALYTICS, 'iot-hub.category.analytics'] +]); + +export enum DeviceCategory { + SENSORS = 'SENSORS', + GATEWAYS = 'GATEWAYS', + CONTROLLERS = 'CONTROLLERS', + ACTUATORS = 'ACTUATORS', + TRACKERS = 'TRACKERS', + METERS = 'METERS' +} + +export const deviceCategoryTranslations = new Map([ + [DeviceCategory.SENSORS, 'iot-hub.category.sensors'], + [DeviceCategory.GATEWAYS, 'iot-hub.category.gateways'], + [DeviceCategory.CONTROLLERS, 'iot-hub.category.controllers'], + [DeviceCategory.ACTUATORS, 'iot-hub.category.actuators'], + [DeviceCategory.TRACKERS, 'iot-hub.category.trackers'], + [DeviceCategory.METERS, 'iot-hub.category.meters'] +]); + +export enum UseCase { + SMART_HOME = 'SMART_HOME', + INDUSTRIAL_IOT = 'INDUSTRIAL_IOT', + ENERGY_MANAGEMENT = 'ENERGY_MANAGEMENT', + FLEET_MANAGEMENT = 'FLEET_MANAGEMENT', + AGRICULTURE = 'AGRICULTURE', + SMART_CITY = 'SMART_CITY', + HEALTHCARE = 'HEALTHCARE', + RETAIL = 'RETAIL', + WATER_UTILITIES = 'WATER_UTILITIES' +} + +export const useCaseTranslations = new Map([ + [UseCase.SMART_HOME, 'iot-hub.use-case.smart-home'], + [UseCase.INDUSTRIAL_IOT, 'iot-hub.use-case.industrial-iot'], + [UseCase.ENERGY_MANAGEMENT, 'iot-hub.use-case.energy-management'], + [UseCase.FLEET_MANAGEMENT, 'iot-hub.use-case.fleet-management'], + [UseCase.AGRICULTURE, 'iot-hub.use-case.agriculture'], + [UseCase.SMART_CITY, 'iot-hub.use-case.smart-city'], + [UseCase.HEALTHCARE, 'iot-hub.use-case.healthcare'], + [UseCase.RETAIL, 'iot-hub.use-case.retail'], + [UseCase.WATER_UTILITIES, 'iot-hub.use-case.water-utilities'] +]); + +export function getCategoriesForType(type: ItemType): Map { + switch (type) { + case ItemType.WIDGET: + return widgetCategoryTranslations as Map; + case ItemType.DASHBOARD: + return dashboardCategoryTranslations as Map; + case ItemType.CALCULATED_FIELD: + return calcFieldCategoryTranslations as Map; + case ItemType.RULE_CHAIN: + return ruleChainCategoryTranslations as Map; + case ItemType.DEVICE: + return deviceCategoryTranslations as Map; + default: + return new Map(); + } +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts new file mode 100644 index 0000000000..7ff055fc61 --- /dev/null +++ b/ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts @@ -0,0 +1,153 @@ +/// +/// 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 './iot-hub-item.models'; +import { PageLink } from '@shared/models/page/page-link'; + +export const widgetTypeTranslations = new Map([ + ['timeseries', 'item-data.widget-type-timeseries'], + ['latest', 'item-data.widget-type-latest'], + ['rpc', 'item-data.widget-type-rpc'], + ['alarm', 'item-data.widget-type-alarm'], + ['static', 'item-data.widget-type-static'], +]); + +export const cfTypeTranslations = new Map([ + ['SIMPLE', 'item-data.cf-type-simple'], + ['SCRIPT', 'item-data.cf-type-script'], + ['GEOFENCING', 'item-data.cf-type-geofencing'], + ['ALARM', 'item-data.cf-type-alarm'], + ['PROPAGATION', 'item-data.cf-type-propagation'], + ['RELATED_ENTITIES_AGGREGATION', 'item-data.cf-type-related-entities-aggregation'], + ['ENTITY_AGGREGATION', 'item-data.cf-type-entity-aggregation'], +]); + +export const cfTypeIcons = new Map([ + ['SIMPLE', 'calculate'], + ['SCRIPT', 'code'], + ['GEOFENCING', 'share_location'], + ['ALARM', 'notification_important'], + ['PROPAGATION', 'account_tree'], + ['RELATED_ENTITIES_AGGREGATION', 'hub'], + ['ENTITY_AGGREGATION', 'functions'], +]); + +export enum NodeComponentType { + ENRICHMENT = 'ENRICHMENT', + FILTER = 'FILTER', + TRANSFORMATION = 'TRANSFORMATION', + ACTION = 'ACTION', + ANALYTICS = 'ANALYTICS', + EXTERNAL = 'EXTERNAL', + FLOW = 'FLOW', + UNKNOWN = 'UNKNOWN' +} + +export const nodeComponentTypeTranslations = new Map([ + [NodeComponentType.ENRICHMENT, 'item-data.node-type-enrichment'], + [NodeComponentType.FILTER, 'item-data.node-type-filter'], + [NodeComponentType.TRANSFORMATION, 'item-data.node-type-transformation'], + [NodeComponentType.ACTION, 'item-data.node-type-action'], + [NodeComponentType.ANALYTICS, 'item-data.node-type-analytics'], + [NodeComponentType.EXTERNAL, 'item-data.node-type-external'], + [NodeComponentType.FLOW, 'item-data.node-type-flow'], + [NodeComponentType.UNKNOWN, 'item-data.node-type-unknown'], +]); + +export interface NodeInfo { + name: string; + type: NodeComponentType; +} + +export const ruleChainTypeTranslations = new Map([ + ['CORE', 'item-data.rule-chain-type-core'], + ['EDGE', 'item-data.rule-chain-type-edge'], +]); + +export interface MpItemVersionView { + id: string; + createdTime: number; + version: string; + publishedTime: number; + changelog: string; + dataDescriptor: any; + image: string; + icon: string; + color: string; + description: string; + categories: string[]; + useCases: string[]; + itemId: string; + creatorId: string; + name: string; + type: ItemType; + peOnly: boolean; + tags: string[]; + creatorDisplayName: string; + creatorWebsite: string; + creatorContactEmail: string; + creatorDescription: string; + creatorAvatarUrl: string; + installCount: number; + totalInstallCount: number; +} + +export class MpItemVersionQuery { + constructor( + public pageLink: PageLink, + public type?: string, + public peOnly?: boolean, + public creatorId?: string, + public categories?: string[], + public useCases?: string[], + public cfTypes?: string[], + public widgetTypes?: string[], + public ruleChainTypes?: string[], + public tbVersion?: number + ) {} + + public toQuery(): string { + let query = this.pageLink.toQuery(); + if (this.type) { + query += `&type=${this.type}`; + } + if (this.peOnly != null) { + query += `&peOnly=${this.peOnly}`; + } + if (this.creatorId) { + query += `&creatorId=${this.creatorId}`; + } + if (this.categories?.length) { + query += this.categories.map(c => `&categories=${encodeURIComponent(c)}`).join(''); + } + if (this.useCases?.length) { + query += this.useCases.map(u => `&useCases=${encodeURIComponent(u)}`).join(''); + } + if (this.cfTypes?.length) { + query += this.cfTypes.map(t => `&cfTypes=${encodeURIComponent(t)}`).join(''); + } + if (this.widgetTypes?.length) { + query += this.widgetTypes.map(t => `&widgetTypes=${encodeURIComponent(t)}`).join(''); + } + if (this.ruleChainTypes?.length) { + query += this.ruleChainTypes.map(t => `&ruleChainTypes=${encodeURIComponent(t)}`).join(''); + } + if (this.tbVersion != null) { + query += `&tbVersion=${this.tbVersion}`; + } + return query; + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ea11fff5a0..11f1c403dc 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3682,7 +3682,131 @@ } }, "item": { - "selected": "Selected" + "selected": "Selected", + "type-widget": "Widget", + "type-dashboard": "Dashboard", + "type-calculated-field": "Calculated Field", + "type-rule-chain": "Rule Chain", + "type-device": "Device" + }, + "iot-hub": { + "iot-hub": "IoT Hub", + "browse": "Browse IoT Hub", + "creator-profile": "Creator Profile", + "title-widgets": "Widgets", + "title-dashboards": "Dashboards", + "title-calculated-fields": "Calculated Fields", + "title-rule-chains": "Rule Chains", + "title-devices": "Devices", + "subtitle-widgets": "Browse community widgets for your dashboards", + "subtitle-dashboards": "Browse ready-to-use dashboards", + "subtitle-calculated-fields": "Browse calculated field templates", + "subtitle-rule-chains": "Browse rule chain templates", + "subtitle-devices": "Browse device configurations", + "search-widgets": "Search widgets...", + "search-dashboards": "Search dashboards...", + "search-calculated-fields": "Search calculated fields...", + "search-rule-chains": "Search rule chains...", + "search-devices": "Search devices...", + "sort-most-installed": "Most installed", + "sort-newest": "Newest", + "sort-name": "Name (A-Z)", + "type-filter-label": "Type", + "category": "Category", + "use-case": "Use case", + "clear-all-filters": "Clear all filters", + "results-count": "{{count}} results", + "unable-to-load": "Unable to load", + "unable-to-load-text": "There was a problem loading items. Please try again.", + "retry": "Retry", + "no-items-found": "No items found", + "no-items-found-text": "Try adjusting your search or filters.", + "become-a-creator": "Become a creator", + "back-to-iot-hub": "Back to IoT Hub", + "published-items": "Published items", + "by-creator": "by {{name}}", + "installs": "installs", + "more": "more", + "categories": "Categories", + "use-cases": "Use Cases", + "changelog": "Changelog", + "install-item-type": "Install {{type}}", + "created-by": "Created by {{name}}", + "install-item-title": "Install Item", + "install-confirm": "Install {{name}} v{{version}}?", + "install-type": "Type: {{type}}", + "install-creator": "Creator: {{creator}}", + "install": "Install", + "installing": "Installing...", + "installed": "Installed", + "install-success": "{{name}} installed successfully", + "install-error": "Failed to install {{name}}", + "category.charts-graphs": "Charts & Graphs", + "category.gauges-indicators": "Gauges & Indicators", + "category.controls": "Controls", + "category.maps-location": "Maps & Location", + "category.tables-lists": "Tables & Lists", + "category.cards-info": "Cards & Info", + "category.scada": "SCADA", + "category.input-forms": "Input Forms", + "category.monitoring": "Monitoring", + "category.analytics": "Analytics", + "category.device-management": "Device Management", + "category.user-management": "User Management", + "category.asset-management": "Asset Management", + "category.reporting": "Reporting", + "category.overview": "Overview", + "category.operations": "Operations", + "category.aggregation": "Aggregation", + "category.geospatial": "Geospatial", + "category.statistical": "Statistical", + "category.energy": "Energy", + "category.environmental": "Environmental", + "category.predictive": "Predictive", + "category.custom-formula": "Custom Formula", + "category.data-processing": "Data Processing", + "category.alerting": "Alerting", + "category.device-connectivity": "Device Connectivity", + "category.integration": "Integration", + "category.sensors": "Sensors", + "category.gateways": "Gateways", + "category.controllers": "Controllers", + "category.actuators": "Actuators", + "category.trackers": "Trackers", + "category.meters": "Meters", + "use-case.smart-home": "Smart Home", + "use-case.industrial-iot": "Industrial IoT", + "use-case.energy-management": "Energy Management", + "use-case.fleet-management": "Fleet Management", + "use-case.agriculture": "Agriculture", + "use-case.smart-city": "Smart City", + "use-case.healthcare": "Healthcare", + "use-case.retail": "Retail", + "use-case.water-utilities": "Water & Utilities" + }, + "item-data": { + "widget-type-timeseries": "Timeseries", + "widget-type-latest": "Latest", + "widget-type-rpc": "RPC", + "widget-type-alarm": "Alarm", + "widget-type-static": "Static", + "cf-type-simple": "Simple", + "cf-type-script": "Script", + "cf-type-geofencing": "Geofencing", + "cf-type-alarm": "Alarm", + "cf-type-propagation": "Propagation", + "cf-type-related-entities-aggregation": "Related Entities Aggregation", + "cf-type-entity-aggregation": "Entity Aggregation", + "rule-chain-type-core": "Core", + "rule-chain-type-edge": "Edge", + "node-type-enrichment": "Enrichment", + "node-type-filter": "Filter", + "node-type-transformation": "Transformation", + "node-type-action": "Action", + "node-type-analytics": "Analytics", + "node-type-external": "External", + "node-type-flow": "Flow", + "node-type-unknown": "Unknown" }, "js-func": { "no-return-error": "Function must return value!", From 285c8625536113c93458b7c6a98db8dfcadbc545 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 10 Mar 2026 12:20:45 +0200 Subject: [PATCH 002/219] Add IoT Hub installed items management - Full entity stack: IotHubInstalledItem with ID, descriptors, JPA entity, DAO (extends JpaAbstractDao), service - Controller endpoints: install, list (paginated), list infos, get by itemId, delete - Install flow saves descriptor with entity IDs, delete cascades to installed entities - Frontend installed items page with search, pagination, type chips, action buttons - Browse page shows "Installed" label on cards, detail dialog shows "Open details" for installed items - Installed item infos fetched on browse page init to track installation state --- .../main/data/upgrade/basic/schema_update.sql | 19 +- .../server/controller/IotHubController.java | 49 +++- .../service/iot_hub/DefaultIotHubService.java | 172 ++++++++++---- .../iot_hub/InstallItemVersionResult.java | 40 ++++ .../server/service/iot_hub/IotHubService.java | 7 +- .../common/data/id/IotHubInstalledItemId.java | 34 +++ ...alculatedFieldInstalledItemDescriptor.java | 26 +++ .../DashboardInstalledItemDescriptor.java | 26 +++ .../DeviceInstalledItemDescriptor.java | 23 ++ .../data/iot_hub/IotHubInstalledItem.java | 47 ++++ .../IotHubInstalledItemDescriptor.java | 38 ++++ .../data/iot_hub/IotHubInstalledItemInfo.java | 32 +++ .../RuleChainInstalledItemDescriptor.java | 26 +++ .../WidgetInstalledItemDescriptor.java | 26 +++ .../dao/iot_hub/IotHubInstalledItemDao.java | 41 ++++ .../iot_hub/IotHubInstalledItemService.java | 42 ++++ .../IotHubInstalledItemServiceImpl.java | 69 ++++++ .../server/dao/model/ModelConstants.java | 12 + .../model/sql/IotHubInstalledItemEntity.java | 110 +++++++++ .../IotHubInstalledItemRepository.java | 60 +++++ .../iot_hub/JpaIotHubInstalledItemDao.java | 100 +++++++++ .../server/dao/tenant/TenantServiceImpl.java | 3 + .../main/resources/sql/schema-entities.sql | 13 ++ .../src/app/core/http/iot-hub-api.service.ts | 37 +++ .../iot-hub/iot-hub-browse.component.html | 11 +- .../iot-hub/iot-hub-browse.component.scss | 3 + .../pages/iot-hub/iot-hub-browse.component.ts | 37 ++- .../iot-hub-install-dialog.component.ts | 25 ++- .../iot-hub-installed-items.component.html | 116 ++++++++++ .../iot-hub-installed-items.component.scss | 202 +++++++++++++++++ .../iot-hub-installed-items.component.ts | 210 ++++++++++++++++++ .../iot-hub/iot-hub-item-card.component.html | 6 +- .../iot-hub/iot-hub-item-card.component.scss | 10 + .../iot-hub/iot-hub-item-card.component.ts | 1 + .../iot-hub-item-detail-dialog.component.html | 12 +- .../iot-hub-item-detail-dialog.component.ts | 49 +++- .../pages/iot-hub/iot-hub-routing.module.ts | 13 ++ .../home/pages/iot-hub/iot-hub.module.ts | 4 +- .../iot-hub/iot-hub-installed-item.models.ts | 63 ++++++ .../assets/locale/locale.constant-en_US.json | 18 +- 40 files changed, 1763 insertions(+), 69 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts create mode 100644 ui-ngx/src/app/shared/models/iot-hub/iot-hub-installed-item.models.ts diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 98c7a93c52..3df3837944 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -43,4 +43,21 @@ DROP INDEX IF EXISTS idx_widgets_bundle_external_id; -- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END -ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); \ No newline at end of file +ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); + +-- IOT HUB INSTALLED ITEM START + +CREATE TABLE IF NOT EXISTS iot_hub_installed_item ( + id UUID NOT NULL PRIMARY KEY, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + item_id UUID NOT NULL, + item_version_id UUID NOT NULL, + item_name VARCHAR NOT NULL, + item_type VARCHAR NOT NULL, + version VARCHAR NOT NULL, + descriptor JSONB NOT NULL, + CONSTRAINT iot_hub_installed_item_item_id_unq_key UNIQUE (tenant_id, item_id) +); + +-- IOT HUB INSTALLED ITEM END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java index eef17d8c96..e2b6b297ab 100644 --- a/application/src/main/java/org/thingsboard/server/controller/IotHubController.java +++ b/application/src/main/java/org/thingsboard/server/controller/IotHubController.java @@ -15,18 +15,29 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Hidden; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.List; +import java.util.UUID; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.iot_hub.InstallItemVersionResult; import org.thingsboard.server.service.iot_hub.IotHubService; @Hidden @@ -38,11 +49,45 @@ import org.thingsboard.server.service.iot_hub.IotHubService; public class IotHubController extends BaseController { private final IotHubService iotHubService; + private final IotHubInstalledItemService iotHubInstalledItemService; @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/versions/{versionId}/install") @ResponseBody - public JsonNode installItemVersion(@PathVariable String versionId) throws Exception { + public InstallItemVersionResult installItemVersion(@PathVariable String versionId) throws ThingsboardException { return iotHubService.installItemVersion(getCurrentUser(), versionId); } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems") + @ResponseBody + public PageData getInstalledItems(@RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return iotHubInstalledItemService.findByTenantId(getTenantId(), pageLink); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems/byItemId/{itemId}") + @ResponseBody + public IotHubInstalledItem getInstalledItemByItemId(@PathVariable UUID itemId) throws ThingsboardException { + return iotHubInstalledItemService.findByTenantIdAndItemId(getTenantId(), itemId).orElse(null); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/installedItems/info") + @ResponseBody + public List getInstalledItemInfos() throws ThingsboardException { + return iotHubInstalledItemService.findInstalledItemInfosByTenantId(getTenantId()); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/installedItems/{itemId}") + @ResponseBody + public void deleteInstalledItem(@PathVariable UUID itemId) throws ThingsboardException { + iotHubService.deleteInstalledItem(getCurrentUser(), itemId); + } } 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 173ae79020..bef8cfdd10 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 @@ -24,8 +24,23 @@ import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.CalculatedFieldInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.DeviceInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.RuleChainInstalledItemDescriptor; +import org.thingsboard.server.common.data.iot_hub.WidgetInstalledItemDescriptor; +import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainData; +import org.thingsboard.server.common.data.rule.RuleChainImportResult; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; @@ -33,7 +48,9 @@ import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileServ import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; import org.thingsboard.server.service.rule.TbRuleChainService; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.dao.rule.RuleChainService; + +import java.util.List; +import java.util.UUID; @Service @TbCoreComponent @@ -48,81 +65,148 @@ public class DefaultIotHubService implements IotHubService { private final RuleChainService ruleChainService; private final TbRuleChainService tbRuleChainService; private final TbDeviceProfileService tbDeviceProfileService; + private final IotHubInstalledItemService iotHubInstalledItemService; + private final WidgetTypeService widgetTypeService; + private final DashboardService dashboardService; + private final CalculatedFieldService calculatedFieldService; + private final DeviceProfileService deviceProfileService; @Override - public JsonNode installItemVersion(SecurityUser user, String versionId) throws Exception { + public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId) { TenantId tenantId = user.getTenantId(); log.info("[{}] Installing IoT Hub item version: {}", tenantId, versionId); - JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); - String itemType = versionInfo.get("type").asText(); - String itemName = versionInfo.get("name").asText(); - log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType); - - byte[] fileData = iotHubRestClient.getVersionFileData(versionId); - log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); - - switch (itemType) { - case "WIDGET": - installWidget(user, tenantId, fileData); - break; - case "DASHBOARD": - installDashboard(user, tenantId, fileData); - break; - case "CALCULATED_FIELD": - installCalculatedField(user, tenantId, fileData); - break; - case "RULE_CHAIN": - installRuleChain(tenantId, fileData); - break; - case "DEVICE": - installDeviceProfile(user, tenantId, fileData); - break; - default: - throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType); - } + try { + JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId); + String itemType = versionInfo.get("type").asText(); + String itemName = versionInfo.get("name").asText(); + UUID itemId = UUID.fromString(versionInfo.get("itemId").asText()); + String version = versionInfo.get("version").asText(); + log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType); + + if (iotHubInstalledItemService.findByTenantIdAndItemId(tenantId, itemId).isPresent()) { + return InstallItemVersionResult.error("Item '" + itemName + "' is already installed"); + } - iotHubRestClient.reportVersionInstalled(versionId); - log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); + byte[] fileData = iotHubRestClient.getVersionFileData(versionId); + log.debug("[{}] Fetched file data, size: {} bytes", tenantId, fileData != null ? fileData.length : 0); - return versionInfo; + IotHubInstalledItemDescriptor descriptor = switch (itemType) { + case "WIDGET" -> installWidget(user, tenantId, fileData); + case "DASHBOARD" -> installDashboard(user, tenantId, fileData); + case "CALCULATED_FIELD" -> installCalculatedField(user, tenantId, fileData); + case "RULE_CHAIN" -> installRuleChain(tenantId, fileData); + case "DEVICE" -> installDeviceProfile(user, tenantId, fileData); + default -> throw new IllegalArgumentException("Unsupported IoT Hub item type: " + itemType); + }; + + IotHubInstalledItem installedItem = new IotHubInstalledItem(); + installedItem.setTenantId(tenantId); + installedItem.setItemId(itemId); + installedItem.setItemVersionId(UUID.fromString(versionId)); + installedItem.setItemName(itemName); + installedItem.setItemType(itemType); + installedItem.setVersion(version); + installedItem.setDescriptor(descriptor); + iotHubInstalledItemService.save(tenantId, installedItem); + + iotHubRestClient.reportVersionInstalled(versionId); + log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); + + return InstallItemVersionResult.success(descriptor); + } catch (Exception e) { + log.error("[{}] Failed to install IoT Hub item version: {}", tenantId, versionId, e); + return InstallItemVersionResult.error(e.getMessage()); + } } - private void installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + private WidgetInstalledItemDescriptor installWidget(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { WidgetTypeDetails widgetTypeDetails = JacksonUtil.fromBytes(fileData, WidgetTypeDetails.class); widgetTypeDetails.setId(null); widgetTypeDetails.setTenantId(tenantId); - tbWidgetTypeService.save(widgetTypeDetails, true, user); - log.debug("[{}] Widget installed: {}", tenantId, widgetTypeDetails.getName()); + WidgetTypeDetails saved = tbWidgetTypeService.save(widgetTypeDetails, true, user); + log.debug("[{}] Widget installed: {}", tenantId, saved.getName()); + WidgetInstalledItemDescriptor descriptor = new WidgetInstalledItemDescriptor(); + descriptor.setWidgetTypeId(saved.getId()); + return descriptor; } - private void installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + private DashboardInstalledItemDescriptor installDashboard(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { Dashboard dashboard = JacksonUtil.fromBytes(fileData, Dashboard.class); dashboard.setId(null); dashboard.setTenantId(tenantId); - tbDashboardService.save(dashboard, user); - log.debug("[{}] Dashboard installed: {}", tenantId, dashboard.getTitle()); + Dashboard saved = tbDashboardService.save(dashboard, user); + log.debug("[{}] Dashboard installed: {}", tenantId, saved.getTitle()); + DashboardInstalledItemDescriptor descriptor = new DashboardInstalledItemDescriptor(); + descriptor.setDashboardId(saved.getId()); + return descriptor; } - private void installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + private CalculatedFieldInstalledItemDescriptor installCalculatedField(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { CalculatedField calculatedField = JacksonUtil.fromBytes(fileData, CalculatedField.class); calculatedField.setId(null); calculatedField.setTenantId(tenantId); - tbCalculatedFieldService.save(calculatedField, user); - log.debug("[{}] Calculated field installed: {}", tenantId, calculatedField.getName()); + CalculatedField saved = tbCalculatedFieldService.save(calculatedField, user); + log.debug("[{}] Calculated field installed: {}", tenantId, saved.getName()); + CalculatedFieldInstalledItemDescriptor descriptor = new CalculatedFieldInstalledItemDescriptor(); + descriptor.setCalculatedFieldId(saved.getId()); + return descriptor; } - private void installRuleChain(TenantId tenantId, byte[] fileData) { + private RuleChainInstalledItemDescriptor installRuleChain(TenantId tenantId, byte[] fileData) { RuleChainData ruleChainData = JacksonUtil.fromBytes(fileData, RuleChainData.class); - ruleChainService.importTenantRuleChains(tenantId, ruleChainData, false, tbRuleChainService::updateRuleNodeConfiguration); + List results = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, false, tbRuleChainService::updateRuleNodeConfiguration); log.debug("[{}] Rule chain(s) installed", tenantId); + RuleChainInstalledItemDescriptor descriptor = new RuleChainInstalledItemDescriptor(); + if (!results.isEmpty()) { + descriptor.setRuleChainId(results.get(0).getRuleChainId()); + } + return descriptor; } - private void installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { + private DeviceInstalledItemDescriptor installDeviceProfile(SecurityUser user, TenantId tenantId, byte[] fileData) throws Exception { DeviceProfile deviceProfile = JacksonUtil.fromBytes(fileData, DeviceProfile.class); deviceProfile.setId(null); deviceProfile.setTenantId(tenantId); tbDeviceProfileService.save(deviceProfile, user); log.debug("[{}] Device profile installed: {}", tenantId, deviceProfile.getName()); + return new DeviceInstalledItemDescriptor(); + } + + @Override + public void deleteInstalledItem(SecurityUser user, UUID itemId) { + TenantId tenantId = user.getTenantId(); + IotHubInstalledItem installedItem = iotHubInstalledItemService.findByTenantIdAndItemId(tenantId, itemId) + .orElseThrow(() -> new IllegalArgumentException("Installed item not found")); + + IotHubInstalledItemDescriptor descriptor = installedItem.getDescriptor(); + if (descriptor instanceof WidgetInstalledItemDescriptor wd) { + WidgetTypeDetails widgetType = widgetTypeService.findWidgetTypeDetailsById(tenantId, wd.getWidgetTypeId()); + if (widgetType != null) { + tbWidgetTypeService.delete(widgetType, user); + } + } else if (descriptor instanceof DashboardInstalledItemDescriptor dd) { + Dashboard dashboard = dashboardService.findDashboardById(tenantId, dd.getDashboardId()); + if (dashboard != null) { + tbDashboardService.delete(dashboard, user); + } + } else if (descriptor instanceof CalculatedFieldInstalledItemDescriptor cd) { + CalculatedField calculatedField = calculatedFieldService.findById(tenantId, cd.getCalculatedFieldId()); + if (calculatedField != null) { + tbCalculatedFieldService.delete(calculatedField, user); + } + } else if (descriptor instanceof RuleChainInstalledItemDescriptor rd) { + if (rd.getRuleChainId() != null) { + RuleChain ruleChain = ruleChainService.findRuleChainById(tenantId, rd.getRuleChainId()); + if (ruleChain != null) { + tbRuleChainService.delete(ruleChain, user); + } + } + } else if (descriptor instanceof DeviceInstalledItemDescriptor) { + // no entity to delete for now + } + + iotHubInstalledItemService.deleteByTenantIdAndItemId(tenantId, itemId); + log.info("[{}] Deleted installed IoT Hub item: {}", tenantId, installedItem.getItemName()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java new file mode 100644 index 0000000000..f91085cfd6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallItemVersionResult.java @@ -0,0 +1,40 @@ +/** + * 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. + */ +package org.thingsboard.server.service.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemDescriptor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstallItemVersionResult { + + private boolean success; + private String errorMessage; + private IotHubInstalledItemDescriptor descriptor; + + public static InstallItemVersionResult success(IotHubInstalledItemDescriptor descriptor) { + return new InstallItemVersionResult(true, null, descriptor); + } + + public static InstallItemVersionResult error(String errorMessage) { + return new InstallItemVersionResult(false, errorMessage, null); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java index 9a92825d82..f0cfc6c49d 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.service.iot_hub; -import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.service.security.model.SecurityUser; +import java.util.UUID; + public interface IotHubService { - JsonNode installItemVersion(SecurityUser user, String versionId) throws Exception; + InstallItemVersionResult installItemVersion(SecurityUser user, String versionId); + + void deleteInstalledItem(SecurityUser user, UUID itemId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java new file mode 100644 index 0000000000..f9ea6c4443 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/IotHubInstalledItemId.java @@ -0,0 +1,34 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public class IotHubInstalledItemId extends UUIDBased { + + @JsonCreator + public IotHubInstalledItemId(@JsonProperty("id") UUID id) { + super(id); + } + + public static IotHubInstalledItemId fromString(String id) { + return new IotHubInstalledItemId(UUID.fromString(id)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java new file mode 100644 index 0000000000..b47b9f8cc8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/CalculatedFieldInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.CalculatedFieldId; + +@Data +public class CalculatedFieldInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private CalculatedFieldId calculatedFieldId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java new file mode 100644 index 0000000000..260665b82f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DashboardInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DashboardId; + +@Data +public class DashboardInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private DashboardId dashboardId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java new file mode 100644 index 0000000000..b558058db5 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/DeviceInstalledItemDescriptor.java @@ -0,0 +1,23 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; + +@Data +public class DeviceInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java new file mode 100644 index 0000000000..8152ec1b0d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItem.java @@ -0,0 +1,47 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.IotHubInstalledItemId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +public class IotHubInstalledItem extends BaseData implements HasTenantId { + + private TenantId tenantId; + private UUID itemId; + private UUID itemVersionId; + private String itemName; + private String itemType; + private String version; + private IotHubInstalledItemDescriptor descriptor; + + public IotHubInstalledItem() { + super(); + } + + public IotHubInstalledItem(IotHubInstalledItemId id) { + super(id); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java new file mode 100644 index 0000000000..e08b686515 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemDescriptor.java @@ -0,0 +1,38 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.io.Serializable; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type") +@JsonSubTypes({ + @Type(name = "WIDGET", value = WidgetInstalledItemDescriptor.class), + @Type(name = "DASHBOARD", value = DashboardInstalledItemDescriptor.class), + @Type(name = "CALCULATED_FIELD", value = CalculatedFieldInstalledItemDescriptor.class), + @Type(name = "RULE_CHAIN", value = RuleChainInstalledItemDescriptor.class), + @Type(name = "DEVICE", value = DeviceInstalledItemDescriptor.class) +}) +public interface IotHubInstalledItemDescriptor extends Serializable { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemInfo.java new file mode 100644 index 0000000000..19eb74dccb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/IotHubInstalledItemInfo.java @@ -0,0 +1,32 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class IotHubInstalledItemInfo { + + private UUID itemId; + private UUID itemVersionId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java new file mode 100644 index 0000000000..f684607759 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/RuleChainInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.RuleChainId; + +@Data +public class RuleChainInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private RuleChainId ruleChainId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java new file mode 100644 index 0000000000..5c4195cb51 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/iot_hub/WidgetInstalledItemDescriptor.java @@ -0,0 +1,26 @@ +/** + * 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. + */ +package org.thingsboard.server.common.data.iot_hub; + +import lombok.Data; +import org.thingsboard.server.common.data.id.WidgetTypeId; + +@Data +public class WidgetInstalledItemDescriptor implements IotHubInstalledItemDescriptor { + + private WidgetTypeId widgetTypeId; + +} 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 new file mode 100644 index 0000000000..300b1cd620 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemDao.java @@ -0,0 +1,41 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.iot_hub; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IotHubInstalledItemDao extends Dao { + + Optional findByTenantIdAndItemId(TenantId tenantId, UUID itemId); + + PageData findByTenantId(TenantId tenantId, PageLink pageLink); + + List findInstalledItemInfosByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndItemId(TenantId tenantId, UUID itemId); + + void deleteByTenantId(TenantId tenantId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java new file mode 100644 index 0000000000..1cee8f10a2 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemService.java @@ -0,0 +1,42 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.iot_hub; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IotHubInstalledItemService { + + IotHubInstalledItem save(TenantId tenantId, IotHubInstalledItem item); + + Optional findByTenantIdAndItemId(TenantId tenantId, UUID itemId); + + PageData findByTenantId(TenantId tenantId, PageLink pageLink); + + List findInstalledItemInfosByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndItemId(TenantId tenantId, UUID itemId); + + void deleteByTenantId(TenantId tenantId); + +} 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 new file mode 100644 index 0000000000..37593df30b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/iot_hub/IotHubInstalledItemServiceImpl.java @@ -0,0 +1,69 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.iot_hub; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +class IotHubInstalledItemServiceImpl implements IotHubInstalledItemService { + + private final IotHubInstalledItemDao iotHubInstalledItemDao; + + @Override + public IotHubInstalledItem save(TenantId tenantId, IotHubInstalledItem item) { + log.debug("[{}] Saving IoT Hub installed item: {}", tenantId, item); + return iotHubInstalledItemDao.save(tenantId, item); + } + + @Override + public Optional findByTenantIdAndItemId(TenantId tenantId, UUID itemId) { + return iotHubInstalledItemDao.findByTenantIdAndItemId(tenantId, itemId); + } + + @Override + public PageData findByTenantId(TenantId tenantId, PageLink pageLink) { + return iotHubInstalledItemDao.findByTenantId(tenantId, pageLink); + } + + @Override + public List findInstalledItemInfosByTenantId(TenantId tenantId) { + return iotHubInstalledItemDao.findInstalledItemInfosByTenantId(tenantId); + } + + @Override + public boolean deleteByTenantIdAndItemId(TenantId tenantId, UUID itemId) { + return iotHubInstalledItemDao.deleteByTenantIdAndItemId(tenantId, itemId); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + iotHubInstalledItemDao.deleteByTenantId(tenantId); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index de1a04e81e..d0573d0b69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -760,6 +760,18 @@ public class ModelConstants { public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; + /** + * IoT Hub installed item constants. + */ + public static final String IOT_HUB_INSTALLED_ITEM_TABLE_NAME = "iot_hub_installed_item"; + public static final String IOT_HUB_INSTALLED_ITEM_TENANT_ID_COLUMN = TENANT_ID_COLUMN; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_ID_COLUMN = "item_id"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_VERSION_ID_COLUMN = "item_version_id"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_NAME_COLUMN = "item_name"; + public static final String IOT_HUB_INSTALLED_ITEM_ITEM_TYPE_COLUMN = "item_type"; + public static final String IOT_HUB_INSTALLED_ITEM_VERSION_COLUMN = "version"; + public static final String IOT_HUB_INSTALLED_ITEM_DESCRIPTOR_COLUMN = "descriptor"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java new file mode 100644 index 0000000000..e0cd2f1c17 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/IotHubInstalledItemEntity.java @@ -0,0 +1,110 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.model.sql; + +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Type; +import org.hibernate.proxy.HibernateProxy; +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.iot_hub.IotHubInstalledItemDescriptor; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.Objects; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_TABLE_NAME) +public class IotHubInstalledItemEntity extends BaseSqlEntity { + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_TENANT_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID tenantId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID itemId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_VERSION_ID_COLUMN, nullable = false, columnDefinition = "UUID") + private UUID itemVersionId; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_NAME_COLUMN, nullable = false) + private String itemName; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_ITEM_TYPE_COLUMN, nullable = false) + private String itemType; + + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_VERSION_COLUMN, nullable = false) + private String version; + + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.IOT_HUB_INSTALLED_ITEM_DESCRIPTOR_COLUMN, nullable = false, columnDefinition = "JSONB") + private IotHubInstalledItemDescriptor descriptor; + + public IotHubInstalledItemEntity() { + } + + public IotHubInstalledItemEntity(IotHubInstalledItem item) { + super(item); + tenantId = getTenantUuid(item.getTenantId()); + itemId = item.getItemId(); + itemVersionId = item.getItemVersionId(); + itemName = item.getItemName(); + itemType = item.getItemType(); + version = item.getVersion(); + descriptor = item.getDescriptor(); + } + + @Override + public IotHubInstalledItem toData() { + var item = new IotHubInstalledItem(new IotHubInstalledItemId(id)); + item.setCreatedTime(createdTime); + item.setTenantId(TenantId.fromUUID(tenantId)); + item.setItemId(itemId); + item.setItemVersionId(itemVersionId); + item.setItemName(itemName); + item.setItemType(itemType); + item.setVersion(version); + item.setDescriptor(descriptor); + return item; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + IotHubInstalledItemEntity that = (IotHubInstalledItemEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + +} 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 new file mode 100644 index 0000000000..fa4b98da6d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/IotHubInstalledItemRepository.java @@ -0,0 +1,60 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.iot_hub; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +interface IotHubInstalledItemRepository extends JpaRepository { + + Optional findByTenantIdAndItemId(UUID tenantId, UUID itemId); + + @Query("SELECT new org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo(item.itemId, item.itemVersionId) " + + "FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId") + List findInstalledItemInfosByTenantId(@Param("tenantId") UUID tenantId); + + @Query(""" + SELECT item FROM IotHubInstalledItemEntity item + WHERE item.tenantId = :tenantId + AND (:textSearch IS NULL + OR ilike(item.itemName, CONCAT('%', :textSearch, '%')) = true + OR ilike(item.itemType, CONCAT('%', :textSearch, '%')) = true + OR ilike(item.version, CONCAT('%', :textSearch, '%')) = true) + """) + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); + + @Transactional + @Modifying + @Query("DELETE FROM IotHubInstalledItemEntity item WHERE item.tenantId = :tenantId AND item.itemId = :itemId") + int deleteByTenantIdAndItemId(@Param("tenantId") UUID tenantId, @Param("itemId") UUID itemId); + + @Transactional + @Modifying + @Query(value = "DELETE FROM iot_hub_installed_item WHERE tenant_id = :tenantId", nativeQuery = true) + void deleteByTenantId(@Param("tenantId") UUID tenantId); + +} 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 new file mode 100644 index 0000000000..265026d710 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/iot_hub/JpaIotHubInstalledItemDao.java @@ -0,0 +1,100 @@ +/** + * 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. + */ +package org.thingsboard.server.dao.sql.iot_hub; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +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.TenantId; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem; +import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItemInfo; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemDao; +import org.thingsboard.server.dao.model.sql.IotHubInstalledItemEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaIotHubInstalledItemDao extends JpaAbstractDao implements IotHubInstalledItemDao { + + private final IotHubInstalledItemRepository repository; + + @Override + public Optional findByTenantIdAndItemId(TenantId tenantId, UUID itemId) { + return repository.findByTenantIdAndItemId(tenantId.getId(), itemId).map(DaoUtil::getData); + } + + @Override + public PageData findByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(repository.findByTenantId( + tenantId.getId(), + StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), + toPageRequest(pageLink)) + ); + } + + @Override + public boolean deleteByTenantIdAndItemId(TenantId tenantId, UUID itemId) { + return repository.deleteByTenantIdAndItemId(tenantId.getId(), itemId) > 0; + } + + @Override + public List findInstalledItemInfosByTenantId(TenantId tenantId) { + return repository.findInstalledItemInfosByTenantId(tenantId.getId()); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + repository.deleteByTenantId(tenantId.getId()); + } + + private static PageRequest toPageRequest(PageLink pageLink) { + Sort sort; + SortOrder sortOrder = pageLink.getSortOrder(); + if (sortOrder == null) { + sort = Sort.by(Sort.Direction.DESC, "createdTime"); + } else { + sort = Sort.by( + Sort.Direction.fromString(sortOrder.getDirection().name()), + sortOrder.getProperty() + ).and(Sort.by(Sort.Direction.ASC, "id")); + } + return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); + } + + @Override + protected Class getEntityClass() { + return IotHubInstalledItemEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + +} 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 3674804493..e7939ea32e 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 @@ -83,6 +83,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache; @@ -168,6 +170,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService { + return this.http.get( + `${this.baseUrl}/api/versions/${versionId}`, + { params: this.buildParams(config) } + ); + } + public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable { return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, { params: this.buildParams(config), @@ -111,6 +120,34 @@ export class IotHubApiService { ); } + public getInstalledItemByItemId(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `/api/iot-hub/installedItems/byItemId/${itemId}`, + { params: this.buildParams(config) } + ); + } + + public getInstalledItemInfos(config?: IotHubRequestConfig): Observable { + return this.http.get( + `/api/iot-hub/installedItems/info`, + { params: this.buildParams(config) } + ); + } + + public getInstalledItems(pageLink: PageLink, config?: IotHubRequestConfig): Observable> { + return this.http.get>( + `/api/iot-hub/installedItems${pageLink.toQuery()}`, + { params: this.buildParams(config) } + ); + } + + public deleteInstalledItem(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.delete( + `/api/iot-hub/installedItems/${itemId}`, + { params: this.buildParams(config) } + ); + } + public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable { return this.http.get( `${this.baseUrl}/api/creators/${creatorId}/profile`, diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html index b9f4bf4d75..83cf44e087 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html @@ -19,8 +19,14 @@ @if (!embedded) {
-

{{ getTitle() | translate }}

-

{{ getSubtitle() | translate }}

+
+

{{ getTitle() | translate }}

+

{{ getSubtitle() | translate }}

+
+
} @@ -187,6 +193,7 @@ [item]="item" [showTypeChip]="false" [showCreator]="!creatorId" + [installed]="isInstalled(item)" (cardClick)="openItemDetail($event)" (creatorClick)="navigateToCreator($event)" (installClick)="installItem($event)"> diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss index 110133e5ff..deef2d98bc 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss @@ -47,6 +47,9 @@ } .tb-iot-hub-browse-header { + display: flex; + justify-content: space-between; + align-items: flex-start; margin-bottom: 24px; h1 { diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts index 517e91ef8c..004dac5fde 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts @@ -27,6 +27,7 @@ import { } from '@shared/models/iot-hub/iot-hub-item.models'; import { cfTypeTranslations, widgetTypeTranslations, ruleChainTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubInstalledItemInfo } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { MatDialog } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { Router } from '@angular/router'; @@ -89,6 +90,8 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { widgetTypes: Map = widgetTypeTranslations; ruleChainTypes: Map = ruleChainTypeTranslations; + installedItemsMap = new Map(); + private searchSubject = new Subject(); private destroy$ = new Subject(); @@ -108,6 +111,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { this.loadItems(); }); this.updateCategories(); + this.loadInstalledItemInfos(); this.loadItems(); } @@ -322,31 +326,58 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy { } } + isInstalled(item: MpItemVersionView): boolean { + return this.installedItemsMap.has(item.itemId); + } + openItemDetail(item: MpItemVersionView): void { - this.dialog.open(TbIotHubItemDetailDialogComponent, { + const installedInfo = this.installedItemsMap.get(item.itemId); + const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], autoFocus: false, data: { item, - iotHubApiService: this.iotHubApiService + iotHubApiService: this.iotHubApiService, + installedItemInfo: installedInfo } as IotHubItemDetailDialogData }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.loadInstalledItemInfos(); + } + }); } installItem(item: MpItemVersionView): void { - this.dialog.open(TbIotHubInstallDialogComponent, { + const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, { panelClass: ['tb-dialog'], data: { item, iotHubApiService: this.iotHubApiService } as IotHubInstallDialogData }); + dialogRef.afterClosed().subscribe(result => { + if (result === 'installed') { + this.loadInstalledItemInfos(); + } + }); } navigateToCreator(creatorId: string): void { this.router.navigate(['/iot-hub/creator', creatorId]); } + navigateToInstalledItems(): void { + this.router.navigate(['/iot-hub/installed']); + } + + private loadInstalledItemInfos(): void { + this.iotHubApiService.getInstalledItemInfos({ignoreLoading: true}).subscribe(infos => { + this.installedItemsMap.clear(); + infos.forEach(info => this.installedItemsMap.set(info.itemId, info)); + }); + } + private updateCategories(): void { this.categories = getCategoriesForType(this.activeType); } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts index f8132ec5c5..0fe5dd867b 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts @@ -97,14 +97,23 @@ export class TbIotHubInstallDialogComponent { this.state = 'installing'; const versionId = this.item.id as string; this.data.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({ - next: () => { - this.state = 'success'; - this.store.dispatch(new ActionNotificationShow({ - message: this.translate.instant('iot-hub.install-success', { name: this.item.name }), - type: 'success', - duration: 3000 - })); - setTimeout(() => this.dialogRef.close(true), 1500); + next: (result) => { + if (result.success) { + this.state = 'success'; + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.install-success', { name: this.item.name }), + type: 'success', + duration: 3000 + })); + setTimeout(() => this.dialogRef.close('installed'), 1500); + } else { + this.state = 'confirm'; + this.store.dispatch(new ActionNotificationShow({ + message: result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name }), + type: 'error', + duration: 5000 + })); + } }, error: () => { this.state = 'confirm'; diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html new file mode 100644 index 0000000000..b76b8301a8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html @@ -0,0 +1,116 @@ + +
+ + arrow_back + {{ 'iot-hub.back-to-marketplace' | translate }} + +
+

{{ 'iot-hub.installed-items' | translate }}

+
+ +
+ + search + + @if (textSearch) { + + } + +
+ +
+ @if (isLoading) { + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'iot-hub.item-name' | translate }}{{ item.itemName }}{{ 'iot-hub.item-type' | translate }} + {{ getItemTypeLabel(item.itemType) }} + {{ 'iot-hub.item-version' | translate }}{{ item.version }}{{ 'iot-hub.installed-date' | translate }}{{ item.createdTime | date:'medium' }} + + @if (getEntityId(item)) { + + } + +
+
+ {{ 'iot-hub.no-installed-items' | translate }} +
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss new file mode 100644 index 0000000000..b8d0596781 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss @@ -0,0 +1,202 @@ +/** + * 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: flex; + flex-direction: column; + height: 100%; + padding: 16px 24px; + overflow: auto; +} + +.tb-iot-hub-installed-items-container { + max-width: 1400px; + margin: 0 auto; + width: 100%; + background: white; + padding: 24px; + border-radius: 4px; + box-shadow: 0 0 1px rgba(0, 0, 0, 0.14), 0 1px 1px rgba(0, 0, 0, 0.12); +} + +.tb-iot-hub-back-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: rgba(0, 0, 0, 0.54); + cursor: pointer; + text-decoration: none; + border-bottom: none; + margin-bottom: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + color: rgba(0, 0, 0, 0.87); + text-decoration: none; + border-bottom: none; + } +} + +.tb-iot-hub-installed-items-header { + margin-bottom: 24px; + + h2 { + font-size: 28px; + font-weight: 700; + margin: 0; + color: rgba(0, 0, 0, 0.87); + } +} + +.tb-iot-hub-installed-search-row { + display: flex; + gap: 12px; + margin-bottom: 16px; + align-items: center; +} + +.tb-iot-hub-installed-search-field { + flex: 1; + + ::ng-deep { + .mat-mdc-text-field-wrapper { + height: 42px; + padding: 0 12px; + } + + .mat-mdc-form-field-flex { + height: 42px; + align-items: center; + } + + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #d5dbe3; + } + + .mdc-notched-outline__leading { + border-radius: 8px 0 0 8px; + } + + .mdc-notched-outline__trailing { + border-radius: 0 8px 8px 0; + } + + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 0; + min-height: unset; + } + + .mdc-notched-outline__notch { + display: none; + } + + .mat-mdc-floating-label { + display: none; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + input.mat-mdc-input-element { + font-size: 14px; + color: #3e4a59; + + &::placeholder { + color: #8b95a2; + opacity: 1; + } + } + + .mat-icon { + color: #8b95a2; + font-size: 20px; + width: 20px; + height: 20px; + } + + .mat-mdc-form-field-icon-prefix { + padding: 0 8px 0 0; + + > .mat-icon { + padding: 0; + } + } + + .mat-mdc-form-field-icon-suffix { + padding: 0; + + .mat-mdc-icon-button { + width: 32px; + height: 32px; + padding: 4px; + } + } + } + + &.mat-focused ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: #8b95a2; + border-width: 1px; + } + } +} + +.tb-iot-hub-installed-table-container { + border-radius: 4px; + overflow: hidden; + + table { + width: 100%; + } + + .mat-column-actions { + text-align: right; + white-space: nowrap; + } +} + +.tb-iot-hub-type-chip { + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + + &.tb-type-widget { background: #e3f2fd; color: #1565c0; } + &.tb-type-dashboard { background: #e8f5e9; color: #2e7d32; } + &.tb-type-calc-field { background: #fff3e0; color: #e65100; } + &.tb-type-rule-chain { background: #f3e5f5; color: #7b1fa2; } + &.tb-type-device { background: #eceff1; color: #455a64; } +} + +.tb-iot-hub-installed-empty { + padding: 48px 24px; + text-align: center; + color: rgba(0, 0, 0, 0.54); + font-size: 14px; +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts new file mode 100644 index 0000000000..0c2902856f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts @@ -0,0 +1,210 @@ +/// +/// 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, AfterViewInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatPaginator } from '@angular/material/paginator'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { Subject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.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 { TbIotHubItemDetailDialogComponent, IotHubItemDetailDialogData } from './iot-hub-item-detail-dialog.component'; + +@Component({ + selector: 'tb-iot-hub-installed-items', + standalone: false, + templateUrl: './iot-hub-installed-items.component.html', + styleUrls: ['./iot-hub-installed-items.component.scss'] +}) +export class TbIotHubInstalledItemsComponent implements OnInit, AfterViewInit { + + displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'actions']; + dataSource: IotHubInstalledItem[] = []; + totalElements = 0; + pageSize = 10; + pageIndex = 0; + isLoading = false; + textSearch = ''; + + private searchSubject = new Subject(); + + @ViewChild(MatSort, {static: true}) sort: MatSort; + @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; + + private static readonly ITEM_TYPE_TO_ENTITY_TYPE: Record = { + 'WIDGET': EntityType.WIDGET_TYPE, + 'DASHBOARD': EntityType.DASHBOARD, + 'CALCULATED_FIELD': EntityType.CALCULATED_FIELD, + 'RULE_CHAIN': EntityType.RULE_CHAIN, + 'DEVICE': EntityType.DEVICE_PROFILE + }; + + constructor( + private iotHubApiService: IotHubApiService, + private dialogService: DialogService, + private translate: TranslateService, + private store: Store, + private router: Router, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.searchSubject.pipe( + debounceTime(300) + ).subscribe(() => { + this.pageIndex = 0; + this.paginator.pageIndex = 0; + this.loadData(); + }); + this.loadData(); + } + + ngAfterViewInit(): void { + this.sort.sortChange.subscribe(() => { + this.pageIndex = 0; + this.paginator.pageIndex = 0; + this.loadData(); + }); + } + + navigateToMarketplace(): void { + this.router.navigate(['/iot-hub']); + } + + onSearchChange(value: string): void { + this.textSearch = value; + this.searchSubject.next(value); + } + + onPageChange(event: any): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.loadData(); + } + + deleteItem(item: IotHubInstalledItem): void { + this.dialogService.confirm( + this.translate.instant('iot-hub.delete-installed-item-title'), + this.translate.instant('iot-hub.delete-installed-item-text', {name: item.itemName}), + this.translate.instant('action.no'), + this.translate.instant('action.yes') + ).subscribe(result => { + if (result) { + this.iotHubApiService.deleteInstalledItem(item.itemId).subscribe({ + next: () => { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.installed-item-deleted', {name: item.itemName}), + type: 'success', + duration: 3000 + })); + this.loadData(); + }, + error: () => { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('iot-hub.installed-item-delete-error', {name: item.itemName}), + type: 'error', + duration: 5000 + })); + } + }); + } + }); + } + + getItemTypeLabel(itemType: string): string { + const key = itemTypeTranslations.get(itemType as ItemType); + return key ? this.translate.instant(key) : itemType; + } + + getItemTypeChipClass(itemType: string): string { + switch (itemType) { + case 'WIDGET': return 'tb-type-widget'; + case 'DASHBOARD': return 'tb-type-dashboard'; + case 'CALCULATED_FIELD': return 'tb-type-calc-field'; + case 'RULE_CHAIN': return 'tb-type-rule-chain'; + case 'DEVICE': return 'tb-type-device'; + default: return ''; + } + } + + viewItemDetails(item: IotHubInstalledItem): void { + this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { + this.dialog.open(TbIotHubItemDetailDialogComponent, { + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + item: versionView, + iotHubApiService: this.iotHubApiService, + installedDescriptor: item.descriptor + } as IotHubItemDetailDialogData + }); + }); + } + + 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.calculatedFieldId?.id; + case 'RULE_CHAIN': return descriptor.ruleChainId?.id; + default: return null; + } + } + + openEntity(item: IotHubInstalledItem): void { + const entityType = TbIotHubInstalledItemsComponent.ITEM_TYPE_TO_ENTITY_TYPE[item.itemType]; + const entityId = this.getEntityId(item); + if (entityType && entityId) { + const url = getEntityDetailsPageURL(entityId, entityType); + if (url) { + this.router.navigateByUrl(url); + } + } + } + + private loadData(): void { + this.isLoading = true; + const sortOrder: SortOrder = { + property: this.sort?.active || 'createdTime', + direction: this.sort?.direction === 'asc' ? Direction.ASC : Direction.DESC + }; + const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); + this.iotHubApiService.getInstalledItems(pageLink, {ignoreLoading: true}).subscribe({ + next: (data) => { + this.dataSource = data.data; + this.totalElements = data.totalElements; + this.isLoading = false; + }, + error: () => { + this.dataSource = []; + this.totalElements = 0; + this.isLoading = false; + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html index 8b99b4cbe0..3eb8620633 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html @@ -100,7 +100,11 @@ download {{ item.totalInstallCount | shortNumber }} - + @if (installed) { + {{ 'iot-hub.installed' | translate }} + } @else { + + } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss index 0f16dd4fae..20f1d873b9 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss @@ -348,4 +348,14 @@ color: #fff; } } + + .tb-iot-hub-card-installed-label { + padding: 5px 14px; + border-radius: 6px; + background: #e8f5e9; + color: #2e7d32; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts index 87f5502696..709559e534 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts @@ -35,6 +35,7 @@ export class TbIotHubItemCardComponent { @Input() item: MpItemVersionView; @Input() showCreator = true; @Input() showTypeChip = true; + @Input() installed = false; @Output() cardClick = new EventEmitter(); @Output() creatorClick = new EventEmitter(); @Output() installClick = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html index 6d30c3c920..f4746d4b63 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html @@ -174,9 +174,15 @@