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 8671057520..3034cee011 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 @@ -26,7 +26,7 @@ import { widgetType, WidgetTypeInfo } from '@shared/models/widget.models'; -import { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators'; import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; @@ -780,21 +780,17 @@ export class DashboardWidgetSelectComponent { } private fetchInstalledWidgetVersions() { - const itemIds = (this.installedWidgets || []).map(i => i.itemId); - if (itemIds.length === 0) { + const versionIds = (this.installedWidgets || []) + .map(i => i.itemVersionId) + .filter(id => !!id); + if (versionIds.length === 0) { this.installedWidgetVersions = []; return of([]); } - return this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true }).pipe( - switchMap(infos => { - if (infos.length === 0) { - return of([]); - } - const versionRequests = infos.map(info => - this.iotHubApiService.getVersionInfo(info.publishedVersionId, { ignoreLoading: true }) - ); - return forkJoin(versionRequests); - }), + const versionRequests = versionIds.map(id => + this.iotHubApiService.getVersionInfo(id, { ignoreLoading: true }) + ); + return forkJoin(versionRequests).pipe( map(versions => { this.installedWidgetVersions = versions.sort((a, b) => b.totalInstallCount - a.totalInstallCount); return this.installedWidgetVersions; diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html index 83b1764afd..0448de834c 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.html @@ -410,11 +410,11 @@ @if (hasError) {
-
-

{{ 'iot-hub.unable-to-load' | translate }}

-

{{ 'iot-hub.unable-to-load-text' | translate }}

-
} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss index 4c058ef5d1..fc685f5b90 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.scss @@ -424,7 +424,7 @@ // Empty state text + no-data image .tb-iot-hub-empty-state { - .tb-no-data-bg { + .tb-no-data-bg, .tb-no-service-bg { flex: auto; height: 100px; } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts index 149ef64912..2efea716bf 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-browse.component.ts @@ -113,6 +113,7 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy isLoading = false; hasError = false; filterDrawerOpened = false; + private retryTimer: any = null; textSearch = ''; _activeType: ItemType = ItemType.WIDGET; @@ -676,9 +677,23 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy }); } + retryLoadItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadItems(); + }, 350); + } + loadItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } this.isLoading = true; - this.hasError = false; const sort = this.sortOptions[this.selectedSortIndex]; const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); @@ -702,6 +717,7 @@ export class TbIotHubBrowseComponent implements OnInit, AfterViewInit, OnDestroy this.items = data.data; this.totalElements = data.totalElements; this.isLoading = false; + this.hasError = false; }, error: () => { this.isLoading = false; diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html index ac2f4b6215..5a74f045fe 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.html @@ -90,8 +90,20 @@
+ + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ +
+ } + - @if (totalElements === 0 && searchText?.trim()) { + @if (!hasError && totalElements === 0 && searchText?.trim()) {

{{ 'iot-hub.no-items-found' | translate }}

diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss index 39f8402297..de87e32c0f 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.scss @@ -123,27 +123,22 @@ // Empty state no-data-bg + text .tb-search-empty { - .tb-no-data-bg { + .tb-no-data-bg, .tb-no-service-bg { flex: auto; height: 100px; } 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; + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; } p { font-size: 14px; - font-weight: 400; - line-height: 20px; - letter-spacing: 0.2px; color: rgba(0, 0, 0, 0.54); - margin: 0; + margin: 0 0 24px; } } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts index 529d372082..64ff558723 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-search.component.ts @@ -18,7 +18,7 @@ import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angu import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { forkJoin, Subject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { PageLink } from '@shared/models/page/page-link'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; @@ -65,6 +65,8 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy { resultGroups: SearchResultGroup[] = []; totalElements = 0; isLoading = false; + hasError = false; + private retryTimer: any = null; pageSize = 15; pageIndex = 0; @@ -98,14 +100,10 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy { this.loadInstalledItems(); this.searchSubscription = this.searchSubject.pipe( debounceTime(300), - distinctUntilChanged(), - switchMap(text => { - this.isLoading = true; - this.pageIndex = 0; - return this.fetchResults(text); - }) - ).subscribe(result => { - this.applyResults(result.data, result.totalElements); + distinctUntilChanged() + ).subscribe(() => { + this.pageIndex = 0; + this.loadResults(); }); this.loadResults(); } @@ -269,11 +267,37 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy { void this.router.navigate(['/iot-hub/creator', creatorId]); } + retryLoadResults(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadResults(); + }, 350); + } + // Data loading private loadResults(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } this.isLoading = true; - this.fetchResults(this.searchText || '').subscribe(result => { - this.applyResults(result.data, result.totalElements); + // hasError stays as-is until the request actually succeeds + // (cleared in the `next` callback below). + this.fetchResults(this.searchText || '').subscribe({ + next: result => { + this.applyResults(result.data, result.totalElements); + this.hasError = false; + }, + error: () => { + this.isLoading = false; + this.hasError = true; + this.resultGroups = []; + this.totalElements = 0; + } }); } @@ -282,7 +306,7 @@ export class TbIotHubSearchComponent implements OnInit, OnDestroy { const sortOrder: SortOrder = { property: sort.value, direction: sort.direction }; const pageLink = new PageLink(this.pageSize, this.pageIndex, text.trim() || null, sortOrder); const query = new MpItemVersionQuery(pageLink, { creatorId: this.creatorId || undefined }); - return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true }); + return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true, ignoreErrors: true }); } private applyResults(data: MpItemVersionView[], totalElements: number): void { diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html index 3e597f61ac..e36dd5210c 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.html @@ -16,7 +16,24 @@ -->
- @if (creator) { + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ + @if (isLoading) { +
+
+ +
+
+ } +
+ } + @if (creator && !hasError) {
diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss index e35f8d4089..885ef74812 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.scss @@ -31,6 +31,28 @@ height: 100%; } +.tb-iot-hub-empty-state { + position: relative; + .tb-no-data-bg, + .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + // Left column: creator info .tb-creator-info { width: 360px; diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts index f0ad8f3f94..d4a992f50c 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-creator-profile.component.ts @@ -31,7 +31,10 @@ export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy { creator: CreatorView; creatorId: string; + isLoading = false; + hasError = false; + private retryTimer: any = null; private destroy$ = new Subject(); constructor( @@ -64,10 +67,40 @@ export class TbIotHubCreatorProfileComponent implements OnInit, OnDestroy { void this.router.navigate(['/iot-hub']); } + retryLoadCreator(): void { + // Debounce frequent retry clicks: show the loading spinner + // immediately and defer the actual loadCreator() call by 350ms + // so rapid-fire clicks coalesce into a single network round-trip. + // hasError is left as-is until the next request actually succeeds. + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadCreator(); + }, 350); + } + private loadCreator(): void { - this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true }).subscribe({ - next: creator => this.creator = creator, - error: () => void this.router.navigate(['/iot-hub']) + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + // hasError stays as-is until the request actually succeeds — + // cleared in the `next` callback below. + this.iotHubApiService.getCreatorProfile(this.creatorId, { ignoreLoading: true, ignoreErrors: true }).subscribe({ + next: creator => { + this.creator = creator; + this.isLoading = false; + this.hasError = false; + }, + error: () => { + this.creator = null; + this.isLoading = false; + this.hasError = true; + } }); } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html index 7c66048cb8..682411d884 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.html @@ -153,7 +153,25 @@ }
- @if (!isLoading) { + @if (hasError) { +
+
+

{{ 'iot-hub.network-server-unavailable' | translate }}

+

{{ 'iot-hub.network-server-unavailable-text' | translate }}

+ + @if (isLoading) { +
+
+ +
+
+ } +
+ } + + @if (!isLoading && !hasError) { @if (hasAnyPopularItems()) {
@@ -299,7 +317,7 @@ } } - @if (isLoading) { + @if (isLoading && !hasError) {
@@ -307,7 +325,7 @@
- @if (!isLoading) { + @if (!isLoading && !hasError) {

{{ 'iot-hub.become-a-creator' | translate }}

diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss index 9905ab1515..f0768bf330 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.scss @@ -542,6 +542,28 @@ margin: 0; } +.tb-iot-hub-empty-state { + position: relative; + .tb-no-data-bg, + .tb-no-service-bg { + flex: auto; + height: 100px; + } + + h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin: 16px 0 8px; + } + + p { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px; + } +} + // Sections — inside main-content, no individual padding needed .tb-iot-hub-section { } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts index e095ef3cc8..72a5513056 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-home.component.ts @@ -134,6 +134,8 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { installedItemsCount = 0; isLoading = true; + hasError = false; + private retryTimer: any = null; bigCardCount = 5; compactCardCount = 6; @@ -444,7 +446,7 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } openSignup(): void { - window.open('https://iothub.thingsboard.io/signup', '_blank'); + window.open(this.iotHubApiService.baseUrl + '/signup', '_blank'); } private updateCardCounts(): void { @@ -479,9 +481,32 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { } } + retryLoadPopularItems(): void { + // Debounce frequent retry clicks: show the loading spinner + // immediately and defer the actual loadPopularItems() call by + // 350ms so rapid-fire clicks coalesce into a single request set. + // hasError stays as-is until the next request actually succeeds. + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + } + this.isLoading = true; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.loadPopularItems(); + }, 350); + } + private loadPopularItems(): void { + if (this.retryTimer != null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.isLoading = true; + // hasError is intentionally NOT reset here — the error UI stays + // visible until the next forkJoin actually succeeds (cleared in + // the `next` callback below). const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC }; - const config = { ignoreLoading: true }; + const config = { ignoreLoading: true, ignoreErrors: true }; const installedPageLink = new PageLink(10000, 0); const buildQuery = (type: ItemType, size: number): MpItemVersionQuery => { @@ -519,9 +544,17 @@ export class TbIotHubHomeComponent implements OnInit, OnDestroy { this.installedRuleChainCounts = results.installedRuleChainCounts; this.installedItemsCount = results.installedCount; this.isLoading = false; + this.hasError = false; }, error: () => { this.isLoading = false; + this.hasError = true; + this.popularWidgets = []; + this.popularSolutionTemplates = []; + this.popularCalcFields = []; + this.popularAlarmRules = []; + this.popularRuleChains = []; + this.popularDevices = []; } }); } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts index 28209b9b7d..558f929f6a 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts @@ -120,7 +120,7 @@ export class TbIotHubItemsPageComponent implements OnInit { } openSignup(): void { - window.open('https://iothub.thingsboard.io/signup', '_blank'); + window.open(this.iotHubApiService.baseUrl + '/signup', '_blank'); } private loadInstalledCount(): void { diff --git a/ui-ngx/src/assets/home/no-service.svg b/ui-ngx/src/assets/home/no-service.svg new file mode 100644 index 0000000000..2c591b268a --- /dev/null +++ b/ui-ngx/src/assets/home/no-service.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ace260ead5..0abf1cbcc0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3789,6 +3789,9 @@ "results-count": "{{count}} results", "unable-to-load": "Unable to load", "unable-to-load-text": "There was a problem loading items. Please try again.", + "network-server-unavailable": "Network or server unavailable", + "network-server-unavailable-text": "We couldn’t reach the catalog. Please try again in a moment.", + "try-again": "Try again", "retry": "Retry", "no-items-found": "No items found", "no-items-found-text": "Try adjusting your search or filters.", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index a694e04a65..ab178e29ae 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -720,6 +720,31 @@ } } + .tb-no-service-bg { + margin: 10px; + position: relative; + flex: 1; + width: 100%; + max-height: 100px; + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $tb-primary-color; + mask-image: url(/assets/home/no-service.svg); + -webkit-mask-image: url(/assets/home/no-service.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + mask-position: center; + -webkit-mask-position: center; + } + } + .tb-no-data-text { font-weight: 500; font-size: 14px;