{{item.name}}
@@ -405,10 +406,12 @@
{{ 'iot-hub.installed' | translate }}
}
-
- file_download
- {{ item.totalInstallCount | shortNumber }}
-
+ @if (!isIotHubWidgetBuiltIn(item)) {
+
+ file_download
+ {{ item.totalInstallCount | shortNumber }}
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
index 3034cee011..46d4e06e91 100644
--- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
+++ b/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({
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
index 536c9da878..cd3d48d5d5 100644
--- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
+++ b/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
{
+ 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 {
+ 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('handled');
+ default:
+ // 'opened' or 'cancelled' — the component is in place either way, so never install.
+ return of('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 {
+ 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 {
+ 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 {
+ 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 {
return this.openDeviceInstallDialog(item);
}
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts
index 0111fa0abe..6971a62bc6 100644
--- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-add-item-dialog.component.ts
+++ b/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 {
+ 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;
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-built-in.service.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-built-in.service.ts
new file mode 100644
index 0000000000..054bec852f
--- /dev/null
+++ b/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 {
+ 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> {
+ const noMatch: IotHubLocalLookup = { 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(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> {
+ 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(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 {
+ return this.resolveLocalComponentUrl(item).pipe(
+ switchMap(lookup => {
+ if (!lookup.value) {
+ return of(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('cancelled'))
+ );
+ })
+ );
+ }
+
+ private resolveLocalComponentUrl(item: MpItemVersionView): Observable> {
+ // 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(value: T | null): IotHubLocalLookup {
+ 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(err: HttpErrorResponse): IotHubLocalLookup {
+ return { value: null, failed: err?.status !== 404 };
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
index ea3774ac11..25c5ca2089 100644
--- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
+++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
@@ -117,7 +117,7 @@
}
@case ('installing') {
- {{ 'iot-hub.install-confirm-title' | translate:{ name: item.name } }}
+ {{ 'iot-hub.installing-title' | translate:{ name: item.name } }}
{{ 'iot-hub.install-desc' | translate }}
}
@case ('success') {
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
index 3f2687a2a8..7de5717b66 100644
--- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
+++ b/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{{ item.creatorDisplayName }}
- ·
+ @if (showInstallCount) {
+ ·
+ }
+ }
+ @if (showInstallCount) {
+ download
+ {{ item.totalInstallCount | shortNumber }}
}
- download
- {{ item.totalInstallCount | shortNumber }}
@if (mode === 'add') {
@if (isInstalled()) {