diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index 06a6c86ddd..e4e50cc830 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -188,7 +188,7 @@ export class WidgetService { } public getWidgetType(fullFqn: string, config?: RequestConfig): Observable { - return this.http.get(`/api/widgetType?fqn=${fullFqn}`, + return this.http.get(`/api/widgetType?fqn=${encodeURIComponent(fullFqn)}`, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html index 7d2ea9cdb5..6da41a9878 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html @@ -370,7 +370,8 @@ - +
{{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 73e627fbe6..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 @@ -17,6 +17,7 @@ import { Injectable } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; 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'; @@ -30,6 +31,15 @@ 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 { @@ -37,7 +47,8 @@ export class IotHubActionsService { constructor( private dialog: MatDialog, private dialogService: DialogService, - private translate: TranslateService + private translate: TranslateService, + private builtInService: IotHubBuiltInService ) {} openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number, @@ -72,7 +83,67 @@ 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'), @@ -81,13 +152,13 @@ export class IotHubActionsService { 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(); } @@ -121,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 e83580e271..404ec8ef03 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'; export interface IotHubAddItemDialogData { itemType: ItemType; @@ -73,6 +74,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 43ed8d21b4..e4816d19ad 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 = @@ -140,6 +147,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()) { check{{ 'iot-hub.installed' | translate }} } @else { - + } } @else if (isInstalled()) { @if (isSameVersion()) { @@ -74,7 +78,7 @@ } } @else { - + } } @else { @@ -105,18 +109,22 @@ [class.clickable]="mode !== 'add'" (click)="mode !== 'add' && onCreatorClick($event)" (keyup.enter)="mode !== 'add' && onCreatorClick($event)">{{ item.creatorDisplayName }} - · + @if (showInstallCount) { + · + } + } + @if (showInstallCount) { + + download + {{ item.totalInstallCount | shortNumber }} + } - - download - {{ item.totalInstallCount | shortNumber }} - @if (mode === 'add') { @if (isInstalled()) { check{{ 'iot-hub.installed' | translate }} } @else { - + } } @else if (isInstalled()) { @if (isSameVersion()) { @@ -143,7 +151,7 @@ } } @else { - + } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts index 2d9c13d8d7..b2fe9a58f3 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-card.component.ts +++ b/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 diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html index 30d9322dd9..eba34fb30a 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -30,15 +30,17 @@ {{ getTypeIcon() }} {{ typeTranslations.get(item.type) | translate }} - - - download - {{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }} - + @if (showInstallCount) { + + + download + {{ item.totalInstallCount | shortNumber }} {{ 'iot-hub.installs' | translate }} + + } update - v {{ item.version }} + {{ versionLabel }} @if (preview) { @@ -318,19 +320,13 @@ @if (mode === 'add' && !isInstalled()) { } @if (!isInstalled() && mode !== 'add') { - @if (item.type === ItemType.DEVICE) { - - } @else { - - } + } @if (isInstalled() && mode !== 'add') {