Browse Source

Merge pull request #16030 from thingsboard/rc

Merge rc into master
pull/16014/merge
Vladyslav Prykhodko 2 weeks ago
committed by GitHub
parent
commit
45c30e83fa
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      ui-ngx/src/app/core/http/widget.service.ts
  2. 13
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  3. 41
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  4. 92
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
  5. 15
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts
  6. 146
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-built-in.service.ts
  7. 2
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
  8. 11
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  9. 32
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.html
  10. 13
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts
  11. 28
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  12. 32
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  13. 52
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-utils.ts
  14. 20
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html
  15. 7
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts
  16. 8
      ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts
  17. 7
      ui-ngx/src/assets/locale/locale.constant-en_US.json

2
ui-ngx/src/app/core/http/widget.service.ts

@ -188,7 +188,7 @@ export class WidgetService {
}
public getWidgetType(fullFqn: string, config?: RequestConfig): Observable<WidgetType> {
return this.http.get<WidgetType>(`/api/widgetType?fqn=${fullFqn}`,
return this.http.get<WidgetType>(`/api/widgetType?fqn=${encodeURIComponent(fullFqn)}`,
defaultHttpOptionsFromConfig(config));
}

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

@ -370,7 +370,8 @@
</ng-template>
<ng-template #iotHubWidgetCardTpl let-item let-showInstalledBadge="showInstalledBadge">
<mat-card class="tb-widget-preview-card tb-iot-hub-widget-card flex flex-col gap-2" appearance="raised" (click)="onIotHubWidgetClicked($event, item)">
<mat-card class="tb-widget-preview-card tb-iot-hub-widget-card flex flex-col gap-2" appearance="raised"
(click)="onIotHubWidgetClicked($event, item)">
<div class="title-container">
<div title="{{item.name}}" class="widget-title">
{{item.name}}
@ -405,10 +406,12 @@
{{ 'iot-hub.installed' | translate }}
</span>
}
<span class="tb-iot-hub-widget-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
@if (!isIotHubWidgetBuiltIn(item)) {
<span class="tb-iot-hub-widget-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
}
</div>
</mat-card>
</ng-template>

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

@ -39,7 +39,14 @@ import { ItemType, FilterParamInfo, WidgetCategory } from '@shared/models/iot-hu
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { filterIotHubItemsBySearch, groupIotHubFilterItems, IotHubFilterGroup, resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
import {
filterIotHubItemsBySearch,
groupIotHubFilterItems,
IotHubFilterGroup,
isBuiltInItem,
resolveIotHubItemImageUrl
} from '@home/components/iot-hub/iot-hub-utils';
import { IotHubBuiltInService } from '@home/components/iot-hub/iot-hub-built-in.service';
type selectWidgetMode = 'installed' | 'iotHub';
type installedSubMode = 'default' | 'allWidgets';
@ -278,6 +285,7 @@ export class DashboardWidgetSelectComponent {
constructor(private widgetsService: WidgetService,
private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService,
private iotHubBuiltInService: IotHubBuiltInService,
private translate: TranslateService,
private cd: ChangeDetectorRef) {
@ -494,6 +502,13 @@ export class DashboardWidgetSelectComponent {
}
return;
}
if (isBuiltInItem(item)) {
// Built-in widgets ship with the platform: pick the local widget type matched by fqn
// instead of installing a duplicate, exactly like an already installed item.
this.selectBuiltInWidget(item);
return;
}
// Built-in items never reach the dialog (they return above), so anything added from it installs.
this.iotHubActions.openItemDetail(item, undefined, undefined, 'add').subscribe(result => {
if (result?.action === 'add') {
this.installAndAddWidget(result.item);
@ -505,6 +520,10 @@ export class DashboardWidgetSelectComponent {
return this.installedWidgets?.some(i => i.itemId === item.itemId) ?? false;
}
isIotHubWidgetBuiltIn(item: MpItemVersionView): boolean {
return isBuiltInItem(item);
}
getIotHubItemImage(item: MpItemVersionView): string | null {
return resolveIotHubItemImageUrl(item, this.iotHubApiService);
}
@ -737,6 +756,26 @@ export class DashboardWidgetSelectComponent {
this.cd.markForCheck();
}
private selectBuiltInWidget(item: MpItemVersionView): void {
this.iotHubBuiltInService.resolveLocalWidgetTypeInfo(item).subscribe(lookup => {
if (lookup.value) {
this.widgetSelected.emit(this.toWidgetInfo(lookup.value));
return;
}
if (lookup.failed) {
// Existence is unknown — installing now could duplicate a widget that is still there.
this.iotHubActions.showBuiltInLookupFailed(item);
return;
}
// The local copy is gone (e.g. deleted by a sysadmin) — installing is the only way back.
this.iotHubActions.confirmInstallMissingBuiltIn(item).subscribe(confirmed => {
if (confirmed) {
this.installAndAddWidget(item);
}
});
});
}
private installAndAddWidget(item: MpItemVersionView): void {
const versionId = item.id as string;
this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({

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

@ -16,7 +16,10 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable, of } from 'rxjs';
import { Observable, of, EMPTY } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from '@core/services/dialog.service';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { DeviceInstalledItemDescriptor, IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
@ -28,12 +31,24 @@ import { TbIotHubUpdateDialogComponent, IotHubUpdateDialogData } from './iot-hub
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';
import { IotHubBuiltInService } from './iot-hub-built-in.service';
import { isBuiltInItem } from './iot-hub-utils';
/**
* What `openBuiltInOrConfirmInstall` did with a built-in item: 'handled' means nothing is left to do
* (the local copy was opened, or the user was told why it could not be), while 'install-requested'
* means the component really is absent and the user asked for it to be installed.
*/
export type IotHubBuiltInAction = 'handled' | 'install-requested';
@Injectable()
export class IotHubActionsService {
constructor(
private dialog: MatDialog
private dialog: MatDialog,
private dialogService: DialogService,
private translate: TranslateService,
private builtInService: IotHubBuiltInService
) {}
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number,
@ -68,15 +83,82 @@ export class IotHubActionsService {
}).afterClosed();
}
/**
* Runs the primary action of an item: built-in content is opened locally (never installed),
* device packages are connected, everything else goes through the install dialog.
*/
installItem(item: MpItemVersionView): Observable<string> {
if (isBuiltInItem(item)) {
return this.openOrInstallBuiltIn(item);
}
return this.runInstall(item);
}
/** Opens the local copy of a built-in item, or asks to install it when it really is absent. */
openBuiltInOrConfirmInstall(item: MpItemVersionView): Observable<IotHubBuiltInAction> {
return this.builtInService.openLocalComponent(item).pipe(
switchMap(outcome => {
switch (outcome) {
case 'missing':
return this.confirmInstallMissingBuiltIn(item).pipe(
map((confirmed): IotHubBuiltInAction => confirmed ? 'install-requested' : 'handled')
);
case 'failed':
this.showBuiltInLookupFailed(item);
return of<IotHubBuiltInAction>('handled');
default:
// 'opened' or 'cancelled' — the component is in place either way, so never install.
return of<IotHubBuiltInAction>('handled');
}
})
);
}
/**
* Asks whether to install a built-in item whose local copy is gone the component the Hub entry
* mirrors was deleted from this instance, so installing is the only way to get it back.
*/
confirmInstallMissingBuiltIn(item: MpItemVersionView): Observable<boolean> {
return this.dialogService.confirm(
this.translate.instant('iot-hub.built-in-missing-title'),
this.translate.instant('iot-hub.built-in-missing-text', { name: item?.name }),
this.translate.instant('action.cancel'),
this.translate.instant('iot-hub.install')
);
}
/** Reports that the local copy could not be checked — nothing was opened and nothing installed. */
showBuiltInLookupFailed(item: MpItemVersionView): void {
this.dialogService.alert(
this.translate.instant('iot-hub.built-in-lookup-failed-title'),
this.translate.instant('iot-hub.built-in-lookup-failed-text', { name: item?.name })
);
}
private openOrInstallBuiltIn(item: MpItemVersionView): Observable<string> {
return this.openBuiltInOrConfirmInstall(item).pipe(
// The confirmation inside is the only one the user gets: install starts immediately,
// and opening the component is a separate click once it is back in the library.
switchMap(action => action === 'install-requested' ? this.runInstall(item, true) : EMPTY)
);
}
private runInstall(item: MpItemVersionView, skipConfirm = false): Observable<string> {
if (item.type === ItemType.ALARM_RULE) {
this.dialogService.alert(
this.translate.instant('iot-hub.alarm-rule-install-update-required'),
this.translate.instant('iot-hub.alarm-rule-install-update-required-text')
);
return EMPTY;
}
if (item.type === ItemType.DEVICE) {
return this.installDevice(item);
return this.openDeviceInstallDialog(item);
}
return this.dialog.open(TbIotHubInstallDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: { item } as IotHubInstallDialogData
data: { item, skipConfirm } as IotHubInstallDialogData
}).afterClosed();
}
@ -110,6 +192,8 @@ export class IotHubActionsService {
}).afterClosed();
}
// Reached only for items whose action mode is 'connect', i.e. never for built-in content:
// that decision is made once, at the public entry points above.
installDevice(item: MpItemVersionView): Observable<string> {
return this.openDeviceInstallDialog(item);
}

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

@ -27,6 +27,7 @@ import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { EntityId } from '@shared/models/id/entity-id';
import { IotHubActionsService } from './iot-hub-actions.service';
import { isBuiltInItem } from './iot-hub-utils';
import {
IotHubSelectCfEntityDialogData,
TbIotHubSelectCfEntityDialogComponent
@ -78,6 +79,20 @@ export class TbIotHubAddItemDialogComponent extends DialogComponent<TbIotHubAddI
}
onAddItem(item: MpItemVersionView): void {
if (isBuiltInItem(item)) {
// Built-in content is already part of the platform — open the local copy instead of
// installing a duplicate, and install only if that copy no longer exists.
this.iotHubActions.openBuiltInOrConfirmInstall(item).subscribe(action => {
if (action === 'install-requested') {
this.doAddItem(item);
}
});
return;
}
this.doAddItem(item);
}
private doAddItem(item: MpItemVersionView): void {
if (this.itemType === ItemType.DEVICE) {
this.installDeviceItem(item);
return;

146
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-built-in.service.ts

@ -0,0 +1,146 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { from, Observable, of } from 'rxjs';
import { catchError, concatMap, first, map, switchMap } from 'rxjs/operators';
import { WidgetService } from '@core/http/widget.service';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { isValidWidgetFullFqn, WidgetType, WidgetTypeInfo } from '@shared/models/widget.models';
import { EntityType } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL, isNotEmptyStr } from '@core/utils';
/**
* Result of looking up the local component a built-in Hub item mirrors. `failed` separates
* "the lookup itself did not answer" (network error, 5xx, no read permission) from "the component
* is not here": only the latter may be offered as an install, otherwise a transient error would
* hand the tenant a duplicate of a component that is still in place.
*/
export interface IotHubLocalLookup<T> {
value: T | null;
failed: boolean;
}
/**
* Outcome of trying to open the local component of a built-in item. 'cancelled' means the component
* is there but the router did not go typically a route guard blocked it (unsaved changes) so
* there is nothing to install and nothing to report.
*/
export type IotHubOpenLocalOutcome = 'opened' | 'cancelled' | 'missing' | 'failed';
/**
* Handles IoT Hub items marked `builtIn` content that already ships inside ThingsBoard.
* Such an item is never installed: its `fqn` is deliberately identical to the fqn of the
* platform's own component, which is used as the match key to reach the local copy.
*/
@Injectable({
providedIn: 'root'
})
export class IotHubBuiltInService {
constructor(
private router: Router,
private widgetService: WidgetService
) {}
/**
* Resolves the platform's own widget type mirroring a built-in Hub item, matched by fqn.
*
* There is no API to test a batch of fqns at once `GET /api/widgetType?fqn=` is per fqn and
* carries the full descriptor, and `/api/widgetTypeFqns` is scoped to a bundle the Hub payload
* does not name so this must stay a per-item check triggered by a click, never a prefetch
* across a catalogue page.
*/
resolveLocalWidgetType(item: MpItemVersionView): Observable<IotHubLocalLookup<WidgetType>> {
const noMatch: IotHubLocalLookup<WidgetType> = { value: null, failed: false };
const fqn = item?.dataDescriptor?.fqn;
if (!isNotEmptyStr(fqn)) {
// Without a match key nothing was asked of the server, so existence is unknown rather than
// disproven — reporting it as absent would offer an install for a component that is in place.
return of({ value: null, failed: true });
}
// Hub items carry a bare fqn; platform widget types are addressed by a scoped full fqn.
// Built-in content is system scoped; the tenant scope covers a copy installed into this tenant,
// which matters while the installed-items list a caller filters by is still stale.
const fullFqns = isValidWidgetFullFqn(fqn) ? [fqn] : [`system.${fqn}`, `tenant.${fqn}`];
return from(fullFqns).pipe(
concatMap(fullFqn => this.widgetService.getWidgetType(fullFqn, { ignoreErrors: true, ignoreLoading: true }).pipe(
map(widgetType => this.lookupResult(widgetType)),
catchError((err: HttpErrorResponse) => of(this.lookupFailure<WidgetType>(err)))
)),
// Stop at the first match, and at the first hard failure too: with existence unknown, the
// remaining candidate cannot turn a failure into a trustworthy "not here".
first(lookup => !!lookup.value?.id?.id || lookup.failed, noMatch)
);
}
/** Same match, resolved to the info projection widget pickers need (image, description, type). */
resolveLocalWidgetTypeInfo(item: MpItemVersionView): Observable<IotHubLocalLookup<WidgetTypeInfo>> {
return this.resolveLocalWidgetType(item).pipe(
switchMap(lookup => lookup.value
? this.widgetService.getWidgetTypeInfoById(lookup.value.id.id, { ignoreErrors: true, ignoreLoading: true }).pipe(
map(widgetTypeInfo => this.lookupResult(widgetTypeInfo)),
catchError((err: HttpErrorResponse) => of(this.lookupFailure<WidgetTypeInfo>(err)))
)
: of({ value: null, failed: lookup.failed }))
);
}
/** Navigates to the local component a built-in item mirrors, reporting why it could not. */
openLocalComponent(item: MpItemVersionView): Observable<IotHubOpenLocalOutcome> {
return this.resolveLocalComponentUrl(item).pipe(
switchMap(lookup => {
if (!lookup.value) {
return of<IotHubOpenLocalOutcome>(lookup.failed ? 'failed' : 'missing');
}
// The navigation itself can still be refused — a ConfirmOnExitGuard on the page the user is
// leaving, a failing resolver — and reporting it as opened would leave them with the Hub
// dialog closed (it closes on NavigationStart) and nothing opened in its place.
return from(this.router.navigateByUrl(lookup.value)).pipe(
map((navigated): IotHubOpenLocalOutcome => navigated ? 'opened' : 'cancelled'),
catchError(() => of<IotHubOpenLocalOutcome>('cancelled'))
);
})
);
}
private resolveLocalComponentUrl(item: MpItemVersionView): Observable<IotHubLocalLookup<string>> {
// Only widgets (including SCADA symbol widgets) ship inside the platform today. Other item
// types have no local counterpart to match by fqn, so they count as missing.
if (item?.type !== ItemType.WIDGET) {
return of({ value: null, failed: false });
}
return this.resolveLocalWidgetType(item).pipe(
map(lookup => ({
value: lookup.value ? getEntityDetailsPageURL(lookup.value.id.id, EntityType.WIDGET_TYPE) || null : null,
failed: lookup.failed
}))
);
}
private lookupResult<T>(value: T | null): IotHubLocalLookup<T> {
return { value: value ?? null, failed: false };
}
// A 404 is an answer — the component is not here. Anything else (network error, 5xx, 403) leaves
// existence unknown, and must never be presented to the user as deleted content.
private lookupFailure<T>(err: HttpErrorResponse): IotHubLocalLookup<T> {
return { value: null, failed: err?.status !== 404 };
}
}

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

@ -117,7 +117,7 @@
</ul>
}
@case ('installing') {
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}</h2>
<h2 class="tb-iot-hub-install-title">{{ 'iot-hub.installing-title' | translate:{ name: item.name } }}</h2>
<p class="tb-iot-hub-install-desc">{{ 'iot-hub.install-desc' | translate }}</p>
}
@case ('success') {

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

@ -60,6 +60,13 @@ interface PendingOverwrite {
export interface IotHubInstallDialogData {
item: MpItemVersionView;
/**
* Starts installing right away instead of opening on the confirm step. Set by callers that
* already asked the user e.g. a built-in item whose local copy was deleted so the user is
* not asked to confirm the same install twice. Ignored when the item needs input first
* (entity selection, rule chain options).
*/
skipConfirm?: boolean;
}
export type InstallState =
@ -146,6 +153,10 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
if (this.item.type === ItemType.RULE_CHAIN) {
this.initRuleChainForm();
}
if (data.skipConfirm && this.state === 'confirm') {
this.state = 'installing';
this.install();
}
}
private computeInitialState(): InstallState {

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

@ -37,17 +37,21 @@
[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>
@if (showInstallCount) {
<span class="tb-iot-hub-compact-dot">&middot;</span>
}
}
@if (showInstallCount) {
<tb-icon class="tb-iot-hub-compact-download-icon">download</tb-icon>
<span class="tb-iot-hub-compact-count">{{ item.totalInstallCount | shortNumber }}</span>
}
<tb-icon class="tb-iot-hub-compact-download-icon">download</tb-icon>
<span class="tb-iot-hub-compact-count">{{ item.totalInstallCount | shortNumber }}</span>
</div>
</div>
@if (mode === 'add') {
@if (isInstalled()) {
<span class="tb-iot-hub-card-installed-chip"><tb-icon>check</tb-icon>{{ 'iot-hub.installed' | translate }}</span>
} @else {
<button class="tb-iot-hub-card-install-btn" (click)="onAddClick($event)">{{ 'action.add' | translate }}</button>
<button class="tb-iot-hub-card-install-btn" (click)="onAddClick($event)">{{ actionLabel | translate }}</button>
}
} @else if (isInstalled()) {
@if (isSameVersion()) {
@ -74,7 +78,7 @@
</button>
}
} @else {
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ (item.type === ItemType.DEVICE ? 'iot-hub.connect' : 'iot-hub.install') | translate }}</button>
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ actionLabel | translate }}</button>
}
</div>
} @else {
@ -105,18 +109,22 @@
[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>
@if (showInstallCount) {
<span class="tb-iot-hub-card-dot">&middot;</span>
}
}
@if (showInstallCount) {
<span class="tb-iot-hub-card-stat">
<tb-icon>download</tb-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
}
<span class="tb-iot-hub-card-stat">
<tb-icon>download</tb-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
<span class="tb-iot-hub-card-stats">
@if (mode === 'add') {
@if (isInstalled()) {
<span class="tb-iot-hub-card-installed-chip"><tb-icon>check</tb-icon>{{ 'iot-hub.installed' | translate }}</span>
} @else {
<button class="tb-iot-hub-card-install-btn" (click)="onAddClick($event)">{{ 'action.add' | translate }}</button>
<button class="tb-iot-hub-card-install-btn" (click)="onAddClick($event)">{{ actionLabel | translate }}</button>
}
} @else if (isInstalled()) {
@if (isSameVersion()) {
@ -143,7 +151,7 @@
</button>
}
} @else {
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ (item.type === ItemType.DEVICE ? 'iot-hub.connect' : 'iot-hub.install') | translate }}</button>
<button class="tb-iot-hub-card-install-btn" (click)="onInstallClick($event)">{{ actionLabel | translate }}</button>
}
</span>
</div>

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

@ -20,6 +20,7 @@ import { getItemTypeIcon, ItemType } from '@shared/models/iot-hub/iot-hub-item.m
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';
import { iotHubItemActionLabel, isBuiltInItem } from '@home/components/iot-hub/iot-hub-utils';
@Component({
selector: 'tb-iot-hub-item-card',
@ -29,8 +30,6 @@ import { IotHubApiService } from '@core/http/iot-hub-api.service';
})
export class TbIotHubItemCardComponent {
readonly ItemType = ItemType;
@Input() item: MpItemVersionView;
@Input() installedItem: IotHubInstalledItem;
@Input() installedItemsCount = 0;
@ -51,6 +50,16 @@ export class TbIotHubItemCardComponent {
private iotHubApiService: IotHubApiService
) {}
// Install counters mean nothing for content the tenant already has, so the counter and the
// separator that would dangle next to it are both derived from this one boolean.
get showInstallCount(): boolean {
return !isBuiltInItem(this.item);
}
get actionLabel(): string {
return iotHubItemActionLabel(this.item, this.mode === 'add' ? 'add' : 'card');
}
isCompactLayout(): boolean {
return this.item.type === ItemType.CALCULATED_FIELD
|| this.item.type === ItemType.ALARM_RULE

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

@ -30,15 +30,17 @@
<tb-icon class="dlg-subtitle-icon">{{ getTypeIcon() }}</tb-icon>
<span>{{ typeTranslations.get(item.type) | translate }}</span>
</span>
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group">
<tb-icon class="dlg-subtitle-icon">download</tb-icon>
<span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span>
</span>
@if (showInstallCount) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group">
<tb-icon class="dlg-subtitle-icon">download</tb-icon>
<span>{{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }}</span>
</span>
}
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group">
<mat-icon class="dlg-subtitle-icon">update</mat-icon>
<span>v {{ item.version }}</span>
<span>{{ versionLabel }}</span>
</span>
@if (preview) {
<span class="dlg-dot"></span>
@ -318,19 +320,13 @@
<button mat-button (click)="close()">{{ 'action.close' | translate }}</button>
@if (mode === 'add' && !isInstalled()) {
<button mat-flat-button color="primary" (click)="addItem()">
{{ 'action.add' | translate }}
{{ actionLabel | translate }}
</button>
}
@if (!isInstalled() && mode !== 'add') {
@if (item.type === ItemType.DEVICE) {
<button mat-flat-button color="primary" (click)="installDevice()">
{{ 'iot-hub.connect-device' | translate }}
</button>
} @else {
<button mat-flat-button color="primary" (click)="install()">
{{ 'iot-hub.install' | translate }}
</button>
}
<button mat-flat-button color="primary" (click)="runPrimaryAction()">
{{ actionLabel | translate }}
</button>
}
@if (isInstalled() && mode !== 'add') {
<button mat-flat-button color="primary" (click)="openEntityDetails()">

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

@ -28,6 +28,11 @@ import { TranslateService } from '@ngx-translate/core';
import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import {
iotHubItemActionLabel,
iotHubItemActionMode,
isBuiltInItem
} from '@home/components/iot-hub/iot-hub-utils';
export type IotHubItemDetailDialogMode = 'default' | 'add';
@ -59,6 +64,8 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
installedItemsCount = 0;
carouselImages: string[] = [];
carouselIndex = 0;
// Built-in marker rides on the version line rather than as a standalone badge.
versionLabel: string;
constructor(
protected store: Store<AppState>,
@ -77,10 +84,22 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
this.preview = data.preview === true;
this.installedItem = data.installedItem;
this.installedItemsCount = data.installedItemsCount || 0;
this.versionLabel = isBuiltInItem(this.item)
? `v ${this.item.version} | ${this.translate.instant('iot-hub.built-in')}`
: `v ${this.item.version}`;
this.buildCarouselImages();
this.loadReadme();
}
// The counter and the separator that would otherwise dangle next to it share this boolean.
get showInstallCount(): boolean {
return !isBuiltInItem(this.item);
}
get actionLabel(): string {
return iotHubItemActionLabel(this.item, this.mode === 'add' ? 'add' : 'detail');
}
isCompactLayout(): boolean {
return this.item.type === ItemType.CALCULATED_FIELD
|| this.item.type === ItemType.ALARM_RULE
@ -167,6 +186,19 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
&& this.installedItem.itemVersionId !== this.item.id;
}
/** Runs whatever the item's action mode calls for: open the local copy, connect, or install. */
runPrimaryAction(): void {
if (iotHubItemActionMode(this.item) === 'connect') {
this.installDevice();
} else {
this.install();
}
}
/**
* For a built-in item this opens the local copy (the dialog closes itself on navigation) and only
* installs if that copy no longer exists `IotHubActionsService.installItem` owns that decision.
*/
install(): void {
this.iotHubActions.installItem(this.item).subscribe(result => {
if (result === 'installed') {

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

@ -14,13 +14,63 @@
/// limitations under the License.
///
import { FilterParamInfo } from '@shared/models/iot-hub/iot-hub-item.models';
import { FilterParamInfo, ItemType } 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;
/**
* Primary action offered for an IoT Hub item:
* - `open` the item is built-in, the tenant already has it, so we navigate to the local copy;
* - `connect` device packages are connected rather than installed;
* - `install` everything else.
* Dependent copy (button labels, dialog CTA, tooltips) is keyed by this mode, never by the verb itself.
*/
export type IotHubItemActionMode = 'open' | 'connect' | 'install';
/**
* The `builtIn` flag is server-owned and not live on every Hub deployment yet, so an absent,
* null or non-boolean value must read as "not built-in" instead of leaking into the UI.
*/
export const isBuiltInItem = (item?: MpItemVersionView | null): boolean => item?.builtIn === true;
export const iotHubItemActionMode = (item?: MpItemVersionView | null): IotHubItemActionMode => {
if (isBuiltInItem(item)) {
return 'open';
}
return item?.type === ItemType.DEVICE ? 'connect' : 'install';
};
/** Surface a CTA is rendered on: a catalogue card, the item detail footer, or an add-item picker. */
export type IotHubItemActionContext = 'card' | 'detail' | 'add';
const iotHubActionLabels: Record<IotHubItemActionContext, Record<IotHubItemActionMode, string>> = {
card: {
open: 'iot-hub.open',
connect: 'iot-hub.connect',
install: 'iot-hub.install'
},
// The detail footer has room to spell the device wording out.
detail: {
open: 'iot-hub.open',
connect: 'iot-hub.connect-device',
install: 'iot-hub.install'
},
// Picking an item to add to an entity or a dashboard is "Add", whether or not it is a device.
add: {
open: 'iot-hub.open',
connect: 'action.add',
install: 'action.add'
}
};
/** Translation key of the primary CTA for an item on the given surface. */
export const iotHubItemActionLabel = (item: MpItemVersionView | null,
context: IotHubItemActionContext): string =>
iotHubActionLabels[context][iotHubItemActionMode(item)];
export interface IotHubFilterGroup {
label: string;
items: FilterParamInfo[];

20
ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.html

@ -39,10 +39,12 @@
</div>
<div class="tb-iot-hub-widget-card-label">
<span class="tb-iot-hub-widget-card-name" tbTruncateWithTooltip>{{ item.name }}</span>
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
@if (showInstallCount(item)) {
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
}
</div>
</div>
}
@ -63,10 +65,12 @@
</div>
<div class="tb-iot-hub-widget-card-label">
<span class="tb-iot-hub-widget-card-name" tbTruncateWithTooltip>{{ item.name }}</span>
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
@if (showInstallCount(item)) {
<span class="tb-iot-hub-widget-card-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
</span>
}
</div>
</div>
}

7
ui-ngx/src/app/modules/home/components/widget/lib/home-page/iot-hub-widget.component.ts

@ -22,7 +22,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { WidgetContext } from '@home/models/widget-component.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
import { isBuiltInItem, resolveIotHubItemImageUrl } from '@home/components/iot-hub/iot-hub-utils';
import { MpItemVersionQuery, 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';
@ -83,6 +83,11 @@ export class IotHubWidgetComponent extends PageComponent implements OnInit {
return item.type === ItemType.SOLUTION_TEMPLATE && !!this.findInstalledSolutionTemplate(item);
}
// Install counters track nothing actionable for content that already ships with the platform.
showInstallCount(item: MpItemVersionView): boolean {
return !isBuiltInItem(item);
}
private findInstalledSolutionTemplate(item: MpItemVersionView): IotHubInstalledItem | undefined {
return this.installedSolutionTemplates.find(i => i.itemId === item.itemId);
}

8
ui-ngx/src/app/shared/models/iot-hub/iot-hub-version.models.ts

@ -109,6 +109,14 @@ export interface MpItemVersionView {
creatorVerified: boolean;
installCount: number;
totalInstallCount: number;
/**
* Server-owned, read-only marker for content that already ships inside ThingsBoard
* (a bundled widget, a SCADA symbol) instead of being installed by the IoT Hub.
* Optional on purpose: older Hub deployments simply omit the field, so it must never
* be read directly use `isBuiltInItem()` from `@home/components/iot-hub/iot-hub-utils`.
* Never send it back to the server.
*/
builtIn?: boolean;
resources: MpItemVersionResource[];
relatedItems?: string[];
checksum?: string;

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

@ -4323,9 +4323,16 @@
"rule-chain-overwrite-body": "Profile <b>{{profile}}</b> currently uses <b>{{existing}}</b> as its default rule chain. Installing will replace it with this rule chain.",
"rule-chain-overwrite-replace": "Replace",
"install": "Install",
"open": "Open",
"built-in": "Built-in",
"built-in-lookup-failed-title": "Could not check this component",
"built-in-lookup-failed-text": "Failed to check whether '{{name}}' is already present in this instance. Nothing was installed — please try again.",
"built-in-missing-title": "Not available in this instance",
"built-in-missing-text": "'{{name}}' normally ships with ThingsBoard, but it is not present in this instance — it may have been deleted by the system administrator. You can install it from the IoT Hub instead.",
"connect": "Connect",
"connect-device": "Connect device",
"installing": "Installing...",
"installing-title": "Installing '{{name}}'",
"installed": "Installed",
"install-success": "{{name}} installed successfully",
"install-success-title": "Installation Successful",

Loading…
Cancel
Save