Browse Source

chore(iot-hub): comprehensive cleanup — unused code, shared utils, IDE diagnostics

Refactor: route all iot-hub dialog.open calls through IotHubActionsService; new
openInstalledItems/reviewDevice methods; simpler deleteItem/updateItem signatures
reading metadata from installedItem.

Extract shared code:
- iot-hub-utils.ts — filterIotHubItemsBySearch, groupIotHubFilterItems,
  resolveIotHubItemImageUrl used by browse, search, widget-select, home
- _iot-hub-common.scss — mixins for the 40px search/filter input pattern
- device-install-dialog — markEntityStepSuccess and saveRuleChainWithMetadata
  helpers collapse duplicate blocks
- update dialog reuses install dialog's SCSS (styles were byte-identical)

Cleanup:
- Removed 30+ unused methods, fields, and imports across iot-hub components
- Removed 14 unused locale keys (iot-hub.* / item.selected / vendor-placeholder)
- Deleted unused SCSS selectors (.dlg-close, .dlg-row-1, dlg-info-actions, etc.)
- All router.navigate* calls prefixed with void to satisfy ignored-promise hints

IDE diagnostics fixed:
- 28 a11y errors — added role/tabindex/keyup.enter to interactive divs/spans
- Regex redundant escapes and single-char alternations
- Bare boolean attrs (stickyEnd, linear, disabled, showFirstLastButtons)
  converted to [prop]="true"
- Merged background/background-image/background-size shorthand
- Removed redundant border-bottom overrides
pull/15508/head
Igor Kulikov 5 months ago
parent
commit
7e19b679bb
  1. 6
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  2. 45
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss
  3. 59
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  4. 72
      ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss
  5. 22
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html
  6. 54
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss
  7. 94
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts
  8. 66
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
  9. 2
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.html
  10. 10
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.scss
  11. 67
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html
  12. 161
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss
  13. 189
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts
  14. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.html
  15. 15
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.scss
  16. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
  17. 13
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss
  18. 7
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  19. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.html
  20. 22
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss
  21. 18
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.html
  22. 34
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.scss
  23. 107
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-table.component.ts
  24. 18
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html
  25. 50
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss
  26. 99
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts
  27. 51
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  28. 56
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  29. 164
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  30. 10
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html
  31. 116
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss
  32. 8
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts
  33. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html
  34. 58
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.scss
  35. 9
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts
  36. 69
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts
  37. 8
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html
  38. 25
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss
  39. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts
  40. 23
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html
  41. 14
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss
  42. 22
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts
  43. 11
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html
  44. 12
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss
  45. 7
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts
  46. 6
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.html
  47. 13
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss
  48. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts
  49. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.html
  50. 12
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss
  51. 2
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts
  52. 18
      ui-ngx/src/assets/locale/locale.constant-en_US.json

6
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html

@ -242,7 +242,7 @@
[noData]="noData">
</tb-scroll-grid>
<ng-template #widgetCard let-item="item">
<mat-card class="tb-widget-preview-card flex flex-col gap-2" appearance="raised" (click)="onWidgetClicked($event, item)">
<mat-card class="tb-widget-preview-card flex flex-col gap-2" appearance="raised" (click)="onWidgetClicked(item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.name}}
@ -344,7 +344,9 @@
}
</div>
@if (selectWidgetMode === 'installed' && installedSubMode === 'allWidgets') {
<div class="tb-widget-select-browse-tip" (click)="navigateToIotHubAllWidgets()">
<div class="tb-widget-select-browse-tip" role="button" tabindex="0"
(click)="navigateToIotHubAllWidgets()"
(keyup.enter)="navigateToIotHubAllWidgets()">
<mat-icon class="tb-widget-select-browse-tip-icon">storefront</mat-icon>
<p class="tb-widget-select-browse-tip-text">
<span>{{ 'iot-hub.browse-tip-prefix' | translate }} </span><span class="tb-widget-select-browse-tip-link">{{ 'iot-hub.browse-tip-link' | translate }}</span><span> {{ 'iot-hub.browse-tip-suffix' | translate }}</span>

45
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss

@ -14,6 +14,7 @@
* limitations under the License.
*/
@import "../../../../../scss/constants";
@import "../iot-hub/iot-hub-common";
tb-dashboard-widget-select {
display: flex;
@ -501,55 +502,19 @@ tb-dashboard-widget-select {
}
.tb-iot-hub-filter-search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
@include tb-iot-hub-search-container;
margin: 4px 8px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.54);
flex-shrink: 0;
@include tb-iot-hub-search-icon(rgba(0, 0, 0, 0.54));
}
input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
padding: 0;
min-width: 0;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
@include tb-iot-hub-search-input-text(0.2px);
}
.tb-iot-hub-filter-search-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@include tb-iot-hub-search-clear;
}
}

59
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

@ -28,22 +28,18 @@ import {
} from '@shared/models/widget.models';
import { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs';
import { isObject } from '@core/utils';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
import { GridEntitiesFetchFunction, ScrollGridColumns } from '@shared/components/grid/scroll-grid-datasource';
import { ItemSizeStrategy } from '@shared/components/grid/scroll-grid.component';
import { coerceBoolean } from '@shared/decorators/coercion';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType, FilterParamInfo, WidgetCategory } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import {
TbIotHubItemDetailDialogComponent,
IotHubItemDetailDialogData
} from '@home/components/iot-hub/iot-hub-item-detail-dialog.component';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { filterIotHubItemsBySearch, groupIotHubFilterItems, IotHubFilterGroup, resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
type selectWidgetMode = 'installed' | 'iotHub';
type installedSubMode = 'default' | 'allWidgets';
@ -281,8 +277,8 @@ export class DashboardWidgetSelectComponent {
constructor(private widgetsService: WidgetService,
private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService,
private translate: TranslateService,
private dialog: MatDialog,
private cd: ChangeDetectorRef) {
this.widgetBundlesFetchFunction = (pageSize, page, filter) => {
@ -437,7 +433,7 @@ export class DashboardWidgetSelectComponent {
}
}
onWidgetClicked($event: Event, widget: WidgetTypeInfo): void {
onWidgetClicked(widget: WidgetTypeInfo): void {
this.widgetSelected.emit(this.toWidgetInfo(widget));
}
@ -498,15 +494,7 @@ export class DashboardWidgetSelectComponent {
}
return;
}
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: {
item,
mode: 'add'
} as IotHubItemDetailDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.openItemDetail(item, undefined, undefined, 'add').subscribe(result => {
if (result?.action === 'add') {
this.installAndAddWidget(result.item);
}
@ -518,14 +506,7 @@ export class DashboardWidgetSelectComponent {
}
getIotHubItemImage(item: MpItemVersionView): string | null {
if (item.image) {
return this.iotHubApiService.resolveResourceUrl(item.image);
}
const resource = item.resources?.find(r => r.type === 'SCREENSHOT') || item.resources?.find(r => r.type === 'ICON');
if (resource) {
return this.iotHubApiService.resolveResourceUrl(`/api/resources/${resource.id}`);
}
return null;
return resolveIotHubItemImageUrl(item, this.iotHubApiService);
}
getIotHubWidgetTypeLabel(key: string): string {
@ -534,29 +515,11 @@ export class DashboardWidgetSelectComponent {
}
getFilteredIotHubItems(items: FilterParamInfo[], searchKey: string): FilterParamInfo[] {
const search = (this.iotHubFilterSearch[searchKey] || '').toLowerCase();
if (!search) { return items; }
return items.filter(item => item.key.toLowerCase().includes(search));
return filterIotHubItemsBySearch(items, this.iotHubFilterSearch[searchKey]);
}
getGroupedIotHubFilterItems(items: FilterParamInfo[], searchKey: string): { label: string; items: FilterParamInfo[] }[] {
const filtered = this.getFilteredIotHubItems(items, searchKey);
if (items.length < 11) {
return [{ label: null, items: filtered }];
}
const topKeys = new Set(
[...items].sort((a, b) => b.totalInstallCount - a.totalInstallCount).slice(0, 10).map(i => i.key)
);
const popular = filtered.filter(i => topKeys.has(i.key));
const rest = filtered.filter(i => !topKeys.has(i.key));
const groups: { label: string; items: FilterParamInfo[] }[] = [];
if (popular.length) {
groups.push({ label: 'iot-hub.most-popular', items: popular });
}
if (rest.length) {
groups.push({ label: 'iot-hub.all', items: rest });
}
return groups;
getGroupedIotHubFilterItems(items: FilterParamInfo[], searchKey: string): IotHubFilterGroup[] {
return groupIotHubFilterItems(items, this.iotHubFilterSearch[searchKey]);
}
toggleIotHubWidgetType(key: string): void {
@ -774,10 +737,6 @@ export class DashboardWidgetSelectComponent {
this.cd.markForCheck();
}
isObject(value: any): boolean {
return isObject(value);
}
private installAndAddWidget(item: MpItemVersionView): void {
const versionId = item.id as string;
this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({

72
ui-ngx/src/app/modules/home/components/iot-hub/_iot-hub-common.scss

@ -0,0 +1,72 @@
/**
* 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.
*/
// Shared IoT Hub visual building blocks — used across
// iot-hub-browse, iot-hub-search, and dashboard-widget-select
// to keep filter / search controls in sync.
@mixin tb-iot-hub-search-container {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
}
@mixin tb-iot-hub-search-icon($color: rgba(0, 0, 0, 0.38)) {
font-size: 24px;
width: 24px;
height: 24px;
color: $color;
flex-shrink: 0;
}
@mixin tb-iot-hub-search-input-text($letter-spacing: normal) {
flex: 1;
min-width: 0;
border: none;
outline: none;
padding: 0;
font-family: inherit;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: $letter-spacing;
color: rgba(0, 0, 0, 0.87);
background: transparent;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}
@mixin tb-iot-hub-search-clear {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}

22
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -30,7 +30,7 @@
}
@case ('form') {
<div class="tb-device-install-form">
<div class="tb-device-install-form flex flex-col gap-2 p-6">
@if (ws.formGroup) {
<form [formGroup]="ws.formGroup">
@for (field of ws.formFields; track field.key) {
@ -108,18 +108,18 @@
}
@case ('progress') {
<div class="tb-device-install-progress">
<div class="flex flex-col p-6">
@for (ep of ws.entitySteps; track ep.step.name; let i = $index) {
@if (i > 0) {
<div class="tb-progress-divider"></div>
}
@if (ep.status === 'conflict' || ep.status === 'error') {
<div class="tb-progress-row tb-progress-row-alert">
<div class="tb-progress-alert-content">
<div class="flex min-w-0 flex-1 items-start gap-4">
<div class="tb-progress-icon">
<mat-icon class="tb-progress-warning">warning</mat-icon>
</div>
<div class="tb-progress-alert-text">
<div class="flex min-w-0 flex-1 flex-col">
<span class="tb-progress-label tb-progress-label-active">
{{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }}
</span>
@ -133,7 +133,7 @@
}
</div>
</div>
<div class="tb-progress-alert-actions">
<div class="flex shrink-0 gap-2">
@if (ep.status === 'conflict') {
@if (ep.conflictType === 'use-or-overwrite') {
<button class="tb-progress-action-btn tb-progress-action-primary" (click)="resolveConflict(ws, ep, 'use-existing')">
@ -198,11 +198,11 @@
<!-- Main dialog -->
@if (loading) {
<div class="tb-device-install-loading">
<div class="flex items-center justify-center py-12">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else {
<div class="tb-device-install-header">
<div class="tb-device-install-header flex items-center gap-3 py-2 pl-6 pr-2">
<h2 class="tb-device-install-title">{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}</h2>
<button mat-icon-button (click)="cancel()" tabindex="-1">
<mat-icon>close</mat-icon>
@ -213,9 +213,9 @@
@if (!wizardStarted) {
<!-- Connectivity selector -->
<div mat-dialog-content>
<div class="tb-device-install-connectivity">
<div class="tb-device-install-connectivity flex flex-col gap-3">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
<div class="tb-connectivity-options">
<div class="flex flex-wrap gap-2">
@for (ct of availableInstallMethods; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
@ -264,13 +264,13 @@
<!-- Install mode: stepper -->
<div mat-dialog-content>
<mat-stepper #installStepper
linear
[linear]="true"
labelPosition="bottom">
<ng-template matStepperIcon="edit">
<mat-icon>done</mat-icon>
</ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
@for (ws of wizardSteps; track ws.label) {
<mat-step [editable]="true"
[completed]="ws.completed"
[stepControl]="ws.type === 'form' ? ws.formGroup : null">

54
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss

@ -69,11 +69,6 @@
}
.tb-device-install-header {
display: flex;
align-items: center;
padding: 8px 8px 8px 24px;
gap: 12px;
.tb-device-install-title {
font-size: 20px;
font-weight: 600;
@ -88,18 +83,7 @@
}
}
.tb-device-install-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.tb-device-install-connectivity {
display: flex;
flex-direction: column;
gap: 12px;
p {
color: rgba(0, 0, 0, 0.54);
font-size: 14px;
@ -107,12 +91,6 @@
margin: 0;
}
.tb-connectivity-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tb-connectivity-button {
min-width: 100px;
}
@ -296,11 +274,6 @@
// Form view
.tb-device-install-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px;
mat-form-field {
width: 100%;
}
@ -322,13 +295,6 @@
}
}
// Progress view
.tb-device-install-progress {
display: flex;
flex-direction: column;
padding: 24px;
}
.tb-progress-divider {
width: 1px;
height: 16px;
@ -443,20 +409,6 @@
color: rgba(0, 0, 0, 0.54);
}
.tb-progress-alert-content {
display: flex;
gap: 16px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.tb-progress-alert-text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.tb-progress-alert-msg {
font-size: 12px;
@ -473,12 +425,6 @@
}
}
.tb-progress-alert-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.tb-progress-action-btn {
display: inline-flex;
align-items: center;

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

@ -186,10 +186,6 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
this.selectedInstallMethod = ct;
}
onStepChanged(): void {
this.onStepActivated();
}
onTabChanged(index: number): void {
const ws = this.wizardSteps[index];
if (!ws) return;
@ -293,7 +289,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
openEntity(url: string): void {
this.dialogRef.close('installed');
this.router.navigateByUrl(url);
void this.router.navigateByUrl(url);
}
done(): void {
@ -306,7 +302,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
retryEntitySteps(step: WizardStep): void {
step.progressError = null;
this.runEntitySteps(step);
void this.runEntitySteps(step);
}
async resolveConflict(ws: WizardStep, ep: EntityStepProgress, resolution: string): Promise<void> {
@ -332,23 +328,9 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
output = await this.createEntity(ep.step);
}
await this.saveStepAttributes(ep.step, output);
ep.entityOutput = output;
ep.existingEntity = null;
ep.conflictType = null;
ep.resolution = resolution;
ep.status = 'success';
const alias = stepTypeAliasMap[ep.step.type];
if (alias) {
this.entityOutputs.set(alias, output);
}
for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name);
}
}
this.cdr.detectChanges();
this.markEntityStepSuccess(ws, ep, output, resolution);
// Resume running remaining steps
await this.runEntitySteps(ws);
} catch (err: any) {
@ -425,7 +407,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
// --- Variable Resolution ---
resolveVariables(content: string): string {
return content.replace(/\$\{([^}]+)\}/g, (_match, key) => {
return content.replace(/\$\{([^}]+)}/g, (_match, key) => {
if (key in this.formValues) {
return String(this.formValues[key]);
}
@ -447,11 +429,11 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
// Image gallery: ${images.gallery(path1,path2,path3)}
const galleryMatch = key.match(/^images\.gallery\((.+)\)$/);
if (galleryMatch) {
const paths = galleryMatch[1].split(',').map(p => p.trim());
const paths = galleryMatch[1].split(',').map((p: string) => p.trim());
const images = paths
.map(p => this.zipImages.get(p))
.filter(src => !!src)
.map(src => `<img src="${src}" class="tb-gallery-img" />`)
.map((p: string) => this.zipImages.get(p))
.filter((src: string | undefined) => !!src)
.map((src: string) => `<img src="${src}" alt="" class="tb-gallery-img" />`)
.join('');
return `<div class="tb-gallery">${images}</div>`;
}
@ -469,7 +451,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
}
resolveImages(content: string): string {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, path) => {
return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => {
if (path.startsWith('data:') || path.startsWith('http')) {
return match;
}
@ -649,7 +631,24 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
}));
ws.progressError = null;
ws.progressDone = false;
this.runEntitySteps(ws);
void this.runEntitySteps(ws);
}
private markEntityStepSuccess(ws: WizardStep, ep: EntityStepProgress,
output: EntityStepOutput, resolution: string): void {
ep.entityOutput = output;
ep.resolution = resolution;
ep.status = 'success';
const alias = stepTypeAliasMap[ep.step.type];
if (alias) {
this.entityOutputs.set(alias, output);
}
for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name);
}
}
this.cdr.detectChanges();
}
private async runEntitySteps(ws: WizardStep): Promise<void> {
@ -679,21 +678,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (elapsed < ENTITY_STEP_MIN_DELAY) {
await this.delay(ENTITY_STEP_MIN_DELAY - elapsed);
}
ep.entityOutput = output;
ep.resolution = 'created';
ep.status = 'success';
const alias = stepTypeAliasMap[ep.step.type];
if (alias) {
this.entityOutputs.set(alias, output);
}
// Re-resolve names of remaining pending steps
for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name);
}
}
this.cdr.detectChanges();
this.markEntityStepSuccess(ws, ep, output, 'created');
} catch (err: any) {
ep.status = 'error';
ep.errorMessage = err?.error?.message || err?.message || 'Unknown error';
@ -823,18 +808,22 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
if (existing) {
return existing;
}
const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true}));
if (metadata) {
metadata.ruleChainId = saved.id;
await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true}));
}
return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` };
return this.saveRuleChainWithMetadata(ruleChain, metadata);
}
default:
throw new Error(`Unsupported entity step type: ${step.type}`);
}
}
private async saveRuleChainWithMetadata(ruleChain: any, metadata: any): Promise<EntityStepOutput> {
const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true}));
if (metadata) {
metadata.ruleChainId = saved.id;
await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true}));
}
return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` };
}
private async resolveCredentials(step: DeviceInstallStep, deviceId: string): Promise<any> {
const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(deviceId, false, {ignoreErrors: true}));
if (step.credentials) {
@ -931,12 +920,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
const ruleChain = template.ruleChain || template;
const metadata = template.metadata;
ruleChain.id = { id: existing.id, entityType: 'RULE_CHAIN' };
const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true}));
if (metadata) {
metadata.ruleChainId = saved.id;
await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true}));
}
return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` };
return this.saveRuleChainWithMetadata(ruleChain, metadata);
}
default:
throw new Error(`Unsupported overwrite for step type: ${step.type}`);

66
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts

@ -20,7 +20,7 @@ import { Observable, of, EMPTY } from 'rxjs';
import { filter, mergeMap } from 'rxjs/operators';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { DeviceInstalledItemDescriptor, IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { EntityId } from '@shared/models/id/entity-id';
import { TbIotHubAddItemDialogComponent, IotHubAddItemDialogData, IotHubAddItemDialogResult } from './iot-hub-add-item-dialog.component';
@ -29,6 +29,7 @@ import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from './iot-h
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from './iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component';
import { TbIotHubInstalledItemsDialogComponent, IotHubInstalledItemsDialogData } from './iot-hub-installed-items-dialog.component';
@Injectable()
export class IotHubActionsService {
@ -42,11 +43,21 @@ export class IotHubActionsService {
mode?: IotHubItemDetailDialogMode, showCreator?: boolean): Observable<any> {
return this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: { item, installedItem, installedItemsCount, mode, showCreator } as IotHubItemDetailDialogData
}).afterClosed();
}
openInstalledItems(item: MpItemVersionView): Observable<any> {
return this.dialog.open(TbIotHubInstalledItemsDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: { item } as IotHubInstalledItemsDialogData
}).afterClosed();
}
addItem(itemType: ItemType, options?: { itemSubType?: string; entityId?: EntityId }): Observable<IotHubAddItemDialogResult> {
return this.dialog.open(TbIotHubAddItemDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
@ -66,36 +77,39 @@ export class IotHubActionsService {
}
return this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: { item } as IotHubInstallDialogData
}).afterClosed();
}
updateItem(item: MpItemVersionView, installedItem: IotHubInstalledItem): Observable<string> {
updateItem(installedItem: IotHubInstalledItem, version: string, versionId: string): Observable<string> {
if (!installedItem) {
return EMPTY;
}
return this.dialog.open(TbIotHubUpdateDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: {
installedItemId: installedItem.id.id,
itemName: item.name,
itemType: item.type,
version: item.version,
versionId: item.id
itemName: installedItem.itemName,
itemType: installedItem.itemType as ItemType,
version,
versionId
} as IotHubUpdateDialogData
}).afterClosed();
}
deleteItem(item: MpItemVersionView, installedItem: IotHubInstalledItem): Observable<boolean> {
deleteItem(installedItem: IotHubInstalledItem): Observable<boolean> {
if (!installedItem) {
return of(false);
}
return this.dialog.open(TbIotHubDeleteDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: { itemName: item.name, itemType: item.type } as IotHubDeleteDialogData
data: { itemName: installedItem.itemName, itemType: installedItem.itemType } as IotHubDeleteDialogData
}).afterClosed().pipe(
filter(confirmed => !!confirmed),
mergeMap(() => this.iotHubApiService.deleteInstalledItem(installedItem.id.id)),
@ -104,16 +118,34 @@ export class IotHubActionsService {
}
installDevice(item: MpItemVersionView): Observable<string> {
return this.fetchZipData(item).pipe(
mergeMap((zipData: ArrayBuffer) => this.openDeviceInstallDialog(item, zipData))
);
}
reviewDevice(item: MpItemVersionView, deviceDescriptor: DeviceInstalledItemDescriptor): Observable<any> {
return this.fetchZipData(item).pipe(
mergeMap((zipData: ArrayBuffer) => this.openDeviceInstallDialog(item, zipData, {
reviewMode: true,
selectedInstallMethod: deviceDescriptor.selectedInstallMethod,
installState: deviceDescriptor.installState
}))
);
}
private fetchZipData(item: MpItemVersionView): Observable<ArrayBuffer> {
return this.iotHubApiService.getVersionFileData(item.id as string, { ignoreLoading: true }).pipe(
mergeMap((blob: Blob) => blob.arrayBuffer()),
mergeMap((zipData: ArrayBuffer) =>
this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
disableClose: true,
autoFocus: false,
data: { item, zipData } as DeviceInstallDialogData
}).afterClosed()
)
mergeMap((blob: Blob) => blob.arrayBuffer())
);
}
private openDeviceInstallDialog(item: MpItemVersionView, zipData: ArrayBuffer,
options?: { reviewMode?: boolean; selectedInstallMethod?: string; installState?: any }): Observable<any> {
return this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
disableClose: true,
autoFocus: false,
data: { item, zipData, ...options } as DeviceInstallDialogData
}).afterClosed();
}
}

2
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.html

@ -27,7 +27,7 @@
</mat-toolbar>
<div mat-dialog-content>
@if (isInstalling) {
<div class="tb-iot-hub-add-item-loading">
<div class="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<mat-spinner diameter="40"></mat-spinner>
</div>
}

10
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.scss

@ -45,13 +45,3 @@
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
}
.tb-iot-hub-add-item-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
z-index: 10;
}

67
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html

@ -15,39 +15,18 @@
limitations under the License.
-->
<div class="tb-iot-hub-browse" [class.embedded]="embedded" [class.tabs-hidden]="hideTabs">
<div class="tb-iot-hub-browse flex h-full flex-col" [class.embedded]="embedded" [class.tabs-hidden]="hideTabs">
<!-- Type tabs -->
<nav mat-tab-nav-bar class="tb-iot-hub-tabs" [tabPanel]="tabPanel" [class.hidden]="hideTabs">
<a mat-tab-link
[active]="activeType === ItemType.WIDGET"
(click)="onTypeTabChange(ItemType.WIDGET)">
{{ 'item.type-widget-plural' | translate }}
</a>
<a mat-tab-link
[active]="activeType === ItemType.DASHBOARD"
(click)="onTypeTabChange(ItemType.DASHBOARD)">
{{ 'item.type-dashboard-plural' | translate }}
</a>
<a mat-tab-link
[active]="activeType === ItemType.SOLUTION_TEMPLATE"
(click)="onTypeTabChange(ItemType.SOLUTION_TEMPLATE)">
{{ 'item.type-solution-template-plural' | translate }}
</a>
<a mat-tab-link
[active]="activeType === ItemType.CALCULATED_FIELD"
(click)="onTypeTabChange(ItemType.CALCULATED_FIELD)">
{{ 'item.type-calculated-field-plural' | translate }}
</a>
<a mat-tab-link
[active]="activeType === ItemType.RULE_CHAIN"
(click)="onTypeTabChange(ItemType.RULE_CHAIN)">
{{ 'item.type-rule-chain-plural' | translate }}
</a>
<a mat-tab-link
[active]="activeType === ItemType.DEVICE"
(click)="onTypeTabChange(ItemType.DEVICE)">
{{ 'item.type-device-plural' | translate }}
</a>
@for (tab of typeTabs; track tab.type) {
<a mat-tab-link
tabindex="0"
[active]="activeType === tab.type"
(click)="onTypeTabChange(tab.type)"
(keyup.enter)="onTypeTabChange(tab.type)">
{{ tab.label | translate }}
</a>
}
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
@ -270,9 +249,9 @@
<!-- Content: filters sidebar + card grid -->
<mat-drawer-container class="tb-iot-hub-content">
<!-- Filter drawer (visible on small screens) -->
<mat-drawer #filterDrawer class="tb-iot-hub-filter-drawer" mode="over" position="end"
<mat-drawer class="tb-iot-hub-filter-drawer" mode="over" position="end"
[autoFocus]="false" [(opened)]="filterDrawerOpened">
<div class="tb-iot-hub-filter-drawer-header">
<div class="tb-iot-hub-filter-drawer-header flex items-center justify-between">
<span>{{ 'iot-hub.filter' | translate }}</span>
<button mat-icon-button (click)="filterDrawerOpened = false">
<mat-icon>close</mat-icon>
@ -280,7 +259,7 @@
</div>
@if (hasActiveDropdownFilters()) {
<div class="tb-iot-hub-filter-divider" style="margin-bottom: 8px"></div>
<div class="tb-iot-hub-filter-drawer-chips">
<div class="tb-iot-hub-filter-drawer-chips flex flex-wrap items-center gap-2">
<mat-chip-set>
@for (key of getActiveSubtypesArray(); track key) {
<mat-chip (removed)="onSubtypeToggle(key)">
@ -330,7 +309,7 @@
</div>
</mat-drawer>
<mat-drawer-content class="tb-iot-hub-drawer-content">
<mat-drawer-content class="tb-iot-hub-drawer-content flex flex-1 overflow-visible">
<!-- Left sidebar filters (visible on large screens) -->
<div class="tb-iot-hub-filters tb-iot-hub-filters-desktop">
<div class="tb-iot-hub-filters-wrapper" [class.filter-items-hovered]="filterItemsHovered">
@ -341,8 +320,8 @@
<!-- Right content area -->
<div class="tb-iot-hub-results">
<!-- Top bar: search + filter button (mobile) -->
<div class="tb-iot-hub-results-top">
<div class="tb-iot-hub-search-field">
<div class="tb-iot-hub-results-top flex shrink-0 items-center justify-between pt-6">
<div class="tb-iot-hub-search-field flex items-center gap-2">
<mat-icon class="tb-iot-hub-search-icon">search</mat-icon>
<input class="tb-iot-hub-search-input"
[placeholder]="'iot-hub.search' | translate"
@ -361,7 +340,7 @@
</div>
<!-- Sort row: results count + filter chips + sort -->
<div class="tb-iot-hub-sort-row">
<div class="tb-iot-hub-sort-row flex shrink-0 flex-wrap items-center gap-2 py-2">
<span class="tb-iot-hub-results-count">
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
@ -409,7 +388,7 @@
{{ 'iot-hub.clear-all' | translate }}
</button>
}
<span class="tb-iot-hub-sort-spacer"></span>
<span class="flex-1"></span>
<button mat-button color="primary" class="tb-iot-hub-sort-btn" [matMenuTriggerFor]="sortMenu">
<mat-icon>filter_list</mat-icon>
{{ sortOptions[selectedSortIndex].label | translate }}
@ -423,14 +402,14 @@
<!-- Loading -->
@if (isLoading) {
<div class="tb-iot-hub-loading">
<div class="flex justify-center py-20">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<!-- Error -->
@if (hasError && !isLoading) {
<div class="tb-iot-hub-empty-state">
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.unable-to-load' | translate }}</h3>
<p>{{ 'iot-hub.unable-to-load-text' | translate }}</p>
@ -442,7 +421,7 @@
<!-- Empty state -->
@if (!isLoading && !hasError && items.length === 0) {
<div class="tb-iot-hub-empty-state">
<div class="tb-iot-hub-empty-state flex flex-col items-center py-20 text-center">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.no-items-found-text' | translate }}</p>
@ -478,8 +457,8 @@
</div>
<!-- Pagination -->
<div class="tb-iot-hub-pagination">
<div class="tb-iot-hub-pagination-pages">
<div class="tb-iot-hub-pagination flex shrink-0 items-center pt-6">
<div class="tb-iot-hub-pagination-pages flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>

161
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss

@ -15,16 +15,13 @@
*/
@import "../../../../../scss/constants";
@import "./iot-hub-common";
:host {
display: block;
}
.tb-iot-hub-browse {
display: flex;
flex-direction: column;
height: 100%;
&.tabs-hidden:not(.embedded) {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
@ -67,6 +64,8 @@
}
}
// tb-iot-hub-drawer-content layout handled via Tailwind (flex flex-1); overflow stays default visible
// Left sidebar filters — Design: 280px wide, right border, own scroll
.tb-iot-hub-filters {
width: 300px;
@ -118,13 +117,6 @@
overflow-y: auto;
}
// Drawer content — holds desktop filters + results side by side
.tb-iot-hub-drawer-content {
display: flex;
flex: 1;
overflow: visible;
}
// Filter toggle button — hidden on large screens, shown on lt-lg
.tb-iot-hub-filter-toggle-btn {
display: none;
@ -162,9 +154,6 @@
}
.tb-iot-hub-filter-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 12px 24px;
font-size: 18px;
font-weight: 500;
@ -177,10 +166,6 @@
}
.tb-iot-hub-filter-drawer-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 4px 24px 12px;
}
@ -256,11 +241,6 @@
}
}
.tb-vendor-filter-field {
width: 100%;
margin-top: 4px;
}
.tb-iot-hub-filter-items {
padding-left: 44px;
max-height: 340px;
@ -295,55 +275,19 @@
}
.tb-iot-hub-filter-search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
@include tb-iot-hub-search-container;
margin: 4px 8px 4px 56px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.54);
flex-shrink: 0;
@include tb-iot-hub-search-icon(rgba(0, 0, 0, 0.54));
}
input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
padding: 0;
min-width: 0;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
@include tb-iot-hub-search-input-text(0.2px);
}
.tb-iot-hub-filter-search-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@include tb-iot-hub-search-clear;
}
}
@ -370,14 +314,6 @@
}
}
// Top bar: results count + search
.tb-iot-hub-results-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0 0;
flex-shrink: 0;
}
// Results count — Design: 14px Medium, rgba(0,0,0,0.76), tracking 0.25
.tb-iot-hub-results-count {
@ -388,71 +324,21 @@
color: rgba(0, 0, 0, 0.76);
}
// Search field — Design: 320px, h-40, rounded-8, border rgba(0,0,0,0.12), white bg
.tb-iot-hub-search-field {
@include tb-iot-hub-search-container;
width: 320px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: white;
}
.tb-iot-hub-search-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.38);
flex-shrink: 0;
@include tb-iot-hub-search-icon;
}
.tb-iot-hub-search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
font-family: inherit;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
@include tb-iot-hub-search-input-text;
}
.tb-iot-hub-search-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
// Sort row + active filters
.tb-iot-hub-sort-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
flex-shrink: 0;
flex-wrap: wrap;
}
.tb-iot-hub-sort-spacer {
flex: 1;
@include tb-iot-hub-search-clear;
}
// Sort button — Design: 14px Medium, primary color, with filter_list icon
@ -499,20 +385,8 @@
}
}
// Loading / Empty states
.tb-iot-hub-loading {
display: flex;
justify-content: center;
padding: 80px 0;
}
// Empty state text + no-data image
.tb-iot-hub-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0;
text-align: center;
.tb-no-data-bg {
flex: auto;
height: 100px;
@ -532,20 +406,13 @@
}
}
// Pagination — Design: centered page numbers, items per page on the right
// Pagination — centered page numbers via absolute positioning
.tb-iot-hub-pagination {
display: flex;
align-items: center;
padding: 24px 0 0;
flex-shrink: 0;
position: relative;
}
// Page buttons — Design: h-40, px-16 py-8, rounded-4, gap-1
// Page buttons — centered via absolute positioning
.tb-iot-hub-pagination-pages {
display: flex;
align-items: center;
gap: 1px;
position: absolute;
left: 50%;
transform: translateX(-50%);

189
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts

@ -25,11 +25,10 @@ import { ItemType, FilterParamInfo } from '@shared/models/iot-hub/iot-hub-item.m
import { widgetTypeTranslations, cfTypeTranslations, ruleChainTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models';
import { IotHubInstalledItem, DeviceInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from './device-install-dialog/device-install-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { filterIotHubItemsBySearch, groupIotHubFilterItems, IotHubFilterGroup } from '@home/components/iot-hub/iot-hub-utils';
interface SortOption {
value: string;
@ -37,15 +36,6 @@ interface SortOption {
direction: Direction;
}
const TYPE_TABS: ItemType[] = [
ItemType.WIDGET,
ItemType.DASHBOARD,
ItemType.SOLUTION_TEMPLATE,
ItemType.CALCULATED_FIELD,
ItemType.RULE_CHAIN,
ItemType.DEVICE
];
@Component({
selector: 'tb-iot-hub-browse',
standalone: false,
@ -114,6 +104,15 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
{ value: 'publishedTime', label: 'iot-hub.sort-newest', direction: Direction.DESC },
{ value: 'name', label: 'iot-hub.sort-name', direction: Direction.ASC }
];
typeTabs: { type: ItemType; label: string }[] = [
{ type: ItemType.WIDGET, label: 'item.type-widget-plural' },
{ type: ItemType.DASHBOARD, label: 'item.type-dashboard-plural' },
{ type: ItemType.SOLUTION_TEMPLATE, label: 'item.type-solution-template-plural' },
{ type: ItemType.CALCULATED_FIELD, label: 'item.type-calculated-field-plural' },
{ type: ItemType.RULE_CHAIN, label: 'item.type-rule-chain-plural' },
{ type: ItemType.DEVICE, label: 'item.type-device-plural' }
];
selectedSortIndex = 0;
subtypeOptions: FilterParamInfo[] = [];
@ -138,8 +137,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
private iotHubActions: IotHubActionsService,
private translate: TranslateService,
private router: Router,
private route: ActivatedRoute,
private dialog: MatDialog
private route: ActivatedRoute
) {}
ngOnInit(): void {
@ -181,24 +179,9 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
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.activeConnectivity.clear();
this.activeHardwareTypes.clear();
this.activeVendors.clear();
this.clearActiveFilterSets();
this.filterSearch = {};
this.loadFilterInfo();
this.pageIndex = 0;
@ -255,7 +238,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
const pages: number[] = [];
const maxVisible = 5;
let start = Math.max(0, this.pageIndex - Math.floor(maxVisible / 2));
let end = Math.min(total, start + maxVisible);
const end = Math.min(total, start + maxVisible);
if (end - start < maxVisible) {
start = Math.max(0, end - maxVisible);
}
@ -278,12 +261,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
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);
@ -294,12 +271,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems();
}
clearUseCases(): void {
this.activeUseCases.clear();
this.pageIndex = 0;
this.loadItems();
}
onConnectivityToggle(value: string): void {
if (this.activeConnectivity.has(value)) {
this.activeConnectivity.delete(value);
@ -310,12 +281,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems();
}
clearConnectivity(): void {
this.activeConnectivity.clear();
this.pageIndex = 0;
this.loadItems();
}
isConnectivityActive(value: string): boolean {
return this.activeConnectivity.has(value);
}
@ -330,12 +295,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems();
}
clearHardwareTypes(): void {
this.activeHardwareTypes.clear();
this.pageIndex = 0;
this.loadItems();
}
isHardwareTypeActive(value: string): boolean {
return this.activeHardwareTypes.has(value);
}
@ -386,12 +345,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems();
}
clearSubtypes(): void {
this.getActiveSubtypes().clear();
this.pageIndex = 0;
this.loadItems();
}
getActiveSubtypesArray(): string[] {
if (this.fixedSubType) {
return Array.from(this.getActiveSubtypes()).filter(s => s !== this.fixedSubType);
@ -407,52 +360,12 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
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();
}
getFilteredItems(items: FilterParamInfo[], searchKey: string): FilterParamInfo[] {
const search = (this.filterSearch[searchKey] || '').toLowerCase();
if (!search) { return items; }
return items.filter(item => item.key.toLowerCase().includes(search));
return filterIotHubItemsBySearch(items, this.filterSearch[searchKey]);
}
getGroupedFilterItems(items: FilterParamInfo[], searchKey: string): { label: string; items: FilterParamInfo[] }[] {
const filtered = this.getFilteredItems(items, searchKey);
if (items.length < 11) {
return [{ label: null, items: filtered }];
}
const topKeys = new Set(
[...items].sort((a, b) => b.totalInstallCount - a.totalInstallCount).slice(0, 10).map(i => i.key)
);
const popular = filtered.filter(i => topKeys.has(i.key));
const rest = filtered.filter(i => !topKeys.has(i.key));
const groups: { label: string; items: FilterParamInfo[] }[] = [];
if (popular.length) {
groups.push({ label: 'iot-hub.most-popular', items: popular });
}
if (rest.length) {
groups.push({ label: 'iot-hub.all', items: rest });
}
return groups;
getGroupedFilterItems(items: FilterParamInfo[], searchKey: string): IotHubFilterGroup[] {
return groupIotHubFilterItems(items, this.filterSearch[searchKey]);
}
getFilteredConnectivityGroups(searchKey: string): { group: string; values: FilterParamInfo[] }[] {
@ -479,14 +392,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
}
clearAllFilters(): void {
this.activeCategories.clear();
this.activeUseCases.clear();
this.activeCfTypes.clear();
this.activeWidgetTypes.clear();
this.activeRuleChainTypes.clear();
this.activeConnectivity.clear();
this.activeHardwareTypes.clear();
this.activeVendors.clear();
this.clearActiveFilterSets();
if (this.fixedSubType) {
this.getActiveSubtypes()?.add(this.fixedSubType);
}
@ -497,6 +403,17 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
this.loadItems();
}
private clearActiveFilterSets(): void {
this.activeCategories.clear();
this.activeUseCases.clear();
this.activeCfTypes.clear();
this.activeWidgetTypes.clear();
this.activeRuleChainTypes.clear();
this.activeConnectivity.clear();
this.activeHardwareTypes.clear();
this.activeVendors.clear();
}
get activeFilterCount(): number {
const subtypeCount = this.fixedSubType ? this.getActiveSubtypesArray().length : (this.getActiveSubtypes()?.size || 0);
return subtypeCount + this.activeCategories.size + this.activeUseCases.size +
@ -526,28 +443,6 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
}
}
getSubtitle(): string {
switch (this.activeType) {
case ItemType.WIDGET: return 'iot-hub.subtitle-widgets';
case ItemType.DASHBOARD: return 'iot-hub.subtitle-dashboards';
case ItemType.SOLUTION_TEMPLATE: return 'iot-hub.subtitle-solution-templates';
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.SOLUTION_TEMPLATE: return 'iot-hub.search-solution-templates';
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';
}
}
getInstalledItem(item: MpItemVersionView): IotHubInstalledItem | undefined {
if (this.activeType === ItemType.WIDGET && this.installedWidgets) {
return this.installedWidgets.find(i => i.itemId === item.itemId);
@ -587,7 +482,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
updateItem(item: MpItemVersionView): void {
const installedItem = this.getInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.updateItem(item, installedItem).subscribe(result => {
this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => {
if (result === 'updated') {
this.reloadInstalledItems();
}
@ -597,7 +492,7 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
deleteInstalledItem(item: MpItemVersionView): void {
const installedItem = this.getInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.deleteItem(item, installedItem).subscribe(() => {
this.iotHubActions.deleteItem(installedItem).subscribe(() => {
this.reloadInstalledItems();
});
}
@ -606,31 +501,13 @@ export class TbIotHubBrowseComponent implements OnInit, OnDestroy {
const installedItem = this.getInstalledItem(item);
if (!installedItem) { return; }
const descriptor = installedItem.descriptor as DeviceInstalledItemDescriptor;
this.iotHubApiService.getVersionFileData(installedItem.itemVersionId, { ignoreLoading: true, ignoreErrors: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
disableClose: false,
autoFocus: false,
data: {
item,
zipData,
reviewMode: true,
selectedInstallMethod: descriptor?.selectedInstallMethod,
installState: descriptor?.installState
} as DeviceInstallDialogData
});
}
this.iotHubApiService.getVersionInfo(installedItem.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => {
this.iotHubActions.reviewDevice(versionView, descriptor).subscribe();
});
}
navigateToCreator(creatorId: string): void {
this.router.navigate(['/iot-hub/creator', creatorId]);
}
navigateToInstalledItems(): void {
this.router.navigate(['/iot-hub/installed']);
void this.router.navigate(['/iot-hub/creator', creatorId]);
}
private loadInstalledWidgets(): void {

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.html

@ -15,11 +15,11 @@
limitations under the License.
-->
<div class="tb-iot-hub-delete-content">
<div class="flex flex-col gap-5 p-6">
<h2 class="tb-iot-hub-delete-title">{{ 'iot-hub.remove-confirm-title' | translate:{ name: data.itemName } }}</h2>
<p class="tb-iot-hub-delete-desc">{{ (data.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.remove-desc-solution-template' : 'iot-hub.remove-desc') | translate }}</p>
</div>
<div class="tb-iot-hub-delete-actions">
<div class="flex items-center justify-end gap-2 p-2">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="warn" (click)="confirm()">{{ 'iot-hub.remove-anyway' | translate }}</button>
</div>

15
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-delete-dialog.component.scss

@ -14,13 +14,6 @@
* limitations under the License.
*/
.tb-iot-hub-delete-content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
}
.tb-iot-hub-delete-title {
font-size: 20px;
font-weight: 600;
@ -38,11 +31,3 @@
color: rgba(0, 0, 0, 0.54);
margin: 0;
}
.tb-iot-hub-delete-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px;
}

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-iot-hub-install-content">
<div class="flex flex-col gap-5 p-6">
@switch (state) {
@case ('confirm') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
@ -61,7 +61,7 @@
@case ('installing') {
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" disabled>
<mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner>
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
{{ 'iot-hub.installing' | translate }}
</button>
}

13
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.scss

@ -14,13 +14,6 @@
* limitations under the License.
*/
.tb-iot-hub-install-content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
}
.tb-iot-hub-install-title {
font-size: 20px;
font-weight: 600;
@ -38,12 +31,6 @@
margin: 0;
}
.tb-iot-hub-inline-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.tb-iot-hub-error-details {
max-height: 200px;
overflow-y: auto;

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

@ -22,10 +22,7 @@ import { AppState } from '@core/core.state';
import { DialogComponent } from '@shared/components/dialog.component';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import {
IotHubInstalledItemDescriptor,
SolutionTemplateInstalledItemDescriptor
} from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models';
@ -125,7 +122,7 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
openEntityDetails(): void {
if (this.entityDetailsUrl) {
this.dialogRef.close('installed');
this.router.navigateByUrl(this.entityDetailsUrl);
void this.router.navigateByUrl(this.entityDetailsUrl);
}
}

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

@ -15,13 +15,13 @@
limitations under the License.
-->
<div class="tb-iot-hub-installed-items-dialog">
<div class="dlg-header">
<h2 class="dlg-title" tbTruncateWithTooltip>
<div class="flex h-full min-h-0 flex-col">
<div class="dlg-header flex h-16 shrink-0 items-center gap-2 py-3 pl-6 pr-3">
<h2 class="dlg-title min-w-0 flex-1 truncate" tbTruncateWithTooltip>
{{ 'iot-hub.installed-items-for' | translate:{ name: item.name } }}
</h2>
<button mat-icon-button
class="dlg-close"
class="shrink-0"
type="button"
(click)="close()">
<mat-icon>close</mat-icon>

22
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-installed-items-dialog.component.scss

@ -23,39 +23,17 @@
min-height: 0;
}
.tb-iot-hub-installed-items-dialog {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.dlg-header {
display: flex;
align-items: center;
gap: 8px;
height: 64px;
padding: 12px 12px 12px 24px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
.dlg-title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dlg-close {
flex-shrink: 0;
}
:host-context(.mat-mdc-dialog-container) {

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

@ -15,8 +15,8 @@
limitations under the License.
-->
<div class="tb-installed-table-container">
<div class="tb-installed-table-wrapper">
<div class="tb-installed-table-container flex flex-1 flex-col overflow-hidden rounded-lg">
<div class="tb-installed-table-wrapper flex-1 overflow-auto">
<table mat-table [dataSource]="dataSource" matSort matSortActive="createdTime" matSortDirection="desc">
<!-- Name -->
<ng-container matColumnDef="itemName">
@ -54,7 +54,7 @@
@if (updatesChecked) {
@if (getPublishedVersionInfo(item); as pubInfo) {
@if (pubInfo.publishedVersionId !== item.itemVersionId) {
<div class="tb-installed-update-row">
<div class="flex items-center gap-3">
@if (!itemVersion) {
<button class="tb-installed-version-btn" (click)="viewUpdateDetails(pubInfo, item)">
<mat-icon>arrow_upward</mat-icon>
@ -86,14 +86,14 @@
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions" stickyEnd>
<ng-container matColumnDef="actions" [stickyEnd]="true">
<th mat-header-cell *matHeaderCellDef>
<div [ngClass]="itemVersion ? 'gt-sm:!hidden' : 'gt-md:!hidden'" style="min-width: 48px;"></div>
<div [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'" style="min-width: 144px;"></div>
</th>
<td mat-cell *matCellDef="let item">
<!-- Desktop: individual buttons -->
<div class="tb-installed-actions" [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'">
<div class="tb-installed-actions flex items-center justify-end" [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'">
@if (!itemVersion || item.itemType === 'DEVICE') {
<button mat-icon-button (click)="viewItemDetails(item)" matTooltip="{{ (item.itemType === 'DEVICE' ? 'iot-hub.review-device-instructions' : 'iot-hub.view-item-details') | translate }}">
<mat-icon>info_outline</mat-icon>
@ -132,18 +132,18 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-row *matRowDef="let _row; columns: displayedColumns;"></tr>
</table>
</div>
@if (isLoading) {
<div class="tb-installed-loading">
<div class="flex justify-center py-12">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
@if (!isLoading && dataSource.length === 0) {
<div class="tb-installed-empty">
<div class="tb-installed-empty py-12 text-center text-sm">
{{ 'iot-hub.no-installed-items' | translate }}
</div>
}
@ -157,6 +157,6 @@
[pageSizeOptions]="[10, 25, 50]"
[hidePageSize]="hidePageSize"
(page)="onPageChange($event)"
showFirstLastButtons>
[showFirstLastButtons]="true">
</mat-paginator>
</div>

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

@ -23,20 +23,12 @@
min-height: 0;
}
// Table + paginator container — unified border
// Table + paginator container — border
.tb-installed-table-container {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.tb-installed-table-wrapper {
overflow: auto;
flex: 1;
table {
width: 100%;
}
@ -137,13 +129,6 @@
.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); }
.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 0.06); }
// Update row — flex container for version btn + update btn
.tb-installed-update-row {
display: flex;
align-items: center;
gap: 12px;
}
// "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8
.tb-installed-version-btn {
display: inline-flex;
@ -206,13 +191,8 @@
}
}
// Actions column
// Actions column — icon-button tweaks
.tb-installed-actions {
display: flex;
align-items: center;
gap: 0;
justify-content: flex-end;
.mat-mdc-icon-button {
width: 36px;
height: 36px;
@ -223,18 +203,8 @@
}
}
// Loading / Empty
.tb-installed-loading {
display: flex;
justify-content: center;
padding: 48px 0;
}
.tb-installed-empty {
text-align: center;
padding: 48px 0;
color: rgba(0, 0, 0, 0.54);
font-size: 14px;
}
:host(:not(.tb-installed-with-version)) {

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

@ -29,14 +29,10 @@ import {
ViewChild
} from '@angular/core';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatPaginator } from '@angular/material/paginator';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { switchMap } from 'rxjs/operators';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
@ -49,22 +45,7 @@ import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils';
import {
IotHubItemDetailDialogData,
TbIotHubItemDetailDialogComponent
} from '@home/components/iot-hub/iot-hub-item-detail-dialog.component';
import {
IotHubUpdateDialogData,
TbIotHubUpdateDialogComponent
} from '@home/components/iot-hub/iot-hub-update-dialog.component';
import {
IotHubDeleteDialogData,
TbIotHubDeleteDialogComponent
} from '@home/components/iot-hub/iot-hub-delete-dialog.component';
import {
DeviceInstallDialogData,
TbDeviceInstallDialogComponent
} from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
@Component({
selector: 'tb-iot-hub-installed-items-table',
@ -102,9 +83,8 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
constructor(
private iotHubApiService: IotHubApiService,
private translate: TranslateService,
private store: Store<AppState>,
private router: Router,
private dialog: MatDialog,
private iotHubActions: IotHubActionsService,
private elementRef: ElementRef,
private zone: NgZone,
private cd: ChangeDetectorRef
@ -177,30 +157,9 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
}
deleteItem(item: IotHubInstalledItem): void {
const dialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, {
panelClass: ['tb-dialog'],
autoFocus: false,
data: { itemName: item.itemName, itemType: item.itemType } as IotHubDeleteDialogData
});
dialogRef.afterClosed().subscribe(confirmed => {
this.iotHubActions.deleteItem(item).subscribe(confirmed => {
if (confirmed) {
this.iotHubApiService.deleteInstalledItem(item.id.id).subscribe({
next: () => {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant('iot-hub.installed-item-removed', {name: item.itemName}),
type: 'success',
duration: 3000
}));
this.loadData();
},
error: () => {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant('iot-hub.installed-item-remove-error', {name: item.itemName}),
type: 'error',
duration: 5000
}));
}
});
this.loadData();
}
});
}
@ -228,15 +187,7 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
return;
}
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => {
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: {
item: versionView,
installedItem: item
} as IotHubItemDetailDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.openItemDetail(versionView, item).subscribe(result => {
if (result === 'updated' || result === 'deleted') {
this.loadData();
}
@ -248,32 +199,12 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
const descriptor = item.descriptor as DeviceInstalledItemDescriptor;
if (!descriptor.installState || !descriptor.selectedInstallMethod) {
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => {
this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: { item: versionView, installedItem: item } as IotHubItemDetailDialogData
});
this.iotHubActions.openItemDetail(versionView, item).subscribe();
});
return;
}
this.iotHubApiService.getVersionFileData(item.itemVersionId, { ignoreLoading: true, ignoreErrors: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => {
this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
disableClose: false,
autoFocus: false,
data: {
item: versionView,
zipData,
reviewMode: true,
selectedInstallMethod: descriptor.selectedInstallMethod,
installState: descriptor.installState
} as DeviceInstallDialogData
});
});
}
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => {
this.iotHubActions.reviewDevice(versionView, descriptor).subscribe();
});
}
@ -333,15 +264,7 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
viewUpdateDetails(publishedInfo: ItemPublishedVersionInfo, installedItem: IotHubInstalledItem): void {
this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => {
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: {
item: versionView,
installedItem
} as IotHubItemDetailDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.openItemDetail(versionView, installedItem).subscribe(result => {
if (result === 'updated' || result === 'deleted') {
this.loadData();
}
@ -350,17 +273,7 @@ export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges,
}
updateItem(item: IotHubInstalledItem, publishedInfo: ItemPublishedVersionInfo): void {
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, {
panelClass: ['tb-dialog'],
data: {
installedItemId: item.id.id,
itemName: item.itemName,
itemType: item.itemType as ItemType,
version: publishedInfo.publishedVersion,
versionId: publishedInfo.publishedVersionId,
} as IotHubUpdateDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.updateItem(item, publishedInfo.publishedVersion, publishedInfo.publishedVersionId).subscribe(result => {
if (result === 'updated') {
this.loadData();
}

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

@ -17,11 +17,11 @@
-->
@if (isCompactLayout()) {
<!-- Compact card (CF / RC) — Design: horizontal row, icon | content | button -->
<div class="tb-iot-hub-card-compact" (click)="onCardClick()">
<div class="tb-iot-hub-card-compact" role="button" tabindex="0" (click)="onCardClick()" (keyup.enter)="onCardClick()">
<div class="tb-iot-hub-compact-icon" [style.background]="item.color || '#4caf50'">
<tb-icon>{{ getPlaceholderIcon() }}</tb-icon>
</div>
<div class="tb-iot-hub-compact-content">
<div class="flex min-w-0 flex-1 flex-col gap-2">
@if (showSubtype && getSubtypeLabel(); as subtypeLabel) {
<span class="tb-iot-hub-card-subtype">{{ subtypeLabel }}</span>
}
@ -33,7 +33,10 @@
} @else {
<tb-icon class="tb-iot-hub-compact-author-icon">person</tb-icon>
}
<span class="tb-iot-hub-compact-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-compact-creator" role="button" tabindex="0"
[class.clickable]="mode !== 'add'"
(click)="mode !== 'add' && onCreatorClick($event)"
(keyup.enter)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-compact-dot">&middot;</span>
}
<tb-icon class="tb-iot-hub-compact-download-icon">download</tb-icon>
@ -76,7 +79,7 @@
</div>
} @else {
<!-- Big card (Widget / Dashboard / Solution Template) -->
<div class="tb-iot-hub-item-card" (click)="onCardClick()">
<div class="tb-iot-hub-item-card" role="button" tabindex="0" (click)="onCardClick()" (keyup.enter)="onCardClick()">
<div class="tb-iot-hub-card-preview">
@if (getPreviewUrl(); as previewUrl) {
<img [src]="previewUrl" alt="" class="tb-iot-hub-preview-image">
@ -86,7 +89,7 @@
</div>
}
</div>
<div class="tb-iot-hub-card-content">
<div class="flex flex-1 flex-col gap-2 px-4 pb-4">
@if (showSubtype && getSubtypeLabel(); as subtypeLabel) {
<span class="tb-iot-hub-card-subtype">{{ subtypeLabel }}</span>
}
@ -98,7 +101,10 @@
} @else {
<tb-icon class="tb-iot-hub-card-author-icon">person</tb-icon>
}
<span class="tb-iot-hub-card-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-card-creator" role="button" tabindex="0"
[class.clickable]="mode !== 'add'"
(click)="mode !== 'add' && onCreatorClick($event)"
(keyup.enter)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }}</span>
<span class="tb-iot-hub-card-dot">&middot;</span>
}
<span class="tb-iot-hub-card-stat">

50
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.scss

@ -45,15 +45,13 @@
// Preview area — Design: aspect ~305:172, bg rgba(0,0,0,0.04), overflow-clip, p-12, dot pattern bg
.tb-iot-hub-card-preview {
aspect-ratio: 305 / 172;
background: rgba(0, 0, 0, 0.04);
background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 20px 20px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
padding: 12px;
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
}
.tb-iot-hub-preview-image {
@ -91,15 +89,6 @@
font-weight: 600;
}
// Content area — Design: px-16, gap-8, flex-col
.tb-iot-hub-card-content {
padding: 0 16px 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
// Compact card (CF / RC) — Design: horizontal flex, gap-16, p-16, rounded-8
.tb-iot-hub-card-compact {
display: flex;
@ -279,10 +268,6 @@
flex-shrink: 0;
}
.tb-iot-hub-card-published {
display: none;
}
.tb-iot-hub-card-stats {
display: flex;
align-items: center;
@ -354,8 +339,7 @@
}
}
// Guide icon button
.tb-iot-hub-card-guide-btn {
%tb-iot-hub-card-icon-btn {
display: flex;
align-items: center;
justify-content: center;
@ -369,32 +353,26 @@
border-radius: 50%;
transition: background 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
// Guide icon button
.tb-iot-hub-card-guide-btn {
@extend %tb-iot-hub-card-icon-btn;
tb-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: rgba(0, 0, 0, 0.54);
}
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
// Remove icon button — Design: trash-can-outline 24px, rgba(0,0,0,0.54)
.tb-iot-hub-card-remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 4px;
border: none;
background: none;
cursor: pointer;
flex-shrink: 0;
border-radius: 50%;
transition: background 0.15s;
@extend %tb-iot-hub-card-icon-btn;
tb-icon {
font-size: 24px;
@ -402,10 +380,6 @@
height: 24px;
color: rgba(0, 0, 0, 0.54);
}
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
.tb-iot-hub-card-update-btn {

99
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts

@ -15,8 +15,8 @@
///
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 } from '@shared/models/iot-hub/iot-hub-item.models';
import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { TranslateService } from '@ngx-translate/core';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
@ -30,8 +30,6 @@ import { IotHubApiService } from '@core/http/iot-hub-api.service';
export class TbIotHubItemCardComponent {
readonly ItemType = ItemType;
readonly maxVisibleCategories = 3;
readonly maxVisibleNodes = 2;
@Input() item: MpItemVersionView;
@Input() installedItem: IotHubInstalledItem;
@ -48,8 +46,6 @@ export class TbIotHubItemCardComponent {
@Output() addClick = new EventEmitter<MpItemVersionView>();
@Output() guideClick = new EventEmitter<MpItemVersionView>();
typeTranslations = itemTypeTranslations;
constructor(
private translate: TranslateService,
private iotHubApiService: IotHubApiService
@ -68,18 +64,6 @@ export class TbIotHubItemCardComponent {
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';
case ItemType.SOLUTION_TEMPLATE: return 'preview-dashboard';
default: return '';
}
}
getPlaceholderIcon(): string {
switch (this.item.type) {
case ItemType.WIDGET: return 'widgets';
@ -94,13 +78,6 @@ export class TbIotHubItemCardComponent {
}
}
getCustomIconColor(): string | null {
if (this.isCompactLayout() && this.item.color) {
return this.item.color;
}
return null;
}
getSubtypeLabel(): string {
switch (this.item.type) {
case ItemType.WIDGET: {
@ -123,71 +100,6 @@ export class TbIotHubItemCardComponent {
}
}
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 [];
}
return this.item.categories;
}
getFirstUseCaseLabel(): string | null {
if (!this.item.useCases?.length) {
return null;
}
return 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';
}
}
isInstalled(): boolean {
return !!this.installedItem;
}
@ -229,14 +141,9 @@ export class TbIotHubItemCardComponent {
this.guideClick.emit(this.item);
}
onCreatorClick(event: MouseEvent): void {
onCreatorClick(event: Event): 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' });
}
}

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

@ -23,7 +23,7 @@
<tb-icon>{{ getCompactIcon() }}</tb-icon>
</div>
}
<div class="dlg-header-content">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<span class="dlg-title">{{ item.name }}</span>
<div class="dlg-subtitle">
<span class="dlg-subtitle-group">
@ -49,7 +49,7 @@
}
</div>
</div>
<div class="dlg-header-actions">
<div class="flex shrink-0 items-center gap-2">
@if (isInstalled()) {
@if (hasUpdate()) {
<button class="dlg-update-btn" (click)="updateItem()">
@ -62,7 +62,7 @@
</span>
}
}
<button mat-icon-button class="dlg-close" (click)="close()" type="button">
<button mat-icon-button class="shrink-0" (click)="close()" type="button">
<tb-icon>close</tb-icon>
</button>
</div>
@ -82,20 +82,10 @@
</div>
}
</div>
<div class="dlg-info-actions">
@if (mode !== 'add' && isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
}
</div>
</div>
} @else {
<!-- Widget/Dashboard/Solution: image + description side by side -->
<div class="dlg-row-1">
<div class="flex gap-6">
<div class="dlg-preview" [class.dlg-preview-carousel]="item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1">
@if (item.type === ItemType.SOLUTION_TEMPLATE && carouselImages.length > 1) {
<div class="dlg-hero-carousel">
@ -110,11 +100,11 @@
[show-num]="1"
class="dlg-carousel">
<section ngx-hm-carousel-container class="dlg-carousel-container">
<article class="dlg-carousel-item"
ngx-hm-carousel-item
*ngFor="let imageUrl of carouselImages">
<img [src]="imageUrl" alt="">
</article>
@for (imageUrl of carouselImages; track imageUrl) {
<article class="dlg-carousel-item" ngx-hm-carousel-item>
<img [src]="imageUrl" alt="">
</article>
}
</section>
<ng-template #carouselPrev><span class="dlg-carousel-hidden-nav"></span></ng-template>
<ng-template #carouselNext><span class="dlg-carousel-hidden-nav"></span></ng-template>
@ -126,9 +116,10 @@
</button>
<div class="dlg-carousel-dots">
@for (img of carouselImages; track img; let i = $index) {
<div class="dlg-carousel-dot"
<div class="dlg-carousel-dot" role="button" tabindex="0"
[class.active]="i === carouselIndex"
(click)="carouselIndex = i"></div>
(click)="carouselIndex = i"
(keyup.enter)="carouselIndex = i"></div>
}
</div>
<button mat-icon-button class="dlg-carousel-nav" (click)="goToNextSlide()">
@ -154,17 +145,6 @@
</div>
}
</div>
<!-- Action buttons -->
<div class="dlg-info-actions">
@if (mode !== 'add' && isInstalled()) {
@if (item.type !== ItemType.WIDGET && item.type !== ItemType.SOLUTION_TEMPLATE) {
<button mat-stroked-button (click)="install()">
<mat-icon>add</mat-icon>
{{ 'iot-hub.install-one-more' | translate }}
</button>
}
}
</div>
</div>
</div>
}
@ -177,7 +157,10 @@
@if (showCreator) {
<div class="dlg-meta-group">
<span class="dlg-meta-label">{{ 'iot-hub.creator' | translate }}</span>
<div class="dlg-meta-creator" [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && navigateToCreator()">
<div class="dlg-meta-creator" role="button" tabindex="0"
[class.clickable]="mode !== 'add'"
(click)="mode !== 'add' && navigateToCreator()"
(keyup.enter)="mode !== 'add' && navigateToCreator()">
<div class="dlg-meta-creator-avatar">
@if (getCreatorAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
@ -278,7 +261,7 @@
<div class="dlg-meta-group dlg-meta-details">
<span class="dlg-meta-label">{{ 'iot-hub.details' | translate }}</span>
<button mat-icon-button class="dlg-manage-icon-btn dlg-meta-details-btn"
tb-popover #detailsPopover="tbPopover"
tb-popover
[tbPopoverContent]="detailsPopoverContent"
tbPopoverTrigger="click"
tbPopoverPlacement="bottom"

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

@ -72,14 +72,6 @@
}
}
.dlg-header-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
// Title — Design: 24px Medium, rgba(0,0,0,0.87)
.dlg-title {
font-size: 24px;
@ -122,17 +114,6 @@
flex-shrink: 0;
}
.dlg-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.dlg-close {
flex-shrink: 0;
}
// Scrollable body
.dlg-body {
overflow-y: auto;
@ -146,20 +127,12 @@
padding: 0 24px 16px;
}
// Row 1 — Design: flex row, gap-24
.dlg-row-1 {
display: flex;
gap: 24px;
}
// Preview image — Design: flex-1, aspect 305:172, bg rgba(0,0,0,0.06), rounded-8, p-16, dot pattern
.dlg-preview {
flex: 1;
min-width: 0;
aspect-ratio: 305 / 172;
background: rgba(0, 0, 0, 0.06);
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
background: radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px) 0 0 / 20px 20px rgba(0, 0, 0, 0.06);
border-radius: 8px;
overflow: hidden;
display: flex;
@ -753,7 +726,6 @@
cursor: pointer;
text-decoration: none;
border: none;
border-bottom: none;
mat-icon {
font-size: 16px;
@ -763,8 +735,6 @@
&:hover {
text-decoration: underline;
border: none;
border-bottom: none;
}
}
@ -775,18 +745,6 @@
gap: 16px;
}
// Action buttons row — Design: flex, full width, Remove always right-aligned
.dlg-info-actions {
display: flex;
align-items: center;
width: 100%;
[color="warn"] {
margin-left: auto;
--mat-button-outlined-outline-color: var(--mat-button-outlined-label-text-color);
}
}
// Footer
.dlg-footer {
display: flex;
@ -802,22 +760,10 @@
}
// Responsive
@media #{$mat-lt-md} {
.dlg-row-1 {
flex-direction: column;
}
}
@media #{$mat-xs} {
.dlg-header {
flex-direction: column;
position: relative;
padding-right: 48px;
}
.dlg-close {
position: absolute;
top: 12px;
right: 12px;
}
}

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

@ -20,23 +20,16 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DialogComponent } from '@shared/components/dialog.component';
import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, nodeComponentTypeTranslations, NodeInfo } from '@shared/models/iot-hub/iot-hub-version.models';
import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTranslations, widgetTypeTranslations, NodeInfo } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL } from '@core/utils';
import { TbIotHubInstallDialogComponent, IotHubInstallDialogData } from '@home/components/iot-hub/iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from '@home/components/iot-hub/iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent, IotHubDeleteDialogData } from '@home/components/iot-hub/iot-hub-delete-dialog.component';
import { TbDeviceInstallDialogComponent, DeviceInstallDialogData } from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component';
import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import {
IotHubInstalledItemsDialogData,
TbIotHubInstalledItemsDialogComponent
} from '@home/components/iot-hub/iot-hub-installed-items-dialog.component';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
export type IotHubItemDetailDialogMode = 'default' | 'add';
@ -74,7 +67,8 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
@Inject(MAT_DIALOG_DATA) public data: IotHubItemDetailDialogData,
private dialog: MatDialog,
private translate: TranslateService,
private iotHubApiService: IotHubApiService
private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService
) {
super(store, router, dialogRef);
this.item = data.item;
@ -98,18 +92,6 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
return this.item.creatorAvatarUrl ? this.iotHubApiService.resolveResourceUrl(this.item.creatorAvatarUrl) : 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';
case ItemType.SOLUTION_TEMPLATE: return 'type-solution-template';
default: return '';
}
}
getTypeLabel(): string {
const key = this.typeTranslations.get(this.item.type);
return key ? this.translate.instant(key) : '';
@ -142,33 +124,6 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
}
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;
@ -196,49 +151,10 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
}
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;
}
isInstalled(): boolean {
return this.installedItem != null;
}
@ -251,13 +167,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
install(): void {
const dialogRef = this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'],
data: {
item: this.item,
} as IotHubInstallDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.installItem(this.item).subscribe(result => {
if (result === 'installed') {
this.dialogRef.close('installed');
}
@ -265,53 +175,24 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
installDevice(): void {
const versionId = this.item.id as string;
this.iotHubApiService.getVersionFileData(versionId, { ignoreLoading: true }).subscribe({
next: async (blob: Blob) => {
const zipData = await blob.arrayBuffer();
const dialogRef = this.dialog.open(TbDeviceInstallDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'],
disableClose: true,
autoFocus: false,
data: {
item: this.item,
zipData,
} as DeviceInstallDialogData
});
dialogRef.afterClosed().subscribe(result => {
if (result === 'installed') {
this.dialogRef.close('installed');
}
});
this.iotHubActions.installDevice(this.item).subscribe(result => {
if (result === 'installed') {
this.dialogRef.close('installed');
}
});
}
updateItem(): void {
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, {
panelClass: ['tb-dialog'],
data: {
installedItemId: this.installedItem.id.id,
itemName: this.item.name,
itemType: this.item.type,
version: this.item.version,
versionId: this.item.id,
} as IotHubUpdateDialogData
});
dialogRef.afterClosed().subscribe(result => {
this.iotHubActions.updateItem(this.installedItem, this.item.version, this.item.id as string).subscribe(result => {
if (result === 'updated') {
this.dialogRef.close('updated');
}
});
}
openSignup(): void {
window.open('https://iothub.thingsboard.io/signup', '_blank');
}
navigateToCreator(): void {
this.dialogRef.close();
this.router.navigate(['/iot-hub/creator', this.item.creatorId]);
void this.router.navigate(['/iot-hub/creator', this.item.creatorId]);
}
openEntityDetails(): void {
@ -335,7 +216,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
const url = getEntityDetailsPageURL(entityId, entityType);
if (url) {
this.dialogRef.close();
this.router.navigateByUrl(url);
void this.router.navigateByUrl(url);
}
}
}
@ -356,19 +237,9 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
deleteItem(): void {
if (!this.installedItem) {
return;
}
const deleteDialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, {
panelClass: ['tb-dialog'],
autoFocus: false,
data: { itemName: this.item.name, itemType: this.item.type } as IotHubDeleteDialogData
});
deleteDialogRef.afterClosed().subscribe(confirmed => {
this.iotHubActions.deleteItem(this.installedItem).subscribe(confirmed => {
if (confirmed) {
this.iotHubApiService.deleteInstalledItem(this.installedItem.id.id).subscribe(() => {
this.dialogRef.close('deleted');
});
this.dialogRef.close('deleted');
}
});
}
@ -378,12 +249,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
}
openInstalledItemsDialog(): void {
this.dialog.open(TbIotHubInstalledItemsDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
disableClose: true,
data: { item: this.item } as IotHubInstalledItemsDialogData
});
this.iotHubActions.openInstalledItems(this.item).subscribe();
}
hasDetails(): boolean {
@ -433,6 +299,6 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
private prefixResourceUrls(markdown: string): string {
const baseUrl = this.iotHubApiService.baseUrl;
return markdown.replace(/(\(|")(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`);
return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`);
}
}

10
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-search-title-row">
<div class="flex items-baseline gap-4 lt-md:-mb-6">
<ng-content></ng-content>
@if (!isLoading && totalElements > 0) {
<span class="tb-search-results-count tb-search-results-count-mobile">
@ -24,7 +24,7 @@
}
</div>
<!-- Search + Sort row -->
<div class="tb-search-toolbar">
<div class="flex items-center gap-4">
<div class="tb-search-input-field">
<mat-icon class="tb-search-input-icon">search</mat-icon>
<input class="tb-search-input"
@ -59,7 +59,7 @@
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }}
</span>
<div class="tb-search-pagination">
<div class="tb-search-pagination-pages">
<div class="flex items-center gap-px">
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)">
<mat-icon>chevron_left</mat-icon>
</button>
@ -95,14 +95,14 @@
<!-- Loading -->
@if (isLoading) {
<div class="tb-search-loading">
<div class="tb-search-loading flex justify-center py-12">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
<!-- No results -->
@if (!isLoading && totalElements === 0 && searchText?.trim()) {
<div class="tb-search-empty">
<div class="tb-search-empty flex flex-col items-center py-10">
<div class="tb-no-data-bg"></div>
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3>
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p>

116
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss

@ -15,6 +15,7 @@
*/
@import "../../../../../scss/constants";
@import "./iot-hub-common";
:host {
display: flex;
@ -22,76 +23,27 @@
gap: 40px;
}
// Title row
.tb-search-title-row {
display: flex;
align-items: baseline;
gap: 16px;
}
.tb-search-results-count-mobile {
display: none;
margin-left: auto;
}
// Search + Sort toolbar
.tb-search-toolbar {
display: flex;
align-items: center;
gap: 16px;
}
.tb-search-input-field {
@include tb-iot-hub-search-container;
width: 320px;
min-width: 0;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
}
.tb-search-input-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.38);
flex-shrink: 0;
@include tb-iot-hub-search-icon;
}
.tb-search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
font-family: inherit;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
@include tb-iot-hub-search-input-text;
}
.tb-search-input-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@include tb-iot-hub-search-clear;
}
.tb-search-sort-btn {
@ -128,12 +80,6 @@
gap: 24px;
}
.tb-search-pagination-pages {
display: flex;
align-items: center;
gap: 1px;
}
.tb-search-page-btn {
min-width: 40px;
height: 40px;
@ -181,44 +127,36 @@
}
}
// Loading
.tb-search-loading {
display: flex;
justify-content: center;
padding: 48px 0;
// Loading / Empty negative margin
.tb-search-loading,
.tb-search-empty {
margin-top: -20px;
}
// Empty state
// Empty state no-data-bg + text
.tb-search-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
margin-top: -20px;
.tb-no-data-bg {
flex: auto;
height: 100px;
}
}
.tb-search-empty h3 {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.76);
margin: 0 0 8px;
}
h3 {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.76);
margin: 0 0 8px;
}
.tb-search-empty p {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.54);
margin: 0;
p {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.54);
margin: 0;
}
}
// Divider before categories
@ -324,10 +262,6 @@
}
@media #{$mat-lt-md} {
.tb-search-title-row {
margin-bottom: -24px;
}
.tb-search-results-count-mobile {
display: inline;
}

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

@ -194,7 +194,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
navigateToType(type: ItemType): void {
const search = this.searchText?.trim() || undefined;
this.router.navigate(['/iot-hub', this.getTypeRoute(type)], { queryParams: { search } });
void this.router.navigate(['/iot-hub', this.getTypeRoute(type)], { queryParams: { search } });
}
// Installed items
@ -244,7 +244,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
updateItem(item: MpItemVersionView): void {
const installedItem = this.getInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.updateItem(item, installedItem).subscribe(result => {
this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => {
if (result === 'updated') {
this.reloadInstalledItems();
}
@ -254,13 +254,13 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy {
deleteInstalledItem(item: MpItemVersionView): void {
const installedItem = this.getInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.deleteItem(item, installedItem).subscribe(() => {
this.iotHubActions.deleteItem(installedItem).subscribe(() => {
this.reloadInstalledItems();
});
}
navigateToCreator(creatorId: string): void {
this.router.navigate(['/iot-hub/creator', creatorId]);
void this.router.navigate(['/iot-hub/creator', creatorId]);
}
// Data loading

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-iot-hub-install-content">
<div class="flex flex-col gap-5 p-6">
@switch (state) {
@case ('confirm') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.update-confirm-title' | translate:{ name: data.itemName } }}</h2>
@ -45,7 +45,7 @@
@case ('updating') {
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" disabled>
<mat-spinner diameter="18" class="tb-iot-hub-inline-spinner"></mat-spinner>
<mat-spinner diameter="18" class="mr-2 inline-block align-middle"></mat-spinner>
{{ 'iot-hub.updating' | translate }}
</button>
}

58
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.scss

@ -1,58 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.tb-iot-hub-install-content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
}
.tb-iot-hub-install-title {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
}
.tb-iot-hub-install-desc {
color: rgba(0, 0, 0, 0.54);
font-size: 14px;
line-height: 20px;
letter-spacing: 0.2px;
margin: 0;
}
.tb-iot-hub-inline-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.tb-iot-hub-error-details {
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: rgba(0, 0, 0, 0.7);
white-space: pre-wrap;
word-break: break-word;
}

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

@ -21,10 +21,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DialogComponent } from '@shared/components/dialog.component';
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import {
IotHubInstalledItemDescriptor,
SolutionTemplateInstalledItemDescriptor
} from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
@ -45,7 +42,7 @@ export type UpdateState = 'confirm' | 'updating' | 'success' | 'error';
selector: 'tb-iot-hub-update-dialog',
standalone: false,
templateUrl: './iot-hub-update-dialog.component.html',
styleUrls: ['./iot-hub-update-dialog.component.scss']
styleUrls: ['./iot-hub-install-dialog.component.scss']
})
export class TbIotHubUpdateDialogComponent extends DialogComponent<TbIotHubUpdateDialogComponent> {
@ -116,7 +113,7 @@ export class TbIotHubUpdateDialogComponent extends DialogComponent<TbIotHubUpdat
openEntityDetails(): void {
if (this.entityDetailsUrl) {
this.dialogRef.close('updated');
this.router.navigateByUrl(this.entityDetailsUrl);
void this.router.navigateByUrl(this.entityDetailsUrl);
}
}

69
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts

@ -0,0 +1,69 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { FilterParamInfo } from '@shared/models/iot-hub/iot-hub-item.models';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
export const IOT_HUB_FILTER_GROUPING_THRESHOLD = 11;
export const IOT_HUB_FILTER_POPULAR_LIMIT = 10;
export interface IotHubFilterGroup {
label: string;
items: FilterParamInfo[];
}
export function filterIotHubItemsBySearch(items: FilterParamInfo[], search: string): FilterParamInfo[] {
const normalized = (search || '').toLowerCase();
if (!normalized) {
return items;
}
return items.filter(item => item.key.toLowerCase().includes(normalized));
}
export function groupIotHubFilterItems(items: FilterParamInfo[], search: string): IotHubFilterGroup[] {
const filtered = filterIotHubItemsBySearch(items, search);
if (items.length < IOT_HUB_FILTER_GROUPING_THRESHOLD) {
return [{ label: null, items: filtered }];
}
const topKeys = new Set(
[...items]
.sort((a, b) => b.totalInstallCount - a.totalInstallCount)
.slice(0, IOT_HUB_FILTER_POPULAR_LIMIT)
.map(i => i.key)
);
const popular = filtered.filter(i => topKeys.has(i.key));
const rest = filtered.filter(i => !topKeys.has(i.key));
const groups: IotHubFilterGroup[] = [];
if (popular.length) {
groups.push({ label: 'iot-hub.most-popular', items: popular });
}
if (rest.length) {
groups.push({ label: 'iot-hub.all', items: rest });
}
return groups;
}
export function resolveIotHubItemImageUrl(item: MpItemVersionView, api: IotHubApiService): string | null {
if (item.image) {
return api.resolveResourceUrl(item.image);
}
const resource = item.resources?.find(r => r.type === 'SCREENSHOT') || item.resources?.find(r => r.type === 'ICON');
if (resource) {
return api.resolveResourceUrl(`/api/resources/${resource.id}`);
}
return null;
}

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

@ -20,11 +20,11 @@
<!-- Left: creator info -->
<div class="tb-creator-info">
<!-- Back link -->
<button mat-button color="primary" class="tb-creator-back-btn" (click)="goBack()">
<button mat-button color="primary" class="tb-creator-back-btn self-start" (click)="goBack()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
<div class="tb-creator-details">
<div class="flex flex-col gap-4">
<div class="tb-creator-avatar">
@if (getAvatarUrl(); as avatarUrl) {
<img [src]="avatarUrl" alt="">
@ -32,7 +32,7 @@
<mat-icon>person</mat-icon>
}
</div>
<div class="tb-creator-name-row">
<div class="flex items-center gap-2">
<h2 class="tb-creator-name">{{ creator.displayName }}</h2>
@if (creator.verified) {
<mat-icon class="tb-creator-verified-badge"
@ -43,7 +43,7 @@
@if (creator.description) {
<p class="tb-creator-desc">{{ creator.description }}</p>
}
<div class="tb-creator-links">
<div class="flex flex-col gap-1">
@if (creator.website) {
<a class="tb-creator-link tb-creator-link-primary" [href]="creator.website" target="_blank">
<mat-icon>language</mat-icon>

25
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss

@ -31,11 +31,6 @@
height: 100%;
}
// Back button
.tb-creator-back-btn {
align-self: flex-start;
}
// Left column: creator info
.tb-creator-info {
width: 360px;
@ -46,12 +41,6 @@
padding: 40px 24px 0 0;
}
.tb-creator-details {
display: flex;
flex-direction: column;
gap: 16px;
}
// Avatar
.tb-creator-avatar {
width: 140px;
@ -78,13 +67,6 @@
}
}
// Name
.tb-creator-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.tb-creator-name {
font-size: 28px;
font-weight: 500;
@ -114,13 +96,6 @@
max-width: 800px;
}
// Links
.tb-creator-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.tb-creator-link {
display: flex;
align-items: center;

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts

@ -61,13 +61,13 @@ export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy {
}
goBack(): void {
this.router.navigate(['/iot-hub']);
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'])
error: () => void this.router.navigate(['/iot-hub'])
});
}

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

@ -50,18 +50,19 @@
<div class="tb-iot-hub-hero-subtitle">
<span class="tb-iot-hub-hero-prefix">{{ 'iot-hub.home-subtitle-prefix' | translate }}</span>
@for (ht of heroTypes; track ht.type; let i = $index; let last = $last) {
<span class="tb-iot-hub-hero-keyword"
<span class="tb-iot-hub-hero-keyword" role="button" tabindex="0"
[class.active]="activeHeroType === ht"
[style.--keyword-color]="ht.color"
(mouseenter)="onHeroTypeHover(ht)"
(mouseleave)="onHeroTypeLeave()"
(click)="navigateToBrowse(ht.type)">{{ ht.labelKey | translate }}@if (!last) {,}</span>
(click)="navigateToBrowse(ht.type)"
(keyup.enter)="navigateToBrowse(ht.type)">{{ ht.labelKey | translate }}@if (!last) {,}</span>
@if (i === 2) {
<span class="tb-iot-hub-hero-subtitle-break"></span>
}
}
</div>
<mat-form-field appearance="outline" class="tb-iot-hub-hero-search" subscriptSizing="dynamic" #searchField>
<mat-form-field appearance="outline" class="tb-iot-hub-hero-search" subscriptSizing="dynamic">
<input matInput #searchInput
[placeholder]="'iot-hub.search-placeholder' | translate"
[(ngModel)]="searchText"
@ -84,11 +85,11 @@
(optionSelected)="onSearchOptionSelected($event)"
[hideSingleSelectionIndicator]="true">
@if (searchLoading) {
<mat-option disabled class="tb-search-popup-loading">
<mat-option [disabled]="true" class="tb-search-popup-loading">
<mat-spinner diameter="24"></mat-spinner>
</mat-option>
} @else if (searchResults.length === 0 && searchLoaded) {
<mat-option disabled class="tb-search-popup-no-results">
<mat-option [disabled]="true" class="tb-search-popup-no-results">
<div class="tb-search-popup-no-results-content">
<mat-icon>search_off</mat-icon>
@if (searchText?.trim()) {
@ -128,7 +129,8 @@
</mat-optgroup>
}
}
<div class="tb-search-popup-footer" (click)="seeAllResults()">
<div class="tb-search-popup-footer" role="button" tabindex="0"
(click)="seeAllResults()" (keyup.enter)="seeAllResults()">
{{ 'iot-hub.see-all-results' | translate }}
</div>
</mat-autocomplete>
@ -136,11 +138,14 @@
</div>
<!-- Main content -->
<div class="tb-iot-hub-main-content">
<div class="flex flex-col gap-12 px-10 pb-16">
<!-- Category cards -->
<div class="tb-iot-hub-categories">
@for (card of categoryCards; track card.type) {
<div class="tb-iot-hub-category-card" [ngClass]="card.cssClass" (click)="navigateToBrowse(card.type)">
<div class="tb-iot-hub-category-card" role="button" tabindex="0"
[ngClass]="card.cssClass"
(click)="navigateToBrowse(card.type)"
(keyup.enter)="navigateToBrowse(card.type)">
<span class="tb-iot-hub-category-title">{{ card.titleKey | translate }}</span>
<img class="tb-iot-hub-category-img" [src]="card.image" alt="">
</div>
@ -289,7 +294,7 @@
}
@if (isLoading) {
<div class="tb-iot-hub-loading">
<div class="flex justify-center py-20">
<mat-spinner diameter="40"></mat-spinner>
</div>
}

14
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss

@ -434,13 +434,6 @@
}
// Main content — wraps categories + item sections
.tb-iot-hub-main-content {
display: flex;
flex-direction: column;
gap: 48px;
padding: 0 40px 64px;
}
// Category cards — Design: centered 1200px container, 3-col rows
.tb-iot-hub-categories {
display: grid;
@ -576,13 +569,6 @@
}
}
// Loading
.tb-iot-hub-loading {
display: flex;
justify-content: center;
padding: 80px 0;
}
// Page load animation — Design: Default→appear-first, Smart Animate 1.02s, Gentle easing
// Hero elements fade in + slide DOWN 20px; categories fade in + slide UP 20px
@keyframes iot-hub-fade-slide-down {

22
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts

@ -30,6 +30,7 @@ import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-it
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { TranslateService } from '@ngx-translate/core';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
interface CategoryCard {
type: ItemType;
@ -255,7 +256,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
seeAllResults(): void {
this.searchAutoTrigger?.closePanel();
const search = this.searchText?.trim() || undefined;
this.router.navigate(['/iot-hub/search'], { queryParams: { search } });
void this.router.navigate(['/iot-hub/search'], { queryParams: { search } });
}
isCompactType(type: ItemType): boolean {
@ -267,14 +268,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
getItemImage(item: MpItemVersionView): string | null {
if (item.image) {
return this.iotHubApiService.resolveResourceUrl(item.image);
}
const resource = item.resources?.find(r => r.type === 'SCREENSHOT') || item.resources?.find(r => r.type === 'ICON');
if (resource) {
return this.iotHubApiService.resolveResourceUrl(`/api/resources/${resource.id}`);
}
return null;
return resolveIotHubItemImageUrl(item, this.iotHubApiService);
}
getItemTypeIcon(type: ItemType): string {
@ -293,7 +287,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
navigateToBrowse(type: ItemType): void {
this.router.navigate(['/iot-hub', this.getTypeRoute(type)]);
void this.router.navigate(['/iot-hub', this.getTypeRoute(type)]);
}
private getTypeRoute(type: ItemType): string {
@ -309,7 +303,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
navigateToInstalledItems(): void {
this.router.navigate(['/iot-hub/installed']);
void this.router.navigate(['/iot-hub/installed']);
}
openItemDetail(item: MpItemVersionView): void {
@ -390,7 +384,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
updateItem(item: MpItemVersionView): void {
const installedItem = this.findInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.updateItem(item, installedItem).subscribe(result => {
this.iotHubActions.updateItem(installedItem, item.version, item.id as string).subscribe(result => {
if (result === 'updated') {
this.reloadInstalledItems(item.type);
}
@ -398,7 +392,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
}
navigateToCreator(creatorId: string): void {
this.router.navigate(['/iot-hub/creator', creatorId]);
void this.router.navigate(['/iot-hub/creator', creatorId]);
}
getInstalledWidget(item: MpItemVersionView): IotHubInstalledItem | undefined {
@ -412,7 +406,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy {
deleteInstalledItem(item: MpItemVersionView): void {
const installedItem = this.findInstalledItem(item);
if (!installedItem) { return; }
this.iotHubActions.deleteItem(item, installedItem).subscribe(() => {
this.iotHubActions.deleteItem(installedItem).subscribe(() => {
this.installedItemsCount = Math.max(0, this.installedItemsCount - 1);
if (item.type === ItemType.WIDGET) {
this.installedWidgets = this.installedWidgets.filter(i => i.id.id !== installedItem.id.id);

11
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.html

@ -15,9 +15,9 @@
limitations under the License.
-->
<div class="tb-installed-items-page">
<div class="tb-installed-items-page flex h-full flex-col">
<!-- Back link -->
<button mat-button color="primary" class="tb-installed-back-btn" (click)="navigateToMarketplace()">
<button mat-button color="primary" class="tb-installed-back-btn self-start" (click)="navigateToMarketplace()">
<mat-icon>chevron_left</mat-icon>
{{ 'iot-hub.back-to-iot-hub' | translate }}
</button>
@ -45,7 +45,10 @@
</button>
<mat-menu #filterMenu="matMenu" class="tb-installed-filter-menu" xPosition="after">
@for (type of allItemTypes; track type; let last = $last) {
<div mat-menu-item class="tb-installed-filter-item" (click)="$event.stopPropagation(); toggleTypeFilter(type)">
<div mat-menu-item class="tb-installed-filter-item" role="menuitemcheckbox" tabindex="0"
[attr.aria-checked]="isTypeFilterActive(type)"
(click)="$event.stopPropagation(); toggleTypeFilter(type)"
(keyup.enter)="toggleTypeFilter(type)">
<mat-checkbox [checked]="isTypeFilterActive(type)">
{{ getItemTypeLabel(type) }}
</mat-checkbox>
@ -68,7 +71,7 @@
{{ 'iot-hub.clear-all' | translate }}
</button>
}
<span class="tb-installed-toolbar-spacer"></span>
<span class="flex-1"></span>
<div class="tb-installed-search">
<mat-icon class="tb-installed-search-icon">search</mat-icon>
<input class="tb-installed-search-input"

12
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.scss

@ -26,15 +26,11 @@
background: white;
border-radius: 8px;
padding: 40px 40px 40px;
height: 100%;
display: flex;
flex-direction: column;
}
// Back button
.tb-installed-back-btn {
margin-bottom: 40px;
align-self: flex-start;
}
// Header: title + check for updates
@ -61,10 +57,6 @@
margin-bottom: 16px;
}
.tb-installed-toolbar-spacer {
flex: 1;
}
.tb-installed-filter-btn {
&.mat-primary {
--mat-button-outlined-outline-color: #{$tb-primary-color};
@ -154,10 +146,6 @@
flex-wrap: wrap;
}
.tb-installed-toolbar-spacer {
order: 0;
}
.tb-installed-search {
order: 1;
}

7
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-installed-items.component.ts

@ -34,7 +34,6 @@ export class TbIotHubInstalledItemsComponent implements OnInit, OnDestroy {
appliedTextSearch = '';
typeFilters: string[] = [];
filterOpen = false;
activeTypeFilters = new Set<string>();
allItemTypes: string[] = ['WIDGET', 'DASHBOARD', 'SOLUTION_TEMPLATE', 'CALCULATED_FIELD', 'RULE_CHAIN', 'DEVICE'];
@ -69,7 +68,7 @@ export class TbIotHubInstalledItemsComponent implements OnInit, OnDestroy {
}
navigateToMarketplace(): void {
this.router.navigate(['/iot-hub']);
void this.router.navigate(['/iot-hub']);
}
onSearchChange(value: string): void {
@ -77,10 +76,6 @@ export class TbIotHubInstalledItemsComponent implements OnInit, OnDestroy {
this.searchSubject.next(value);
}
toggleFilter(): void {
this.filterOpen = !this.filterOpen;
}
isTypeFilterActive(type: string): boolean {
return this.activeTypeFilters.has(type);
}

6
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.html

@ -17,8 +17,8 @@
-->
<div class="tb-iot-hub-items-page">
<!-- Hero section -->
<div class="tb-iot-hub-items-hero">
<div class="tb-iot-hub-items-hero-text">
<div class="tb-iot-hub-items-hero mb-10 flex justify-between gap-10">
<div class="min-w-0 flex-1">
<!-- Back link -->
<button mat-button color="primary" class="tb-iot-hub-back-btn" (click)="goBack()">
<mat-icon>chevron_left</mat-icon>
@ -26,7 +26,7 @@
</button>
<h1 class="tb-iot-hub-items-title">{{ config.titleKey | translate }}</h1>
<p class="tb-iot-hub-items-desc">{{ config.descriptionKey | translate }}</p>
<div class="tb-iot-hub-items-actions">
<div class="flex items-center gap-4">
@if (installedItemsCount > 0) {
<button mat-stroked-button color="primary" class="tb-iot-hub-items-installed-btn" (click)="navigateToInstalledItems()">
<mat-icon>inventory_2</mat-icon>

13
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.scss

@ -28,19 +28,6 @@
overflow: hidden;
}
// Hero section — flex row: left (back + text) and right (image), space-between
.tb-iot-hub-items-hero {
display: flex;
justify-content: space-between;
gap: 40px;
margin-bottom: 40px;
}
.tb-iot-hub-items-hero-text {
flex: 1;
min-width: 0;
}
.tb-iot-hub-back-btn {
margin-bottom: 40px;
}

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts

@ -96,11 +96,11 @@ export class TbIotHubItemsPageComponent implements OnInit {
}
goBack(): void {
this.router.navigate(['/iot-hub']);
void this.router.navigate(['/iot-hub']);
}
navigateToInstalledItems(): void {
this.router.navigate(['/iot-hub/installed'], { queryParams: { itemType: this.config.type } });
void this.router.navigate(['/iot-hub/installed'], { queryParams: { itemType: this.config.type } });
}
openSignup(): void {

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="tb-search-page">
<div class="tb-search-page flex h-full flex-col gap-10 rounded-lg bg-white lt-sm:gap-4 lt-lg:gap-6">
<!-- Back -->
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()">
<mat-icon>chevron_left</mat-icon>

12
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.scss

@ -22,14 +22,6 @@
height: 100%;
}
.tb-search-page {
background: white;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 40px;
height: 100%;
}
// Back button
.tb-search-back-btn {
@ -97,10 +89,6 @@ tb-iot-hub-search {
}
@media #{$mat-lt-lg} {
.tb-search-page {
gap: 24px;
}
.tb-search-back-btn {
margin-top: 24px;
margin-left: 24px;

2
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-search-page.component.ts

@ -40,6 +40,6 @@ export class TbIotHubSearchPageComponent implements OnInit {
}
navigateBack(): void {
this.router.navigate(['/iot-hub']);
void this.router.navigate(['/iot-hub']);
}
}

18
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3683,7 +3683,6 @@
}
},
"item": {
"selected": "Selected",
"type-widget": "Widget",
"type-widget-plural": "Widgets",
"type-dashboard": "Dashboard",
@ -3742,7 +3741,6 @@
"popular-devices": "Popular Devices",
"become-creator-text": "Submit your templates to the ThingsBoard IoT Hub to get featured and showcase your solutions to our global community.",
"contribute": "Contribute",
"browse": "Browse IoT Hub",
"creator-profile": "Creator Profile",
"title-widgets": "Widgets",
"title-dashboards": "Dashboards",
@ -3770,7 +3768,6 @@
"category": "Category",
"use-case": "Use case",
"vendor": "Vendor",
"vendor-placeholder": "Filter by vendor...",
"view-setup-guide": "View setup guide",
"hardware-type": "Hardware Type",
"connectivity": "Connectivity",
@ -3799,26 +3796,20 @@
"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-confirm-title": "Install '{{name}}'?",
"install-desc": "Installing this will automatically create and configure the necessary resources within your tenant",
"install-success-desc": "'{{name}}' has been installed successfully",
"open-details": "Open details",
"remove": "Remove",
"remove-confirm-title": "Remove '{{name}}'?",
"remove-desc": "Removing this will also remove the installed entity and its associated resources within your tenant",
"remove-desc-solution-template": "Removing this solution template will also remove all created entities including dashboards, rule chains, devices, and other associated resources within your tenant",
"remove-anyway": "Remove anyway",
"select-entity-for-cf": "Select entity for calculated field '{{name}}'",
"install-type": "Type: {{type}}",
"install-creator": "Creator: {{creator}}",
"install": "Install",
"connect": "Connect",
"connect-device": "Connect device",
"install-one-more": "Install one more",
"installing": "Installing...",
"installed": "Installed",
"install-success": "{{name}} installed successfully",
@ -3857,7 +3848,6 @@
"item-type": "Type",
"item-version": "Version",
"installed-date": "Installed",
"actions": "Actions",
"no-installed-items": "No installed items found",
"remove-installed-item-title": "Remove Installed Item",
"remove-installed-item-text": "Are you sure you want to remove '{{name}}'? The installed entity will also be removed.",
@ -3868,9 +3858,6 @@
"review-device-instructions": "Review device instructions",
"open-item-type": "Open {{type}}",
"back-to-marketplace": "Back to Marketplace",
"device-install-connection": "Connection: {{type}}",
"device-install-entity-error": "Failed to create {{name}}: {{error}}",
"device-install-expand-image": "Click to expand",
"device-install-select-connectivity": "Select your connection type:",
"device-install-step-error": "Failed",
"device-install-step-pending": "Pending",
@ -3890,13 +3877,10 @@
"device-install-resolution-use-existing": "Used existing",
"device-install-resolution-overwrite": "Overwritten",
"device-install-resolution-create-copy": "Created copy",
"device-install-gateway-docker-compose": "Download docker-compose.yml for your gateway",
"device-install-title": "Connect \"{{name}}\"",
"device-install-complete-title": "Install \"{{name}}\" — Complete",
"device-installing-title": "Installing \"{{name}}\"",
"done": "Done",
"in-progress": "In progress",
"error": "Error"
"in-progress": "In progress"
},
"item-data": {
"widget-type-timeseries": "Timeseries",

Loading…
Cancel
Save