+ }
+
- @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()) {
{{ 'iot-hub.most-popular-in-iot-hub' | translate }}
@@ -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;