From bb47013d1b99898b89be284d4487dd5cd9936770 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 5 Jun 2026 13:25:26 +0300 Subject: [PATCH] feat(iot-hub): unified network-unavailable error state with debounced retry Bring the same fetch-failure UI to every IoT Hub surface (browse, search, home, creator profile) so all of them recover gracefully from network / server outages instead of dead-ending on a global error toast or a hard redirect. - Add a tb-no-service-bg global utility in form.scss that mirrors tb-no-data-bg but renders /assets/home/no-service.svg (copied from thingsboard.io). Same primary-color mask treatment, so PE builds retint without re-exporting the asset. - Add hasError / retryTimer state to the four affected components and switch their data calls to ignoreErrors: true so the global toast no longer fires. hasError flips to true on error, stays true while retries are pending, and is only cleared in the `next` callback of the next request that actually succeeds. - Add retryLoad* helpers (retryLoadItems / retryLoadResults / retryLoadPopularItems / retryLoadCreator) that set isLoading=true immediately and schedule the real load via a 350ms setTimeout so rapid-fire retry clicks coalesce into one network round-trip. - Search component drops the inline switchMap-on-searchSubject path and pipes the debounced subject straight into loadResults() so search-triggered loads go through the same error / spinner / retry plumbing. - Render the error block consistently across surfaces: in browse and search inside their respective results container, on the home page right after the category cards, on the creator profile as the whole page body (replacing the old router.navigate to /iot-hub). Each block uses .tb-no-service-bg + the new iot-hub.network-server-unavailable / -text / try-again locale keys and binds the primary button to the matching retryLoad* method. - Add the matching .tb-iot-hub-empty-state typography (18/500 title, 14/0.54 body, 24px button gap) to the home and creator profile SCSS so the wrapper matches the layout already used in browse / search. Also pick up an unrelated dashboard-widget-select adjustment touched in the same workspace. --- .../dashboard-widget-select.component.ts | 22 ++++----- .../iot-hub/iot-hub-browse.component.html | 10 ++-- .../iot-hub/iot-hub-browse.component.scss | 2 +- .../iot-hub/iot-hub-browse.component.ts | 18 ++++++- .../iot-hub/iot-hub-search.component.html | 14 +++++- .../iot-hub/iot-hub-search.component.scss | 17 +++---- .../iot-hub/iot-hub-search.component.ts | 48 ++++++++++++++----- .../iot-hub-creator-profile.component.html | 19 +++++++- .../iot-hub-creator-profile.component.scss | 22 +++++++++ .../iot-hub-creator-profile.component.ts | 39 +++++++++++++-- .../pages/iot-hub/iot-hub-home.component.html | 24 ++++++++-- .../pages/iot-hub/iot-hub-home.component.scss | 22 +++++++++ .../pages/iot-hub/iot-hub-home.component.ts | 37 +++++++++++++- .../iot-hub/iot-hub-items-page.component.ts | 2 +- ui-ngx/src/assets/home/no-service.svg | 23 +++++++++ .../assets/locale/locale.constant-en_US.json | 3 ++ ui-ngx/src/form.scss | 25 ++++++++++ 17 files changed, 293 insertions(+), 54 deletions(-) create mode 100644 ui-ngx/src/assets/home/no-service.svg 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;