From d8308be53aae5b882485571198faefefe0adb475 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 9 Mar 2026 18:49:29 +0200 Subject: [PATCH] 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!",