Browse Source

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
pull/15347/head
Igor Kulikov 5 months ago
parent
commit
d8308be53a
  1. 48
      application/src/main/java/org/thingsboard/server/controller/IotHubController.java
  2. 4
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  3. 128
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  4. 54
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java
  5. 24
      application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubService.java
  6. 2
      application/src/main/resources/thingsboard.yml
  7. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  8. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  9. 3
      ui-ngx/src/app/core/auth/auth.reducer.ts
  10. 126
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  11. 14
      ui-ngx/src/app/core/services/menu.models.ts
  12. 2
      ui-ngx/src/app/modules/home/pages/home-pages.module.ts
  13. 215
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.html
  14. 400
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.scss
  15. 388
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-browse.component.ts
  16. 82
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html
  17. 119
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss
  18. 70
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts
  19. 123
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-install-dialog.component.ts
  20. 107
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html
  21. 351
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.scss
  22. 204
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.ts
  23. 188
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html
  24. 419
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.scss
  25. 246
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.ts
  26. 63
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
  27. 41
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts
  28. 29
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-creator.models.ts
  29. 174
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-item.models.ts
  30. 153
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts
  31. 126
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

4
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));

128
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());
}
}

54
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<byte[]> 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);
}
}

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

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

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

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

3
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 = {

126
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<AppState>
) {}
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<PageData<MpItemVersionView>> {
if (query.tbVersion == null) {
query.tbVersion = tbVersionToInt(env.tbVersion);
}
if (query.peOnly == null) {
query.peOnly = false;
}
return this.http.get<PageData<MpItemVersionView>>(
`${this.baseUrl}/api/versions/published${query.toQuery()}`,
{ params: this.buildParams(config) }
);
}
public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {
return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, {
params: this.buildParams(config),
responseType: 'text'
});
}
public getVersionFileData(versionId: string, config?: IotHubRequestConfig): Observable<Blob> {
return this.http.get(`${this.baseUrl}/api/versions/${versionId}/fileData`, {
params: this.buildParams(config),
responseType: 'blob'
});
}
public reportVersionInstalled(versionId: string, config?: IotHubRequestConfig): Observable<void> {
return this.http.post<void>(
`${this.baseUrl}/api/versions/${versionId}/install`,
null,
{ params: this.buildParams(config) }
);
}
public installItemVersion(versionId: string, config?: IotHubRequestConfig): Observable<any> {
return this.http.post<any>(
`/api/iot-hub/versions/${versionId}/install`,
null,
{ params: this.buildParams(config) }
);
}
public getCreatorProfile(creatorId: string, config?: IotHubRequestConfig): Observable<CreatorView> {
return this.http.get<CreatorView>(
`${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)
);
}
}

14
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<MenuId, MenuSection>([
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<Authority, MenuReference[]>([
]
},
{id: MenuId.api_usage},
{id: MenuId.iot_hub},
{
id: MenuId.settings,
pages: [

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

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

@ -0,0 +1,215 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-iot-hub-browse-container" [class.embedded]="embedded">
<!-- Page header -->
@if (!embedded) {
<div class="tb-iot-hub-browse-header">
<h1>{{ getTitle() | translate }}</h1>
<p>{{ getSubtitle() | translate }}</p>
</div>
}
<!-- Type tabs -->
<nav mat-tab-nav-bar class="tb-iot-hub-type-tabs" [tabPanel]="tabPanel">
<a mat-tab-link
[active]="activeType === ItemType.WIDGET"
(click)="onTypeTabChange(ItemType.WIDGET)">
{{ 'item.type-widget' | translate }}s
</a>
<a mat-tab-link
[active]="activeType === ItemType.DASHBOARD"
(click)="onTypeTabChange(ItemType.DASHBOARD)">
{{ 'item.type-dashboard' | translate }}s
</a>
<a mat-tab-link
[active]="activeType === ItemType.CALCULATED_FIELD"
(click)="onTypeTabChange(ItemType.CALCULATED_FIELD)">
{{ 'item.type-calculated-field' | translate }}s
</a>
<a mat-tab-link
[active]="activeType === ItemType.RULE_CHAIN"
(click)="onTypeTabChange(ItemType.RULE_CHAIN)">
{{ 'item.type-rule-chain' | translate }}s
</a>
<a mat-tab-link
[active]="activeType === ItemType.DEVICE"
(click)="onTypeTabChange(ItemType.DEVICE)">
{{ 'item.type-device' | translate }}s
</a>
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
<!-- Search + sort row -->
<div class="tb-iot-hub-search-row">
<mat-form-field appearance="outline" class="tb-iot-hub-search-field">
<mat-icon matPrefix>search</mat-icon>
<input matInput
[placeholder]="getSearchPlaceholder() | translate"
[value]="textSearch"
(input)="onSearchChange($any($event.target).value)">
@if (textSearch) {
<button matSuffix mat-icon-button (click)="onSearchChange('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field appearance="outline" class="tb-iot-hub-sort-field">
<mat-select [value]="selectedSortIndex" (selectionChange)="onSortChange($event.value)">
@for (option of sortOptions; track option.value; let i = $index) {
<mat-option [value]="i">{{ option.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Filter dropdowns row -->
<div class="tb-iot-hub-filter-row">
@if (getSubtypeMap(); as subtypeMap) {
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select">
<mat-select [value]="getActiveSubtypesArray()" multiple
[placeholder]="'iot-hub.type-filter-label' | translate"
(selectionChange)="onSubtypesChange($event.value)">
@for (entry of subtypeMap | keyvalue; track entry.key) {
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (categories.size > 0) {
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select">
<mat-select [value]="getActiveCategoriesArray()" multiple
[placeholder]="'iot-hub.category' | translate"
(selectionChange)="onCategoriesChange($event.value)">
@for (entry of categories | keyvalue; track entry.key) {
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="tb-iot-hub-filter-select">
<mat-select [value]="getActiveUseCasesArray()" multiple
[placeholder]="'iot-hub.use-case' | translate"
(selectionChange)="onUseCasesChange($event.value)">
@for (entry of useCases | keyvalue; track entry.key) {
<mat-option [value]="entry.key">{{ entry.value | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Active filter chips -->
@if (hasActiveDropdownFilters()) {
<div class="tb-iot-hub-active-filters">
@for (key of getActiveSubtypesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onSubtypeToggle(key)">
{{ getSubtypeLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveCategoriesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onCategoryToggle(key)">
{{ getCategoryLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
@for (key of getActiveUseCasesArray(); track key) {
<span class="tb-iot-hub-active-chip" (click)="onUseCaseToggle(key)">
{{ getUseCaseLabel(key) }}
<mat-icon>close</mat-icon>
</span>
}
<button class="tb-iot-hub-clear-filters" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
</div>
}
<!-- Results count -->
@if (!isLoading && !hasError) {
<div class="tb-iot-hub-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</div>
}
<!-- Loading -->
@if (isLoading) {
<div class="tb-iot-hub-loading">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<!-- Error -->
@if (hasError && !isLoading) {
<div class="tb-iot-hub-empty-state">
<mat-icon class="tb-iot-hub-empty-icon">error_outline</mat-icon>
<h3>{{ 'iot-hub.unable-to-load' | translate }}</h3>
<p>{{ 'iot-hub.unable-to-load-text' | translate }}</p>
<button mat-raised-button color="primary" (click)="loadItems()">
{{ 'iot-hub.retry' | translate }}
</button>
</div>
}
<!-- Empty state -->
@if (!isLoading && !hasError && items.length === 0) {
<div class="tb-iot-hub-empty-state">
<mat-icon class="tb-iot-hub-empty-icon">search_off</mat-icon>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.no-items-found-text' | translate }}</p>
@if (hasActiveFilters()) {
<button mat-raised-button color="primary" (click)="clearAllFilters()">
{{ 'iot-hub.clear-all-filters' | translate }}
</button>
}
</div>
}
<!-- Card grid -->
@if (!isLoading && !hasError && items.length > 0) {
<div class="tb-iot-hub-card-grid">
@for (item of items; track item.id) {
<tb-iot-hub-item-card
[item]="item"
[showTypeChip]="false"
[showCreator]="!creatorId"
(cardClick)="openItemDetail($event)"
(creatorClick)="navigateToCreator($event)"
(installClick)="installItem($event)">
</tb-iot-hub-item-card>
}
</div>
<!-- Pagination -->
<mat-paginator
[length]="totalElements"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[12, 24, 48]"
(page)="onPageChange($event)">
</mat-paginator>
}
<!-- Footer -->
@if (!creatorId) {
<div class="tb-iot-hub-browse-footer">
<a class="tb-iot-hub-become-creator" href="https://iothub.thingsboard.io/signup" target="_blank">
{{ 'iot-hub.become-a-creator' | translate }}
</a>
</div>
}
</div>

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

388
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<string>();
activeUseCases = new Set<string>();
activeCfTypes = new Set<string>();
activeWidgetTypes = new Set<string>();
activeRuleChainTypes = new Set<string>();
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<string, string>();
useCases: Map<string, string> = useCaseTranslations as Map<string, string>;
cfTypes: Map<string, string> = cfTypeTranslations;
widgetTypes: Map<string, string> = widgetTypeTranslations;
ruleChainTypes: Map<string, string> = ruleChainTypeTranslations;
private searchSubject = new Subject<string>();
private destroy$ = new Subject<void>();
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<string, string> | 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<string> {
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<MpItemVersionView>) => {
this.items = data.data;
this.totalElements = data.totalElements;
this.isLoading = false;
},
error: () => {
this.isLoading = false;
this.hasError = true;
this.items = [];
this.totalElements = 0;
}
});
}
}

82
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html

@ -0,0 +1,82 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-iot-hub-creator-profile-container">
<button mat-button class="tb-iot-hub-back-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
@if (creator) {
<!-- Creator header -->
<mat-card class="tb-iot-hub-creator-header" appearance="outlined">
<mat-card-content>
<div class="tb-iot-hub-creator-info">
<div class="tb-iot-hub-creator-avatar">
@if (getAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
} @else {
<mat-icon>account_circle</mat-icon>
}
</div>
<div class="tb-iot-hub-creator-details">
<h2>{{ creator.displayName }}</h2>
@if (creator.description) {
<p class="tb-iot-hub-creator-desc">{{ creator.description }}</p>
}
@if (creator.website) {
<a class="tb-iot-hub-creator-link" [href]="creator.website" target="_blank">
{{ creator.website }}
</a>
}
@if (creator.contactEmail) {
<div class="tb-iot-hub-creator-email">{{ creator.contactEmail }}</div>
}
<div class="tb-iot-hub-social-links">
@if (creator.githubUrl) {
<a [href]="creator.githubUrl" target="_blank" mat-icon-button matTooltip="GitHub">
<mat-icon>code</mat-icon>
</a>
}
@if (creator.linkedinUrl) {
<a [href]="creator.linkedinUrl" target="_blank" mat-icon-button matTooltip="LinkedIn">
<mat-icon>work</mat-icon>
</a>
}
@if (creator.twitterUrl) {
<a [href]="creator.twitterUrl" target="_blank" mat-icon-button matTooltip="Twitter/X">
<mat-icon>chat</mat-icon>
</a>
}
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<!-- Published items -->
<div class="tb-iot-hub-creator-items-header">
<h3>{{ 'iot-hub.published-items' | translate }} ({{ creator.publishedCount }})</h3>
</div>
<tb-iot-hub-browse
#browseComponent
[creatorId]="creatorId"
[embedded]="true">
</tb-iot-hub-browse>
}
</div>

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

70
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<void>();
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'])
});
}
}

123
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: `
<h2 mat-dialog-title>{{ 'iot-hub.install-item-title' | translate }}</h2>
<mat-dialog-content>
<p>{{ 'iot-hub.install-confirm' | translate:{ name: item.name, version: item.version } }}</p>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-type' | translate:{ type: (typeTranslations.get(item.type) | translate) } }}</p>
<p class="tb-iot-hub-install-meta">{{ 'iot-hub.install-creator' | translate:{ creator: item.creatorDisplayName } }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()" [disabled]="state === 'installing'">
{{ 'action.cancel' | translate }}
</button>
@switch (state) {
@case ('confirm') {
<button mat-raised-button color="primary" (click)="install()">
{{ 'iot-hub.install' | translate }}
</button>
}
@case ('installing') {
<button mat-raised-button color="primary" disabled>
<mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner>
{{ 'iot-hub.installing' | translate }}
</button>
}
@case ('success') {
<button mat-raised-button color="primary" disabled>
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }}
</button>
}
}
</mat-dialog-actions>
`,
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<TbIotHubInstallDialogComponent>,
private store: Store<AppState>,
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);
}
}

107
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-card.component.html

@ -0,0 +1,107 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-iot-hub-item-card" [class.tb-iot-hub-card-compact]="isCompactLayout()" (click)="onCardClick()">
@if (!isCompactLayout()) {
<div class="tb-iot-hub-card-preview" [class]="getPreviewClass()">
@if (getPreviewUrl(); as previewUrl) {
<img [src]="previewUrl" alt="" class="tb-iot-hub-preview-image">
} @else {
<div class="tb-iot-hub-preview-placeholder" [style.background]="getCustomIconColor() ? getCustomIconColor() + '22' : null">
<tb-icon [style.color]="getCustomIconColor()">{{ getPlaceholderIcon() }}</tb-icon>
</div>
}
@if (item.peOnly) {
<span class="tb-iot-hub-pe-badge">PE</span>
}
</div>
}
<div class="tb-iot-hub-card-content">
@if (isCompactLayout()) {
<div class="tb-iot-hub-card-cf-header">
<div class="tb-iot-hub-card-cf-icon" [style.background]="item.color || '#6366f1'">
<tb-icon>{{ getPlaceholderIcon() }}</tb-icon>
</div>
<div class="tb-iot-hub-card-cf-title-area">
<div class="tb-iot-hub-card-name-row">
<h3 class="tb-iot-hub-card-name">{{ item.name }}</h3>
<span class="tb-iot-hub-card-version">v{{ item.version }}</span>
</div>
@if (getSubtypeLabel(); as subtypeLabel) {
<span class="tb-iot-hub-card-cf-type">{{ subtypeLabel }}</span>
}
</div>
@if (item.peOnly) {
<span class="tb-iot-hub-pe-badge tb-iot-hub-pe-badge-inline">PE</span>
}
</div>
} @else {
<div class="tb-iot-hub-card-name-row">
<h3 class="tb-iot-hub-card-name">{{ item.name }}</h3>
<span class="tb-iot-hub-card-version">v{{ item.version }}</span>
</div>
@if (item.type === ItemType.WIDGET && getSubtypeLabel(); as subtypeLabel) {
<span class="tb-iot-hub-card-widget-type-label">{{ subtypeLabel }}</span>
}
}
@if (item.description) {
<p class="tb-iot-hub-card-description">{{ item.description }}</p>
}
@if (getNodes(); as nodes) {
@if (nodes.length) {
<div class="tb-iot-hub-card-nodes">
@for (node of nodes | slice:0:maxVisibleNodes; track node.name) {
<span class="tb-iot-hub-card-node-chip" [ngClass]="getNodeColorClass(node)" [title]="getNodeLabel(node)">{{ node.name }}</span>
}
@if (getNodeCount() > maxVisibleNodes) {
<span class="tb-iot-hub-card-node-more">+{{ getNodeCount() - maxVisibleNodes }}</span>
}
</div>
}
}
@if (getCategoryLabels().length || getFirstUseCaseLabel()) {
<div class="tb-iot-hub-card-categories">
@if (getCategoryLabels(); as labels) {
@if (labels.length) {
<span class="tb-iot-hub-card-category-chip">{{ labels[0] }}</span>
}
}
@if (getFirstUseCaseLabel(); as useCaseLabel) {
<span class="tb-iot-hub-card-use-case-chip">{{ useCaseLabel }}</span>
}
</div>
}
<div class="tb-iot-hub-card-footer">
@if (showCreator) {
<tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon><span class="tb-iot-hub-card-creator" (click)="onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
@if (item.publishedTime) {
<span class="tb-iot-hub-card-dot">&middot;</span>
}
}
@if (item.publishedTime) {
<span class="tb-iot-hub-card-published">{{ formatPublishedTime(item.publishedTime) }}</span>
}
<span class="tb-iot-hub-card-stats">
<span class="tb-iot-hub-card-stat">
<tb-icon>download</tb-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">Install</button>
</span>
</div>
</div>
</div>

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

204
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<MpItemVersionView>();
@Output() creatorClick = new EventEmitter<string>();
@Output() installClick = new EventEmitter<MpItemVersionView>();
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' });
}
}

188
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-detail-dialog.component.html

@ -0,0 +1,188 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div mat-dialog-content class="tb-iot-hub-detail-dialog">
<!-- Header (sticky) -->
<div class="dlg-header">
<div class="dlg-header-top">
@if (item.type === ItemType.CALCULATED_FIELD || item.type === ItemType.RULE_CHAIN) {
<div class="dlg-icon" [style.background]="item.color || '#6366f1'">
<tb-icon>{{ getCompactIcon() }}</tb-icon>
</div>
}
<div class="dlg-header-info">
<div class="dlg-title-row">
<span class="dlg-title">{{ item.name }}</span>
<span class="dlg-version">v{{ item.version }}</span>
@if (getSubtypeLabel(); as subtypeLabel) {
<span class="dlg-type-badge" [ngClass]="getSubtypeColorClass()">{{ subtypeLabel }}</span>
}
</div>
<div class="dlg-subtitle">
<span>{{ typeTranslations.get(item.type) | translate }}</span>
<span class="dlg-sep">&middot;</span>
<span>{{ 'iot-hub.by-creator' | translate:{ name: '' } }}</span>
<span class="dlg-creator-link" (click)="navigateToCreator()">{{ item.creatorDisplayName }}</span>
@if (item.publishedTime) {
<span class="dlg-sep">&middot;</span>
<span>{{ item.publishedTime | date:'mediumDate' }}</span>
}
</div>
</div>
<button mat-icon-button class="dlg-close" (click)="close()" type="button">
<tb-icon>close</tb-icon>
</button>
</div>
</div>
<!-- Scrollable body -->
<div class="dlg-body">
<!-- Widget: image + description side by side -->
@if (item.type === ItemType.WIDGET) {
<div class="dlg-hero-split">
<div class="dlg-hero-image">
@if (getPreviewUrl(); as url) {
<img [src]="url" alt="">
} @else {
<div class="tb-iot-hub-preview-placeholder">
<tb-icon>widgets</tb-icon>
</div>
}
</div>
@if (item.description) {
<div class="dlg-hero-desc">{{ item.description }}</div>
}
</div>
}
<!-- Dashboard: full-width image + description below -->
@if (item.type === ItemType.DASHBOARD) {
<div class="dlg-hero-full">
<div class="dlg-hero-full-image">
@if (getPreviewUrl(); as url) {
<img [src]="url" alt="">
} @else {
<div class="tb-iot-hub-preview-placeholder">
<tb-icon>dashboard</tb-icon>
</div>
}
</div>
@if (item.description) {
<div class="dlg-hero-full-desc">{{ item.description }}</div>
}
</div>
}
<!-- Stats bar -->
<div class="dlg-stats-bar">
<span class="dlg-stat">
<tb-icon>download</tb-icon>
<strong>{{ item.totalInstallCount | shortNumber }}</strong> {{ 'iot-hub.installs' | translate }}
</span>
<span class="dlg-stat">
<tb-icon>{{ getTypeIcon() }}</tb-icon>
{{ typeTranslations.get(item.type) | translate }}
</span>
@if (item.peOnly) {
<span class="dlg-pe-label">PE</span>
}
</div>
<!-- Device preview -->
@if (item.type === ItemType.DEVICE) {
<div class="dlg-device-preview">
@if (getPreviewUrl(); as url) {
<img [src]="url" alt="">
} @else {
<div class="tb-iot-hub-preview-placeholder">
<tb-icon>memory</tb-icon>
</div>
}
</div>
}
<!-- CF/RC: description block -->
@if (item.description && item.type !== ItemType.WIDGET && item.type !== ItemType.DASHBOARD) {
<div class="dlg-desc-block">{{ item.description }}</div>
}
<!-- Node chips (Rule Chain) -->
@if (item.type === ItemType.RULE_CHAIN && getNodes().length) {
<div class="dlg-node-chips">
@for (node of getNodes(); track node.name) {
<span class="dlg-node-chip" [ngClass]="'dlg-node-' + node.type.toLowerCase()" [title]="getNodeLabel(node)">{{ node.name }}</span>
}
@if (getNodeCount() > getNodes().length) {
<span class="dlg-node-more">+{{ getNodeCount() - getNodes().length }} {{ 'iot-hub.more' | translate }}</span>
}
</div>
}
<!-- Categories & Use Cases -->
@if (item.categories?.length || item.useCases?.length) {
<div class="dlg-meta-section">
@if (item.categories?.length) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.categories' | translate }}</span>
<div class="dlg-meta-chips">
@for (cat of item.categories; track cat) {
<span class="dlg-meta-chip dlg-meta-chip-category">{{ getCategoryLabel(cat) }}</span>
}
</div>
</div>
}
@if (item.useCases?.length) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.use-cases' | translate }}</span>
<div class="dlg-meta-chips">
@for (uc of item.useCases; track uc) {
<span class="dlg-meta-chip dlg-meta-chip-usecase">{{ getUseCaseLabel(uc) }}</span>
}
</div>
</div>
}
</div>
}
<!-- Readme / Changelog -->
@if (readmeContent || item.changelog) {
<div class="dlg-content">
@if (readmeContent) {
<tb-markdown [data]="readmeContent"></tb-markdown>
}
@if (item.changelog) {
<h3>{{ 'iot-hub.changelog' | translate }}</h3>
<tb-markdown [data]="item.changelog"></tb-markdown>
}
</div>
}
</div>
<!-- Footer -->
<div class="dlg-footer">
<button class="dlg-install-btn" (click)="install()">
{{ 'iot-hub.install-item-type' | translate:{ type: getTypeLabel() } }}
</button>
<div class="dlg-creator-info">
<span>{{ 'iot-hub.created-by' | translate:{ name: item.creatorDisplayName } }}</span>
<span class="dlg-sep">&middot;</span>
<span class="dlg-become-creator" (click)="openSignup()">
{{ 'iot-hub.become-a-creator' | translate }}
</span>
</div>
</div>
</div>

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

246
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<string, string>;
private useCaseMap = useCaseTranslations;
constructor(
@Inject(MAT_DIALOG_DATA) public data: IotHubItemDetailDialogData,
private dialogRef: MatDialogRef<TbIotHubItemDetailDialogComponent>,
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`);
}
}

63
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 { }

41
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 { }

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

174
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, string>(
[
[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, string>([
[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, string>([
[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, string>([
[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, string>([
[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, string>([
[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, string>([
[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<string, string> {
switch (type) {
case ItemType.WIDGET:
return widgetCategoryTranslations as Map<string, string>;
case ItemType.DASHBOARD:
return dashboardCategoryTranslations as Map<string, string>;
case ItemType.CALCULATED_FIELD:
return calcFieldCategoryTranslations as Map<string, string>;
case ItemType.RULE_CHAIN:
return ruleChainCategoryTranslations as Map<string, string>;
case ItemType.DEVICE:
return deviceCategoryTranslations as Map<string, string>;
default:
return new Map();
}
}

153
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<string, string>([
['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<string, string>([
['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<string, string>([
['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, string>([
[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<string, string>([
['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;
}
}

126
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!",

Loading…
Cancel
Save