From e18205c4829d91aab08e2f18d8183a6009538a34 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 11:55:59 -0700 Subject: [PATCH 01/13] refactor(frontend): drop NgZone wrappers, no-ops when zoneless The app runs with provideZonelessChangeDetection(), so NgZone.run() and NgZone.runOutsideAngular() just call their callback. Remove the 12 calls and the 9 NgZone injections that existed only for them, in the session-expiry poll, the endpoint health-check interval, the metrics time-window poll, the Kubernetes summary poll, the blur and focus directives and the markdown content observer. The metrics poll keeps its explicit ApplicationRef.tick(). ApplicationTabsBaseComponent injected NgZone without using it. Delete code that never did anything: - core/src/leaveEnterAngularZone.ts: two pass-through scheduler helpers with no importers. - DashboardBaseComponent.redrawSideNav() and the (changedMode) binding that called it. It poked _modeChanged on a Material drawer, but #sidenav is now a plain
, so the property is undefined and the method returned at its first check. - The @ViewChild('sidenav') setter in the same component. It subscribed to drawer.closedStart, which a
's ElementRef does not have, so the subscription was never made. --- .../application-tabs-base.component.ts | 3 +- .../dashboard-base.component.html | 4 +- .../dashboard-base.component.ts | 32 +------------- .../endpoints-page.component.ts | 13 ++---- .../core/src/leaveEnterAngularZone.ts | 42 ------------------- .../packages/core/src/logged-in.service.ts | 35 +++++++--------- .../src/shared/components/blur.directive.ts | 7 +--- .../src/shared/components/focus.directive.ts | 7 +--- .../markdown-content-observer.directive.ts | 7 +--- .../metrics-range-selector-manager.service.ts | 23 +++++----- .../kubernetes-summary.component.ts | 11 +---- 11 files changed, 41 insertions(+), 143 deletions(-) delete mode 100644 src/frontend/packages/core/src/leaveEnterAngularZone.ts diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts index cd4daf5a3e..3dd1eed006 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EffectRef, Injector, NgZone, OnDestroy, OnInit, ChangeDetectionStrategy, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { Component, EffectRef, Injector, OnDestroy, OnInit, ChangeDetectionStrategy, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; import { GitSCMService, GitSCMType } from '@stratosui/git'; @@ -62,7 +62,6 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { applicationService = inject(ApplicationService); private router = inject(Router); private endpointsService = inject(EndpointsService); - private ngZone = inject(NgZone); private currentUserPermissionsService = inject(CurrentUserPermissionsService); private userFavoriteManager = inject(UserFavoriteManager); private cfEndpoints = inject(CfEndpointsDataService); diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html index 714af2cb06..80551b74ec 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html @@ -4,7 +4,7 @@ style="display: flex !important; flex-direction: row !important; width: 100% !important; height: 100vh !important;"> @if (mainNavState$ | async; as navState) { -
- +
} diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts index 5eb5ea4920..d7a1ef982f 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts @@ -1,6 +1,6 @@ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { Portal, PortalModule } from '@angular/cdk/portal'; -import { ChangeDetectionStrategy, AfterViewInit, ChangeDetectorRef, Component, ElementRef, NgZone, OnDestroy, OnInit, signal, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, signal, ViewChild, ViewContainerRef, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Route, Router, RouterModule } from '@angular/router'; import { toObservable } from '@angular/core/rxjs-interop'; @@ -11,7 +11,7 @@ import { import { DashboardSignalService } from '../../../core/signals/dashboard-signal.service'; import { DashboardDataService, DashboardState } from '../../../core/dashboard-data.service'; import { combineLatest, Observable, of, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, map, startWith, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, startWith } from 'rxjs/operators'; import { CustomizationService } from '../../../core/customizations.types'; import { naturalCompare } from '../../../shared/utils/natural-sort'; @@ -50,7 +50,6 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit private activatedRoute = inject(ActivatedRoute); private endpointsService = inject(EndpointsService); tabNavService = inject(TabNavService); - private ngZone = inject(NgZone); sidePanelService = inject(SidePanelService); private cs = inject(CustomizationService); private cd = inject(ChangeDetectorRef); @@ -68,11 +67,9 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit public rightNavState$!: Observable<{ opened: boolean, component?: object, props?: object, }>; private dashboardState$: Observable = of({} as DashboardState); public noMargin$: Observable = of(false); - private closeSub!: Subscription; private mobileSub: Subscription; private resizeObserver?: ResizeObserver; private mutationObserver?: MutationObserver; - private drawer: any; public iconModeOpen = false; public sideNavWidth = 54; @@ -122,28 +119,6 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit .subscribe(isMobile => isMobile ? this.dashboardData.enableMobileNav() : this.dashboardData.disableMobileNav()); } - @ViewChild('sidenav') set sidenav(drawer: any) { - this.drawer = drawer; - if (!this.closeSub && drawer && drawer.closedStart) { - // We need this for mobile to ensure the state is synced when the dashboard is closed by clicking on the backdrop. - this.closeSub = drawer.closedStart.pipe(withLatestFrom(this.dashboardState$)).subscribe(([_change, state]: [any, DashboardState]) => { - if (state.isMobile) { - this.dashboardData.closeSideNav(); - } - }); - } - } - - public redrawSideNav() { - // We need to do this to ensure there isn't a space left behind - // when going from mobile to desktop - if (this.drawer && this.drawer._modeChanged) { - this.ngZone.runOutsideAngular(() => { - setTimeout(() => this.drawer._modeChanged.next(), 250); - }); - } - } - sideHelpClosed() { this.sidePanelService.hide(); } @@ -202,9 +177,6 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit if (this.mobileSub) { this.mobileSub.unsubscribe(); } - if (this.closeSub) { - this.closeSub.unsubscribe(); - } this.resizeObserver?.disconnect(); this.mutationObserver?.disconnect(); this.sidePanelService.unsetContainer(); diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts index 162650e714..44735b1743 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, AfterViewInit, Component, NgZone, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, AfterViewInit, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { CustomTooltipDirective } from '../../../shared/components/custom-tooltip/custom-tooltip.directive'; import { Router, RouterModule } from '@angular/router'; @@ -54,7 +54,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit router = inject(Router); private endpointsData = inject(EndpointsDataService); private authSignal = inject(AuthSignalService); - private ngZone = inject(NgZone); private snackBarService = inject(TailwindSnackBarService); sessionService = inject(SessionService); private endpointModalService = inject(EndpointModalService); @@ -165,13 +164,9 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit public extensionActions: StratosActionMetadata[] = getActionsFromExtensions(StratosActionType.Endpoints); private startEndpointHealthCheckPulse() { - this.ngZone.runOutsideAngular(() => { - this.healthCheckTimeout = window.setInterval(() => { - this.ngZone.run(() => { - this.endpointsService.checkAllEndpoints(); - }); - }, 30000); - }); + this.healthCheckTimeout = window.setInterval(() => { + this.endpointsService.checkAllEndpoints(); + }, 30000); } private stopEndpointHealthCheckPulse() { diff --git a/src/frontend/packages/core/src/leaveEnterAngularZone.ts b/src/frontend/packages/core/src/leaveEnterAngularZone.ts deleted file mode 100644 index 484cdf2242..0000000000 --- a/src/frontend/packages/core/src/leaveEnterAngularZone.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Zoneless Migration: Zone.js-free scheduler utilities - * - * In zoneless mode (Angular 20+), there is no NgZone, so zone-related - * scheduler operations are pass-through. These functions maintain API - * compatibility while operating in a zoneless environment. - */ -import { SchedulerLike } from 'rxjs'; - -/** - * Pass-through function for zoneless mode. - * In zone-based apps, this would run work outside Angular's zone. - * In zoneless mode, schedulers run normally without zone context. - * - * @param _zone Ignored in zoneless mode (kept for API compatibility) - * @param scheduler The scheduler to use - * @returns The scheduler unchanged (no zone wrapping needed) - */ -export function leaveZone(_zone: any, scheduler: SchedulerLike): SchedulerLike { - if (!scheduler) { - throw new Error('leaveZone requires a valid scheduler'); - } - // In zoneless mode, just return the scheduler directly - return scheduler; -} - -/** - * Pass-through function for zoneless mode. - * In zone-based apps, this would run work inside Angular's zone. - * In zoneless mode, schedulers run normally without zone context. - * - * @param _zone Ignored in zoneless mode (kept for API compatibility) - * @param scheduler The scheduler to use - * @returns The scheduler unchanged (no zone wrapping needed) - */ -export function enterZone(_zone: any, scheduler: SchedulerLike): SchedulerLike { - if (!scheduler) { - throw new Error('enterZone requires a valid scheduler'); - } - // In zoneless mode, just return the scheduler directly - return scheduler; -} diff --git a/src/frontend/packages/core/src/logged-in.service.ts b/src/frontend/packages/core/src/logged-in.service.ts index 8ad5acba4d..0835a604c7 100644 --- a/src/frontend/packages/core/src/logged-in.service.ts +++ b/src/frontend/packages/core/src/logged-in.service.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { Injectable, NgZone, inject } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { TailwindDialogService } from './shared/services/tailwind-dialog.service'; import { AuthState } from '@stratosui/store'; @@ -21,7 +21,6 @@ import { DashboardSignalService } from './core/signals/dashboard-signal.service' export class LoggedInService { private document = inject(DOCUMENT); private dialog = inject(TailwindDialogService); - private ngZone = inject(NgZone); private currentUserPermissionsService = inject(CurrentUserPermissionsService); private authSignals = inject(AuthSignalService); private dashboardSignals = inject(DashboardSignalService); @@ -100,29 +99,23 @@ export class LoggedInService { } } - // Run outside Angular zone to prevent E2E test timeouts - // Polling intervals should not block Angular change detection private openSessionCheckerPoll() { this.closeSessionCheckerPoll(); // Ensure interval configuration is valid const intervalTime = this.checkSessionInterval || 5000; - this.ngZone.runOutsideAngular(() => { - this.sessionChecker = interval(intervalTime) - .pipe( - withLatestFrom( - this.dashboardState$, - this.auth$ - ), - tap(([, dashboardState, authState]) => { - if (!authState) { - return; - } - this.ngZone.run(() => { - this._checkSession(dashboardState, authState); - }); - }) - ).subscribe(); - }); + this.sessionChecker = interval(intervalTime) + .pipe( + withLatestFrom( + this.dashboardState$, + this.auth$ + ), + tap(([, dashboardState, authState]) => { + if (!authState) { + return; + } + this._checkSession(dashboardState, authState); + }) + ).subscribe(); } private closeSessionCheckerPoll() { diff --git a/src/frontend/packages/core/src/shared/components/blur.directive.ts b/src/frontend/packages/core/src/shared/components/blur.directive.ts index 4c45b4ccc3..a3f42e6862 100644 --- a/src/frontend/packages/core/src/shared/components/blur.directive.ts +++ b/src/frontend/packages/core/src/shared/components/blur.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, Input, NgZone, OnChanges, inject } from '@angular/core'; +import { Directive, ElementRef, Input, OnChanges, inject } from '@angular/core'; @Directive({ selector: '[appBlur]', @@ -8,7 +8,6 @@ export class BlurDirective implements OnChanges { @Input() appBlur!: boolean; private element = inject(ElementRef); - private ngZone = inject(NgZone); public ngOnChanges() { if (this.appBlur) { @@ -17,8 +16,6 @@ export class BlurDirective implements OnChanges { } private blur() { - this.ngZone.runOutsideAngular(() => { - setTimeout(() => this.element.nativeElement.blur(), 250); - }); + setTimeout(() => this.element.nativeElement.blur(), 250); } } diff --git a/src/frontend/packages/core/src/shared/components/focus.directive.ts b/src/frontend/packages/core/src/shared/components/focus.directive.ts index 42ae8e7f23..14607f5ec6 100644 --- a/src/frontend/packages/core/src/shared/components/focus.directive.ts +++ b/src/frontend/packages/core/src/shared/components/focus.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, Input, NgZone, OnChanges, inject } from '@angular/core'; +import { Directive, ElementRef, Input, OnChanges, inject } from '@angular/core'; @Directive({ selector: '[appFocus]', @@ -8,7 +8,6 @@ export class FocusDirective implements OnChanges { @Input() appFocus!: boolean; private element = inject(ElementRef); - private ngZone = inject(NgZone); public ngOnChanges() { if (this.appFocus) { @@ -17,8 +16,6 @@ export class FocusDirective implements OnChanges { } private focus() { - this.ngZone.runOutsideAngular(() => { - setTimeout(() => this.element.nativeElement.focus(), 250); - }); + setTimeout(() => this.element.nativeElement.focus(), 250); } } diff --git a/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-content-observer.directive.ts b/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-content-observer.directive.ts index b1a57465f8..e1ddb6f3cc 100644 --- a/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-content-observer.directive.ts +++ b/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-content-observer.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, EventEmitter, Output, OnDestroy, NgZone, inject } from '@angular/core'; +import { Directive, ElementRef, EventEmitter, Output, OnDestroy, inject } from '@angular/core'; @Directive({ selector: '[appMarkdownContentObserver]', @@ -8,16 +8,13 @@ export class MarkdownContentObserverDirective implements OnDestroy { private observer: MutationObserver; @Output() innerHtmlRendered = new EventEmitter(); - private ngZone = inject(NgZone); private el = inject(ElementRef); constructor() { this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation, _index) => { if (mutation.type === 'childList') { - this.ngZone.runOutsideAngular(() => { - this.innerHtmlRendered.emit(); - }); + this.innerHtmlRendered.emit(); } }); }); diff --git a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts index b0859d97c4..bbc398359e 100644 --- a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts +++ b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts @@ -1,4 +1,4 @@ -import { ApplicationRef, Injectable, NgZone, OnDestroy, inject } from '@angular/core'; +import { ApplicationRef, Injectable, OnDestroy, inject } from '@angular/core'; import { MetricsRequest, MetricQueryType } from '@stratosui/store'; import { Subject } from 'rxjs'; import { isValid, isEqual } from 'date-fns'; @@ -11,7 +11,6 @@ import { ITimeRange } from './metrics-range-selector.types'; }) export class MetricsRangeSelectorManagerService implements OnDestroy { metricRangeService = inject(MetricsRangeSelectorService); - private ngZone = inject(NgZone); private appRef = inject(ApplicationRef); @@ -126,17 +125,15 @@ export class MetricsRangeSelectorManagerService implements OnDestroy { private startWindowPoll(timeWindow: ITimeRange) { this.endWindowPoll(); - this.ngZone.runOutsideAngular(() => { - this.pollIndex = window.setInterval( - () => { - if (timeWindow.value != null && this.baseRequest) { - this.commitRequest(this.metricRangeService.getNewTimeWindowRequest(this.baseRequest, timeWindow.value)); - this.ngZone.run(() => this.appRef.tick()); - } - }, - this.pollInterval - ); - }); + this.pollIndex = window.setInterval( + () => { + if (timeWindow.value != null && this.baseRequest) { + this.commitRequest(this.metricRangeService.getNewTimeWindowRequest(this.baseRequest, timeWindow.value)); + this.appRef.tick(); + } + }, + this.pollInterval + ); } private endWindowPoll() { diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts index ad6bb03913..bec8c7d2f2 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import {Component, NgZone, OnDestroy, OnInit, computed, inject, ChangeDetectionStrategy, Injector, runInInjectionContext } from '@angular/core'; +import {Component, OnDestroy, OnInit, computed, inject, ChangeDetectionStrategy, Injector, runInInjectionContext } from '@angular/core'; import { toSignal, toObservable } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { SafeResourceUrl } from '@angular/platform-browser'; @@ -87,7 +87,6 @@ export class KubernetesSummaryTabComponent implements OnInit, OnDestroy { private podData = inject(KubePodDataService); private nodeData = inject(KubeNodeDataService); private namespaceData = inject(KubeNamespaceDataService); - private ngZone = inject(NgZone); private router = inject(Router); private injector = inject(Injector); @@ -245,13 +244,7 @@ export class KubernetesSummaryTabComponent implements OnInit, OnDestroy { } private poll(guid: string) { - this.ngZone.runOutsideAngular(() => - this.polls.push( - interval(10000).subscribe(() => { - this.ngZone.run(() => this.refreshAll(guid)); - }) - ) - ); + this.polls.push(interval(10000).subscribe(() => this.refreshAll(guid))); } // Refresh the three cluster caches. Each data service dedups in-flight From 4293315202ce9baf92338bce6bd96741831c964d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 11:57:03 -0700 Subject: [PATCH 02/13] refactor(frontend): drop HttpClientModule duplicating provideHttpClient HttpClientModule is deprecated. AppModule already configures the HTTP client with provideHttpClient(withXhr(), withInterceptors([...])), and that configuration is the one the app uses. The HttpClientModule imported by AppStoreModule and StratosThemeModule, both loaded into the same root injector, only appended a second copy of Angular's XSRF interceptor and the adapter for class-based HTTP_INTERCEPTORS to the interceptor chain. No class-based interceptor is registered at the root, so that adapter had nothing to run. The built-in XSRF interceptor that sends the X-XSRF-TOKEN header still comes from provideHttpClient. Also drop it from HelmBaseTestModules and WorkloadsBaseTestingModule, two exported test arrays that no spec uses. --- .../packages/kubernetes/src/helm/helm-testing.module.ts | 3 +-- .../src/kubernetes/workloads/workloads.testing.module.ts | 2 -- src/frontend/packages/store/src/store.module.ts | 2 -- src/frontend/packages/theme/theme.module.ts | 3 +-- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts index cb29b55f1b..9344039bc3 100644 --- a/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts +++ b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpClientModule, HttpHandler } from '@angular/common/http'; +import { HttpClient, HttpHandler } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; @@ -66,7 +66,6 @@ export const HelmBaseTestModules = [ RouterTestingModule, CoreModule, NoopAnimationsModule, - HttpClientModule, SharedModule ].filter(Boolean); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts index a969b792be..14eef886fb 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts @@ -1,4 +1,3 @@ -import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; @@ -46,7 +45,6 @@ export const WorkloadsBaseTestingModule = [ CoreModule, createBasicStoreModule(), NoopAnimationsModule, - HttpClientModule, SharedModule, HelmTestingModule, WorkloadsTestingModule diff --git a/src/frontend/packages/store/src/store.module.ts b/src/frontend/packages/store/src/store.module.ts index 624196ecd5..6f7b3d0825 100644 --- a/src/frontend/packages/store/src/store.module.ts +++ b/src/frontend/packages/store/src/store.module.ts @@ -1,4 +1,3 @@ -import { HttpClientModule } from '@angular/common/http'; import { NgModule, inject } from '@angular/core'; import { EntityCatalogProvidersModule } from './entity-catalog-providers.module'; @@ -8,7 +7,6 @@ import { EndpointDisconnectCleanupService } from './services/endpoint-disconnect @NgModule({ imports: [ EntityCatalogProvidersModule, - HttpClientModule, ] }) export class AppStoreModule { diff --git a/src/frontend/packages/theme/theme.module.ts b/src/frontend/packages/theme/theme.module.ts index 9576dc311a..8cb5a491ff 100644 --- a/src/frontend/packages/theme/theme.module.ts +++ b/src/frontend/packages/theme/theme.module.ts @@ -1,10 +1,9 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; import { StratosBrandingService } from './stratos-branding.service'; @NgModule({ - imports: [CommonModule, HttpClientModule], + imports: [CommonModule], providers: [StratosBrandingService] }) export class StratosThemeModule { } From 06c58b5d151dd4bd77ae403f937cf40a034c3efb Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 11:59:02 -0700 Subject: [PATCH 03/13] refactor(frontend): pass observers and error factories to RxJS RxJS 7 deprecates several call forms that RxJS 8 removes. Replace them with the forms RxJS 7 recommends; behaviour is unchanged. - subscribe(next, error[, complete]) with positional callbacks becomes subscribe({ next, error, complete }) at 7 sites: the EULA page, backup and restore of endpoints, the markdown preview, the SSH viewer, the kubeconfig certificate check and the Kubernetes dashboard configuration. - throwError(value) becomes throwError(() => value) in the monocular ChartsService error handler. - Observable.create(fn) becomes new Observable(fn) for ChartsService's cached-charts branch. It still emits the cached list and does not complete, as before. - EventEmitter.observers.length > 0 becomes EventEmitter.observed in CardCfRecentAppsComponent. Delete the monocular ReposService and its provider entry. Nothing injects it, and its error handler called error.json(), which an HttpErrorResponse does not have, so it would have thrown a TypeError instead of reporting the failure. Its throwError(value) was the other deprecated call of that form. --- .../card-cf-recent-apps.component.ts | 2 +- .../about/eula-page/eula-page.component.ts | 8 ++-- .../backup-endpoints.component.ts | 8 ++-- .../restore-endpoints.component.ts | 8 ++-- .../markdown-preview.component.ts | 7 +-- .../ssh-viewer/ssh-viewer.component.ts | 10 ++-- .../shared/services/charts.service.ts | 4 +- .../shared/services/repos.service.ts | 46 ------------------- .../stratos-monocular-providers.helpers.ts | 2 - .../kube-config-table-cert.component.ts | 8 ++-- .../kubedash-configuration.component.ts | 29 ++++++------ 11 files changed, 44 insertions(+), 88 deletions(-) delete mode 100644 src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts index 1daa1fd4e1..14f59a5267 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts @@ -74,7 +74,7 @@ export class CardCfRecentAppsComponent implements OnInit { this.hasEntities$ = of(false); return; } - this.canRefresh = this.refresh.observers.length > 0; + this.canRefresh = this.refresh.observed; // Every live caller passes allApps$ (home, CF, org and space summaries) const allApps$ = this.allApps$() ?? of([]); this.appsLoading$ = this.loading$() ?? of(false); diff --git a/src/frontend/packages/core/src/features/about/eula-page/eula-page.component.ts b/src/frontend/packages/core/src/features/about/eula-page/eula-page.component.ts index d8bab530fc..aa92508744 100644 --- a/src/frontend/packages/core/src/features/about/eula-page/eula-page.component.ts +++ b/src/frontend/packages/core/src/features/about/eula-page/eula-page.component.ts @@ -33,10 +33,10 @@ export class EulaPageComponent { constructor() { const http = inject(HttpClient); - http.get('/core/assets/eula.html', {responseType: 'text'}).subscribe( - (html: string) => this.eulaHtml.set(html), - () => this.eulaHtml.set('An error occurred retrieving the EULA') - ); + http.get('/core/assets/eula.html', {responseType: 'text'}).subscribe({ + next: (html: string) => this.eulaHtml.set(html), + error: () => this.eulaHtml.set('An error occurred retrieving the EULA') + }); } } diff --git a/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts b/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts index e7efa54314..dc381ef69c 100644 --- a/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts @@ -274,10 +274,10 @@ export class BackupEndpointsComponent implements OnDestroy { reject(new Error(`Failed to create backup` + (errorMessage ? `: ${errorMessage}` : ''))); }; - const createBackup = () => this.service.createBackup().pipe(take(1), defaultIfEmpty(null)).subscribe( - res => res !== null ? backupSuccess(res) : backupFailure('Backup service returned no response'), - backupFailure - ); + const createBackup = () => this.service.createBackup().pipe(take(1), defaultIfEmpty(null)).subscribe({ + next: res => res !== null ? backupSuccess(res) : backupFailure('Backup service returned no response'), + error: backupFailure + }); if (this.service.hasConnectionDetails()) { this.confirmDialog.openWithCancel(confirmation, createBackup, userCancelledDialog); diff --git a/src/frontend/packages/core/src/features/endpoints/backup-restore/restore-endpoints/restore-endpoints.component.ts b/src/frontend/packages/core/src/features/endpoints/backup-restore/restore-endpoints/restore-endpoints.component.ts index 3a901d8172..3eb0b9e49e 100644 --- a/src/frontend/packages/core/src/features/endpoints/backup-restore/restore-endpoints/restore-endpoints.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/backup-restore/restore-endpoints/restore-endpoints.component.ts @@ -119,10 +119,10 @@ export class RestoreEndpointsComponent { reject(new Error(`Failed to restore backup` + (errorMessage ? `: ${errorMessage}` : ''))); }; - const restoreBackup = () => this.service.restoreBackup().pipe(take(1), defaultIfEmpty(null)).subscribe( - res => res !== null ? restoreSuccess() : backupFailure('Restore service returned no response'), - backupFailure - ); + const restoreBackup = () => this.service.restoreBackup().pipe(take(1), defaultIfEmpty(null)).subscribe({ + next: res => res !== null ? restoreSuccess() : backupFailure('Restore service returned no response'), + error: backupFailure + }); this.confirmDialog.openWithCancel(confirmation, restoreBackup, userCancelledDialog); }); diff --git a/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-preview.component.ts b/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-preview.component.ts index 047f3d1ee0..69a6484275 100644 --- a/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-preview.component.ts +++ b/src/frontend/packages/core/src/shared/components/markdown-preview/markdown-preview.component.ts @@ -57,8 +57,8 @@ export class MarkdownPreviewComponent implements PreviewableComponent { } private loadDocument() { - this.httpClient.get(this._documentUrl, { responseType: 'text' }).subscribe( - (markText: string) => { + this.httpClient.get(this._documentUrl, { responseType: 'text' }).subscribe({ + next: (markText: string) => { if (markText && markText.length > 0) { // Basic sanitization - Note: marked no longer supports sanitize option const renderer = new marked.Renderer(); @@ -71,7 +71,8 @@ export class MarkdownPreviewComponent implements PreviewableComponent { this.markdownHtml.set(typeof result === 'string' ? result : ''); } }, - (error: any) => console.warn(`Failed to fetch markdown with url ${this._documentUrl}: `, error)); + error: (error: any) => console.warn(`Failed to fetch markdown with url ${this._documentUrl}: `, error) + }); } public markdownRendered() { diff --git a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts index 3321908c78..9e8e9bffce 100644 --- a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts +++ b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts @@ -153,8 +153,8 @@ export class SshViewerComponent implements OnInit, OnDestroy { this.xterm.reset(); this.msgSubscription = this.sshStream .pipe(takeUntil(this.destroy$)) - .subscribe( - (data: string) => { + .subscribe({ + next: (data: string) => { // Check for a window title message if (!this.isWindowTitle(data)) { for (const c of data.split(' ')) { @@ -162,13 +162,13 @@ export class SshViewerComponent implements OnInit, OnDestroy { } } }, - (_err) => { + error: () => { this.disconnect(); }, - () => { + complete: () => { this.disconnect(); } - ); + }); } private isWindowTitle(data: string): boolean { const chars = data.split(' '); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts index be656edf76..a3bcfe860b 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts @@ -62,7 +62,7 @@ export class ChartsService { } if (this.cacheCharts[repo] && this.cacheCharts[repo].length > 0) { - return Observable.create((observer: { next: (value: Chart[]) => void }) => { + return new Observable(observer => { observer.next(this.cacheCharts[repo]); }); } else { @@ -268,7 +268,7 @@ export class ChartsService { const errMsg = (error.message) ? error.message : error.status ? `${error.status} - ${error.statusText}` : 'Server error'; console.error(errMsg); - return throwError(errMsg); + return throwError(() => errMsg); } } diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts deleted file mode 100644 index 9509643345..0000000000 --- a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; - -import { RepoAttributes } from '../models/repo'; -import { ConfigService } from './config.service'; - - -@Injectable({ - providedIn: 'root' -}) -export class ReposService { - private http = inject(HttpClient); - private config = inject(ConfigService); - - - hostname: string; - - constructor() { - this.hostname = `/pp/v1/chartrepos`; - } - - /** - * Get all repos from the API - * - * @return An observable that will an array with all repos - */ - getRepos(): Observable { - return this.http.get<{ data: RepoAttributes[] }>(`${this.hostname}`).pipe( - map(r => this.extractData(r)), - catchError(this.handleError) - ); - } - - private extractData(res: { data: T, }): T { - return res.data || {} as T; - } - - private handleError(error: { json: () => { message?: string }; status?: number; statusText?: string }): Observable { - const errMsg = (error.json().message) ? error.json().message : - error.status ? `${error.status} - ${error.statusText}` : 'Server error'; - console.error(errMsg); - return throwError(errMsg); - } -} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts index 2b51e92f3e..a003e87977 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts @@ -5,7 +5,6 @@ import { HttpInterceptingHandler, MonocularInterceptor } from '../monocular.inte import { ChartsService } from './shared/services/charts.service'; import { ConfigService } from './shared/services/config.service'; import { MenuService } from './shared/services/menu.service'; -import { ReposService } from './shared/services/repos.service'; /** * Helm Method to ensure http client with custom monocular interceptor is used in the monocular services @@ -19,7 +18,6 @@ export const createMonocularProviders = () => [ ChartsService, ConfigService, MenuService, - ReposService, MonocularInterceptor, { provide: HttpClient, diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts index 23ddf22687..26f4348a8a 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts @@ -51,12 +51,12 @@ export class KubeConfigTableCertComponent extends TableCellCustom(`/pp/v1/kube/cert?url=${row.cluster.server}`).pipe( timeout(5000), - ).subscribe( + ).subscribe({ // Success, no cert required - (res: CertResponse) => this.update(res.Required), + next: (res: CertResponse) => this.update(res.Required), // Failed, check for specific cert required error - (_e: HttpErrorResponse) => this.update(false) - ); + error: (_e: HttpErrorResponse) => this.update(false) + }); } } } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts index 08522a9b65..958f7ba3b9 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts @@ -265,20 +265,23 @@ export class KubedashConfigurationComponent implements OnDestroy { return; } - obs.subscribe(() => { - console.log(okMsg); // Replace with proper notification system if needed - msgUpdater(okMsg); - busy.set(false); - this.refresh(); - }, (e) => { - let msg = errorMsg; - if (e && e.error && e.error.error) { - msg = e.error.error; + obs.subscribe({ + next: () => { + console.log(okMsg); // Replace with proper notification system if needed + msgUpdater(okMsg); + busy.set(false); + this.refresh(); + }, + error: (e) => { + let msg = errorMsg; + if (e && e.error && e.error.error) { + msg = e.error.error; + } + console.error(msg); // Replace with proper notification system if needed + msgUpdater(msg); + busy.set(false); + this.refresh(); } - console.error(msg); // Replace with proper notification system if needed - msgUpdater(msg); - busy.set(false); - this.refresh(); }); } From e80f024886345664f4923f0d9d213ad13716d738 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:06:15 -0700 Subject: [PATCH 04/13] refactor(frontend): use array combineLatest and combineLatestWith RxJS 7 deprecates two forms of combineLatest that RxJS 8 removes: - the creation function called with each source as its own argument, combineLatest(a, b). 32 calls, 9 of them through the observableCombineLatest import alias, now pass one array, combineLatest([a, b]). The emitted tuple is the same. - the pipeable operator imported from rxjs/operators. Its 8 calls in the log viewer, the services helper, CfRolesService and the build tab now use combineLatestWith, which emits the same [source, ...others] tuple. MetaCardComponent built its combineLatest input as (Observable | undefined)[], which only matched the deprecated overload. Every menu item's `can` is set to of(true) earlier in the same setter when missing, so map it with `?? of(true)` to give the array a non-optional element type. --- .../application-tabs-base.component.ts | 12 ++++++------ .../tabs/build-tab/build-tab.component.ts | 10 +++++----- .../cli-info-application.component.ts | 4 ++-- .../deploy-application-options-step.component.ts | 2 +- .../deploy-application-step2.component.ts | 8 ++++---- .../deploy-application-step3.component.ts | 2 +- .../cloud-foundry/src/features/cf/cf.helpers.ts | 4 ++-- .../cli-info-cloud-foundry.component.ts | 4 ++-- .../cf/services/cloud-foundry-space.service.ts | 2 +- .../cloud-foundry-space-base.component.ts | 4 ++-- .../features/cf/user-invites/user-invite.service.ts | 8 ++++---- .../cf/users/manage-users/cf-roles.service.ts | 12 ++++++------ .../card-cf-recent-apps.component.ts | 4 ++-- .../src/features/service-catalog/services-helper.ts | 4 ++-- .../select-plan-step/select-plan-step.component.ts | 4 ++-- .../user-permissions/cf-user-permissions-checkers.ts | 4 ++-- .../packages/core/src/core/endpoints.service.ts | 4 ++-- .../packages/core/src/core/user-profile.service.ts | 4 ++-- .../core/src/features/endpoints/connect.service.ts | 4 ++-- .../metrics-endpoint-details.component.ts | 4 ++-- .../components/date-time/date-time.component.ts | 4 ++-- .../components/log-viewer/log-viewer.component.ts | 8 ++++---- .../meta-card/meta-card-base/meta-card.component.ts | 2 +- .../page-header-events.component.ts | 4 ++-- .../chart-values-editor.component.ts | 2 +- .../release/tabs/helm-release-helper.service.ts | 2 +- .../helm-release-resource-graph.component.ts | 4 ++-- .../packages/store/src/helpers/store-helpers.ts | 4 ++-- .../packages/store/src/user-favorite-manager.ts | 8 ++++---- 29 files changed, 71 insertions(+), 71 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts index 3dd1eed006..a7a01ea93e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts @@ -317,10 +317,10 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { // snackbar convey the action's progress/result in place. Gating on "app // not yet present" stops those refreshes from blanking the whole page with // the spinner (the restage repaint regression). - this.isFetching$ = observableCombineLatest( + this.isFetching$ = observableCombineLatest([ this.applicationService.isFetchingApp$, toObservable(this.detail.app, { injector: this.injector }), - ).pipe( + ]).pipe( map(([isFetchingApp, app]) => isFetchingApp && !app), ); @@ -329,19 +329,19 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { startWith({ updating: true }), ); - const initialFetch$ = observableCombineLatest( + const initialFetch$ = observableCombineLatest([ this.applicationService.isFetchingApp$, this.applicationService.isFetchingEnvVars$, this.applicationService.isFetchingStats$ - ).pipe( + ]).pipe( map(([isFetchingApp, isFetchingEnvVars, isFetchingStats]) => { return isFetchingApp || isFetchingEnvVars || isFetchingStats; })); - this.summaryDataChanging$ = observableCombineLatest( + this.summaryDataChanging$ = observableCombineLatest([ initialFetch$, this.applicationService.isUpdatingApp$, - ).pipe(map(([isFetchingApp, isUpdating]) => { + ]).pipe(map(([isFetchingApp, isUpdating]) => { return !!(isFetchingApp || isUpdating); })); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.ts index 356e7bc3c7..fd8fcbcefe 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/cor import { RouterModule } from '@angular/router'; import { GitCommit, GitDataService, GitResourceState, GitRepo, GitSCMService, GitSCMType, SCMIcon } from '@stratosui/git'; import { combineLatest as observableCombineLatest, EMPTY, Observable, of } from 'rxjs'; -import { catchError, combineLatest, distinct, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { catchError, combineLatestWith, distinct, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; import { CurrentUserPermissionsService, @@ -118,7 +118,7 @@ export class BuildTabComponent implements OnInit { void this.data.refresh('serviceBindings'); this.cardTwoFetching$ = this.applicationService.application$.pipe( - combineLatest( + combineLatestWith( this.applicationService.appSummary$ ), map(([app, appSummary]: [ApplicationData, EntityInfo]) => { @@ -126,7 +126,7 @@ export class BuildTabComponent implements OnInit { }), distinct()); this.sshStatus$ = this.applicationService.application$.pipe( - combineLatest(this.applicationService.appSpace$), + combineLatestWith(this.applicationService.appSpace$), map(([app, space]) => { if (!space?.allowSsh) { return 'Disabled by the space'; @@ -159,10 +159,10 @@ export class BuildTabComponent implements OnInit { switchMap(repoResource => repoResource.waitForValue$.pipe(catchError(() => EMPTY))) ); - const deploySource$ = observableCombineLatest( + const deploySource$ = observableCombineLatest([ this.applicationService.applicationStratProject$, this.applicationService.application$ - ).pipe( + ]).pipe( map(([project, app]): DeploySourceView | null => { if (project) { const deploySource: CustomEnvVarStratosProjectSource = { ...project.deploySource }; diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/cli-info-application/cli-info-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/cli-info-application/cli-info-application.component.ts index c309177f47..fce4b2c705 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/cli-info-application/cli-info-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/cli-info-application/cli-info-application.component.ts @@ -61,12 +61,12 @@ export class CliInfoApplicationComponent implements OnInit { // Read the wrapped APIResource shapes from the data-service-backed // observables instead so we get name fields without relying on // inline relations. - this.context$ = combineLatest( + this.context$ = combineLatest([ this.applicationService.application$, this.applicationService.appSpace$, this.applicationService.appOrg$, endpoint$, - ).pipe( + ]).pipe( filter(([app, space, org, ep]) => !!app && !!space && !!org && !!ep), map(([app, space, org, ep]: [any, any, any, EndpointModel]) => ({ appName: app.app.entity.name, diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-options-step/deploy-application-options-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-options-step/deploy-application-options-step.component.ts index 7a6d79826c..f294fdee55 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-options-step/deploy-application-options-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-options-step/deploy-application-options-step.component.ts @@ -224,7 +224,7 @@ export class DeployApplicationOptionsStepComponent implements OnInit, OnDestroy // overrides we want to preseed. this.appGuid = this.activatedRoute.snapshot.queryParams.appGuid; if (this.appGuid) { - combineLatest(this.domains$, cfDetails$).pipe( + combineLatest([this.domains$, cfDetails$]).pipe( switchMap(([, cfDetails]) => this.http.get( `/pp/v1/cf/apps/${cfDetails.cloudFoundry}/${this.appGuid}/env`, )), diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts index ae7b039a1c..1fb43f8019 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts @@ -270,11 +270,11 @@ export class DeployApplicationStep2Component }; ngOnInit() { - this.sourceType$ = combineLatest( + this.sourceType$ = combineLatest([ this.appDeploySourceTypes.getAutoSelectedType(this.route), this.sourceType$$, this.appDeploySourceTypes.types$.pipe(take(1), map(st => st[this.INITIAL_SOURCE_TYPE])) - ).pipe( + ]).pipe( map(([sourceFromParam, sourceFromStore, sourceDefault]) => sourceFromParam || sourceFromStore || sourceDefault), filter(sourceType => !!sourceType), ); @@ -377,12 +377,12 @@ export class DeployApplicationStep2Component refCount() ); - const updateBranchAndCommit = observableCombineLatest( + const updateBranchAndCommit = observableCombineLatest([ this.repositoryBranches$, deployBranchName$, this.projectInfo$, deployCommit$, - ).pipe( + ]).pipe( tap(([branches, name, projectInfo, commit]) => { const branch = branches.find(b => b.name === name); if (branch && !!projectInfo && branch.projectName === projectInfo.full_name) { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.ts index f751afda7c..34f31c3973 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.ts @@ -113,7 +113,7 @@ export class DeployApplicationStep3Component implements OnDestroy { ); this.subscriptions.push( - observableCombineLatest(this.validSubject, status$, this.appNotFoundSubject).pipe( + observableCombineLatest([this.validSubject, status$, this.appNotFoundSubject]).pipe( map(([validated, status, appNotFound]) => validated || status.error || appNotFound) ).subscribe(v => this.closeableSubject.next(v)) ); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts index 79eef89c8d..c676b96738 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts @@ -282,10 +282,10 @@ export function canUpdateOrgSpaceRoles( cfGuid: string, orgGuid?: string, spaceGuid?: string): Observable { - return combineLatest( + return combineLatest([ perms.can(CfCurrentUserPermissions.ORGANIZATION_CHANGE_ROLES, cfGuid, orgGuid), perms.can(CfCurrentUserPermissions.SPACE_CHANGE_ROLES, cfGuid, orgGuid, spaceGuid) - ).pipe( + ]).pipe( map((checks: boolean[]) => checks.some(check => check)) ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cli-info-cloud-foundry/cli-info-cloud-foundry.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cli-info-cloud-foundry/cli-info-cloud-foundry.component.ts index 3c6289374c..122f60cc79 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cli-info-cloud-foundry/cli-info-cloud-foundry.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cli-info-cloud-foundry/cli-info-cloud-foundry.component.ts @@ -136,11 +136,11 @@ export class CliInfoCloudFoundryComponent implements OnInit { const space$ = spaceGuid && this.cfSpaceService ? toObservable(this.cfSpaceService.spaceDataService.space, { injector: this.injector }) : observableOf(null); - this.endpointOrgSpace$ = combineLatest( + this.endpointOrgSpace$ = combineLatest([ this.cfEndpointService.endpoint$, org$, space$ - ); + ]); this.context$ = this.endpointOrgSpace$.pipe( map(([cf, org, space]) => { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.ts index 5379469d09..eca0bc2e37 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.ts @@ -164,7 +164,7 @@ export class CloudFoundrySpaceService { this.quotaDefinition$ = this.spaceQuotaDefinition$.pipe( switchMap(def => def ? of(def) : this.cfOrgService.quotaDefinition$.pipe(map(q => q ?? null))), ); - this.quotaLink$ = combineLatest(this.quotaDefinition$, this.spaceQuotaDefinition$).pipe( + this.quotaLink$ = combineLatest([this.quotaDefinition$, this.spaceQuotaDefinition$]).pipe( map(([_quota, spaceQuota]) => { if (!spaceQuota) { return [ diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts index 73ab12ddeb..8a95824207 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-space-base/cloud-foundry-space-base.component.ts @@ -182,10 +182,10 @@ export class CloudFoundrySpaceBaseComponent implements OnInit { // stays the same shape. take(1) preserves the original "freeze first // populated value" behaviour. const orgSignal$ = toObservable(cfOrgService.orgDataService.org, { injector: this.injector }); - this.breadcrumbs$ = combineLatest( + this.breadcrumbs$ = combineLatest([ cfEndpointService.endpoint$, orgSignal$.pipe(filter(org => !!org)), - ).pipe( + ]).pipe( map(([endpoint, org]) => ([ { breadcrumbs: [ diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/user-invites/user-invite.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/user-invites/user-invite.service.ts index 4acd4ba4a0..26d696aa08 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/user-invites/user-invite.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/user-invites/user-invite.service.ts @@ -145,10 +145,10 @@ export class UserInviteService { map(v => !!v.entity.metadata && v.entity.metadata.userInviteAllowed === 'true') ); - this.canConfigure$ = combineLatest( + this.canConfigure$ = combineLatest([ waitForCFPermissions(this.cfRoles, this.activeRouteCfOrgSpace.cfGuid), toObservable(this.authData.sessionData) - ).pipe( + ]).pipe( map(([cf, sessionData]) => cf.global.isAdmin && !!sessionData?.['plugin-config'] && sessionData['plugin-config'].userInvitationsEnabled === 'true') @@ -160,7 +160,7 @@ export class UserInviteService { canShowInviteUser(cfGuid: string, orgGuid: string, spaceGuid: string): Observable { // Can only invite someone to an org or space and user must be admin or org manager return !orgGuid ? observableOf(false) : waitForCFPermissions(this.cfRoles, cfGuid).pipe( - switchMap(() => combineLatest( + switchMap(() => combineLatest([ this.configured$, this.currentUserPermissionsService.can( CfCurrentUserPermissions.ORGANIZATION_CHANGE_ROLES, @@ -168,7 +168,7 @@ export class UserInviteService { orgGuid, spaceGuid ) - )), + ])), map(([configured, canChangeRoles]) => !!configured && !!canChangeRoles) ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts index ecf1188eac..53174ae59c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts @@ -3,7 +3,7 @@ import { Injectable, Injector, inject } from '@angular/core'; import { combineLatest, EMPTY, from, Observable, of as observableOf, of } from 'rxjs'; import { take, catchError, - combineLatest as combineLatestOperators, + combineLatestWith, distinctUntilChanged, expand, filter, @@ -79,7 +79,7 @@ export class CfRolesService { // Create an observable containing the original list of organisations and a corresponding list of whether an org can be edited. // An empty list must still emit, or the caller waits forever. switchMap(orgsOrSpaces => { - return combineLatest( + return combineLatest([ observableOf(orgsOrSpaces), combineLatestOrEmpty(orgsOrSpaces.map(orgOrSpace => CfRolesService.canEditOrgOrSpace( userPerms, @@ -89,7 +89,7 @@ export class CfRolesService { orgOrSpace.entity.cfGuid ?? cfGuid, isOrg ? orgOrSpace.metadata.guid : ((orgOrSpace as APIResource).entity.organization_guid), isOrg ? CfUserPermissionsChecker.ALL_SPACES : orgOrSpace.metadata.guid, - )))); + )))]); }), // Filter out orgs than the current user cannot edit map(([orgs, canEdit]) => orgs.filter(org => { @@ -122,7 +122,7 @@ export class CfRolesService { // replay the last value it pushed, which can be the previous users.) this.users$ = observeSignal(this.rolesData.users, this.injector); this.existingRoles$ = this.users$.pipe( - combineLatestOperators(observeSignal(this.rolesData.cfGuid, this.injector)), + combineLatestWith(observeSignal(this.rolesData.cfGuid, this.injector)), filter(([_users, cfGuid]) => !!cfGuid), switchMap(([users, cfGuid]) => this.populateRoles(cfGuid, users)), distinctUntilChanged(), @@ -134,7 +134,7 @@ export class CfRolesService { ); this.loading$ = this.existingRoles$.pipe( - combineLatestOperators(this.newRoles$), + combineLatestWith(this.newRoles$), map(([existingRoles, newRoles]) => !existingRoles || !newRoles), startWith(true), ); @@ -211,7 +211,7 @@ export class CfRolesService { */ createRolesDiff(orgGuid: string): Observable { return this.existingRoles$.pipe( - combineLatestOperators( + combineLatestWith( this.newRoles$, this.users$, ), diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts index 14f59a5267..8620e80e03 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.ts @@ -82,10 +82,10 @@ export class CardCfRecentAppsComponent implements OnInit { // Stats are loaded by each row (CompactAppCardComponent), once per // started app; the registry shares the request between rows. - this.recentApps$ = combineLatest( + this.recentApps$ = combineLatest([ allApps$, this.maxRows$, - ).pipe( + ]).pipe( filter(([apps]) => !!apps), map(([apps, maxRows]) => this.restrictApps(apps, maxRows)), ); diff --git a/src/frontend/packages/cloud-foundry/src/features/service-catalog/services-helper.ts b/src/frontend/packages/cloud-foundry/src/features/service-catalog/services-helper.ts index 7041af1ae7..bfb2e7501c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/service-catalog/services-helper.ts +++ b/src/frontend/packages/cloud-foundry/src/features/service-catalog/services-helper.ts @@ -1,6 +1,6 @@ import { ActivatedRoute } from '@angular/router'; import { Observable, of as observableOf } from 'rxjs'; -import { take, combineLatest, filter, map } from 'rxjs/operators'; +import { take, combineLatestWith, filter, map } from 'rxjs/operators'; import { StServicePlan, StServicePlanVisibility } from '../../services/endpoint-data/stratos-types'; @@ -154,7 +154,7 @@ export const getServicePlanAccessibility = ( const safeServiceBroker$ = serviceBroker$.pipe(filter(sb => !!sb)); const safeServicePlanVisibilities$ = servicePlanVisibilities$.pipe(filter(spv => !!spv)); return safeServiceBroker$.pipe( - combineLatest(safeServicePlanVisibilities$), + combineLatestWith(safeServicePlanVisibilities$), map(([serviceBroker, allServicePlanVisibilities]) => getSvcAvailability(servicePlan, serviceBroker, allServicePlanVisibilities)) ); }; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts index 62db79b224..77c282e418 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts @@ -170,9 +170,9 @@ export class SelectPlanStepComponent implements OnDestroy { refCount(), ); - this.selectedPlan$ = observableCombineLatest( + this.selectedPlan$ = observableCombineLatest([ this.stepperForm.statusChanges.pipe(startWith(true)), - this.servicePlans$).pipe( + this.servicePlans$]).pipe( filter(([, servicePlans]) => !!servicePlans && servicePlans.length > 0), map(([, servicePlans]) => { return servicePlans.find(s => s.guid === this.stepperForm.controls.servicePlans.value); diff --git a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.ts b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.ts index bbedebe31e..1480b4148b 100644 --- a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.ts +++ b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.ts @@ -323,10 +323,10 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker private applyAdminCheck(check$: Observable, endpointGuid?: string): Observable { const adminCheck$ = this.getAdminChecks(endpointGuid); const readOnlyCheck$ = this.getReadOnlyChecks(endpointGuid); - return combineLatest( + return combineLatest([ adminCheck$, readOnlyCheck$ - ).pipe( + ]).pipe( distinctUntilChanged(), switchMap(([isAdmin, isReadOnly]) => { if (isAdmin) { diff --git a/src/frontend/packages/core/src/core/endpoints.service.ts b/src/frontend/packages/core/src/core/endpoints.service.ts index 5288b19d76..5eb7e3e1bf 100644 --- a/src/frontend/packages/core/src/core/endpoints.service.ts +++ b/src/frontend/packages/core/src/core/endpoints.service.ts @@ -264,10 +264,10 @@ export const endpointsGuard: CanActivateFn = ( // Reroute user to endpoint/no endpoint screens if there are no connected or registered endpoints. // Auth + endpoint loading state both sourced from signal services, via // observables built once there (toObservable() here leaked per run). - const guardLogic$ = observableCombineLatest( + const guardLogic$ = observableCombineLatest([ authSignals.auth$, endpointStatusSignals.status$ - ).pipe( + ]).pipe( filter(([state, endpointState]) => { // Only proceed when logged in and endpoints are done loading return !!state && state.loggedIn && !endpointState.loading; diff --git a/src/frontend/packages/core/src/core/user-profile.service.ts b/src/frontend/packages/core/src/core/user-profile.service.ts index 1d1a21539c..e9f1fbea3c 100644 --- a/src/frontend/packages/core/src/core/user-profile.service.ts +++ b/src/frontend/packages/core/src/core/user-profile.service.ts @@ -111,10 +111,10 @@ export class UserProfileService { const didChangePassword = !!(profileChanges.newPassword && profileChanges.currentPassword); const profileObs$ = didChangeProfile ? this.updateProfileInfo(profile, profileChanges) : observableOf(getDefaultActionState()); const passwordObs$ = didChangePassword ? this.updatePassword(profile, profileChanges) : observableOf(getDefaultActionState()); - return combineLatest( + return combineLatest([ profileObs$, passwordObs$ - ); + ]); } private updateProfileInfo(profile: UserProfileInfo, profileChanges: UserProfileInfoUpdates): Observable { diff --git a/src/frontend/packages/core/src/features/endpoints/connect.service.ts b/src/frontend/packages/core/src/features/endpoints/connect.service.ts index 1dbf46ae9f..684538f946 100644 --- a/src/frontend/packages/core/src/features/endpoints/connect.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/connect.service.ts @@ -201,10 +201,10 @@ export class ConnectEndpointService { } private setupCombinedObservables() { - this.isBusy$ = combineLatest( + this.isBusy$ = combineLatest([ this.connecting$.pipe(startWith(false)), this.fetchingInfo$.pipe(startWith(false)) - ).pipe( + ]).pipe( map(([connecting, fetchingInfo]) => connecting || fetchingInfo), ); } diff --git a/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts b/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts index a34335a9e4..031a2848bd 100644 --- a/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts +++ b/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts @@ -53,10 +53,10 @@ export class MetricsEndpointDetailsComponent extends EndpointListDetailsComponen ); // Raw endpoint data for this metrics endpoint - this.data$ = combineLatest( + this.data$ = combineLatest([ endpoints$, guid$ - ).pipe( + ]).pipe( map(([endpoints, guid]) => endpoints.find((item) => item.provider.guid === guid)), filter(provider => !!provider), filter(data => data.provider.connectionStatus === 'connected'), diff --git a/src/frontend/packages/core/src/shared/components/date-time/date-time.component.ts b/src/frontend/packages/core/src/shared/components/date-time/date-time.component.ts index 0f5bed3b48..b5a7c2787f 100644 --- a/src/frontend/packages/core/src/shared/components/date-time/date-time.component.ts +++ b/src/frontend/packages/core/src/shared/components/date-time/date-time.component.ts @@ -49,10 +49,10 @@ export class DateTimeComponent implements OnDestroy { private setupInputSub() { this.stopInputSub(); - this.sub = combineLatest( + this.sub = combineLatest([ this.timeObservable, this.dateObservable - ).pipe( + ]).pipe( debounceTime(250), filter(([time, date]) => !!(time && date)), map(([time, date]: [string, string]): [number, number, Date] => { diff --git a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts index 5c9c81de87..b614d54c17 100644 --- a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts +++ b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts @@ -13,7 +13,7 @@ import { import { buffer, catchError, - combineLatest, + combineLatestWith, distinctUntilChanged, filter, map, @@ -204,7 +204,7 @@ export class LogViewerComponent implements OnInit, OnDestroy { // When we resize the window, we need to re-enable auto-scroll - if the height changes // we will determine that the user scrolled off the bottom, when in fact this is due to the resize event this.resizeSub = observableFromEvent(window, 'resize').pipe( - combineLatest(this.isLocked$)) + combineLatestWith(this.isLocked$)) .subscribe(([_event, locked]) => { if (locked) { this.scrollToBottom(); @@ -222,7 +222,7 @@ export class LogViewerComponent implements OnInit, OnDestroy { startWith(false)); const buffer$ = observableInterval().pipe( - combineLatest(this.isHighThroughput$), + combineLatestWith(this.isHighThroughput$), throttle(([_t, high]) => { return observableInterval( high ? this.highThroughputBufferIntervalMS : 0 @@ -303,7 +303,7 @@ export class LogViewerComponent implements OnInit, OnDestroy { }) ); - this.listeningSub = observableCombineLatest(this.isLocked$, addedLogs$).pipe( + this.listeningSub = observableCombineLatest([this.isLocked$, addedLogs$]).pipe( tap(([isLocked, _logs]) => { try { if (isLocked) { diff --git a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.ts b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.ts index 43bc0a1455..466314dec5 100644 --- a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.ts +++ b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.ts @@ -104,7 +104,7 @@ export class MetaCardComponent { const nonSeparators = actionMenu .filter(menuItem => !menuItem.separator) - .map(menuItem => menuItem.can); + .map(menuItem => menuItem.can ?? of(true)); this.showMenu$ = combineLatest(nonSeparators).pipe( map(cans => cans.some(can => can)) ); diff --git a/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts b/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts index 6f2a32d4d6..884392e939 100644 --- a/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts +++ b/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts @@ -54,10 +54,10 @@ export class PageHeaderEventsComponent implements OnInit { this.endpointIds$ = observableOf([this.endpointId]); } if (this.endpointIds$) { - this.events$ = combineLatest( + this.events$ = combineLatest([ this.eventService.events$, this.endpointIds$, - ).pipe( + ]).pipe( map(([events, endpointIds]) => { const filteredEvents = events.filter(event => { // Is it an error of type endpoint? diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts index 68c0518192..e9e3ecfd95 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts @@ -180,7 +180,7 @@ export class ChartValuesEditorComponent implements OnInit, OnDestroy, AfterViewI ); // We need the schame, value sand the monaco editor to be all loaded before we're ready - this.loading$ = combineLatest(schema$, values$, this.monacoLoaded$).pipe( + this.loading$ = combineLatest([schema$, values$, this.monacoLoaded$]).pipe( filter(([schema, values, loaded]: [any, any, boolean]) => schema !== undefined && values !== undefined && loaded), tap(([schema, values, _loaded]: [any, any, boolean]) => { this.schema = schema; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts index 6753b366a3..65caa8235c 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts @@ -248,7 +248,7 @@ export class HelmReleaseHelperService { } public hasUpgrade(returnLatest = false): Observable { - const updates = combineLatest(this.getCharts(), this.release$); + const updates = combineLatest([this.getCharts(), this.release$]); return updates.pipe( map(([charts, release]: [any, any]) => { let score = -1; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts index 8318517260..7f2bbe0cad 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts @@ -133,10 +133,10 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { ngOnInit() { // Listen for the graph - this.graph = combineLatest( + this.graph = combineLatest([ this.helper.fetchReleaseGraph(), this.analysisReportUpdated$ - ).subscribe(([g, report]: [HelmReleaseGraph, any]) => { + ]).subscribe(([g, report]: [HelmReleaseGraph, any]) => { const newNodes: CustomHelmReleaseGraphNode[] = []; Object.values(g.nodes).forEach((node: HelmReleaseGraphNode) => { const colors = this.getColor(node.data.status); diff --git a/src/frontend/packages/store/src/helpers/store-helpers.ts b/src/frontend/packages/store/src/helpers/store-helpers.ts index ba3077ce9d..e4879a5ced 100644 --- a/src/frontend/packages/store/src/helpers/store-helpers.ts +++ b/src/frontend/packages/store/src/helpers/store-helpers.ts @@ -29,10 +29,10 @@ export class MultiActionListEntity { export function getFavoriteInfoObservable(userFavorites: UserFavoritesDataService): Observable { - return combineLatest( + return combineLatest([ userFavorites.fetching$, userFavorites.error$ - ).pipe( + ]).pipe( map(([fetching, error]) => ({ fetching, error diff --git a/src/frontend/packages/store/src/user-favorite-manager.ts b/src/frontend/packages/store/src/user-favorite-manager.ts index 2026aa15be..bc72bec14b 100644 --- a/src/frontend/packages/store/src/user-favorite-manager.ts +++ b/src/frontend/packages/store/src/user-favorite-manager.ts @@ -46,19 +46,19 @@ export class UserFavoriteManager { const waitForFavorites$ = this.getWaitForFavoritesObservable(); const favoriteGroups$ = this.userFavorites.groups$; const favoriteEntities$ = this.getFavoriteEntitiesObservable(); - const combined$ = combineLatest( + const combined$ = combineLatest([ favoriteGroups$, favoriteEntities$ - ); + ]); return waitForFavorites$ .pipe(switchMap(() => combined$)); } private getWaitForFavoritesObservable() { - return combineLatest( + return combineLatest([ this.userFavorites.fetching$, this.userFavorites.error$ - ).pipe( + ]).pipe( tap(([fetching, error]) => { // Defensive: Log error details before throwing if (error) { From c25b595e787a45918d40f001ef914e8e28ffecca Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:08:40 -0700 Subject: [PATCH 05/13] refactor(frontend): replace toPromise with firstValueFrom Observable.toPromise() is deprecated in RxJS 7 and removed in RxJS 8. - StratosBrandingService loads /assets/company-config.json, then /assets/config/company.json, then falls back to the built-in default. Both requests are HttpClient GETs, which emit once or error, so firstValueFrom() resolves and rejects exactly as toPromise() did. New specs flush each of the three outcomes through HttpTestingController and check the company name that results; they passed before the change and still pass. - LoginPageComponent reads the current auth state once before an auto-redirect. auth$ comes from toObservable() and can complete without a value if the page is torn down mid-redirect; the code already returns early on undefined. firstValueFrom() with defaultValue: undefined keeps that, where a bare firstValueFrom() would reject with EmptyError. It also takes the first value itself, so the take(1) goes. --- .../login/login-page/login-page.component.ts | 4 +- .../theme/stratos-branding.service.spec.ts | 55 ++++++++++++++++++- .../theme/stratos-branding.service.ts | 5 +- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/core/src/features/login/login-page/login-page.component.ts b/src/frontend/packages/core/src/features/login/login-page/login-page.component.ts index 1c1ff66f72..4b92ec6998 100644 --- a/src/frontend/packages/core/src/features/login/login-page/login-page.component.ts +++ b/src/frontend/packages/core/src/features/login/login-page/login-page.component.ts @@ -4,7 +4,7 @@ import { NgForm, FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { AuthState, RouterRedirect } from '@stratosui/store'; -import { Observable, combineLatest, BehaviorSubject } from 'rxjs'; +import { Observable, combineLatest, BehaviorSubject, firstValueFrom } from 'rxjs'; import { map, startWith, distinctUntilChanged, shareReplay, filter, tap, switchMap, take } from 'rxjs/operators'; import { StratosBrandingService } from '../../../../../theme/stratos-branding.service'; @@ -237,7 +237,7 @@ export class LoginPageComponent implements OnInit { this.navigationInProgress$.next(true); try { // Get current auth state - const auth = await this.auth$.pipe(take(1)).toPromise(); + const auth = await firstValueFrom(this.auth$, { defaultValue: undefined }); if (!auth) { // auth$ only completes without a value if the page is torn down mid-redirect return; diff --git a/src/frontend/packages/theme/stratos-branding.service.spec.ts b/src/frontend/packages/theme/stratos-branding.service.spec.ts index b74fe34ad4..b76997925e 100644 --- a/src/frontend/packages/theme/stratos-branding.service.spec.ts +++ b/src/frontend/packages/theme/stratos-branding.service.spec.ts @@ -1,8 +1,9 @@ import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CompanyConfig } from './company-config.interface'; import { StratosBrandingService } from './stratos-branding.service'; describe('StratosBrandingService', () => { @@ -26,4 +27,54 @@ describe('StratosBrandingService', () => { TestBed.resetTestingModule(); expect(vi.getTimerCount()).toBe(0); }); + + describe('company config loading', () => { + // loadCompanyConfig() awaits each request, so the next request (or the + // config update) happens on a later microtask than the flush. + const settle = () => new Promise(resolve => setTimeout(resolve)); + const notFound = { status: 404, statusText: 'Not Found' }; + const withName = (name: string) => ({ company: { name } }) as Partial; + + let service: StratosBrandingService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(StratosBrandingService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + http.verify(); + localStorage.removeItem('stratos-company-config'); + }); + + it('applies /assets/company-config.json when it loads', async () => { + http.expectOne('/assets/company-config.json').flush(withName('Acme')); + await settle(); + + expect(service.getCompanyName()).toBe('Acme'); + http.expectNone('/assets/config/company.json'); + }); + + it('falls back to /assets/config/company.json when the first file fails', async () => { + http.expectOne('/assets/company-config.json').flush(null, notFound); + await settle(); + http.expectOne('/assets/config/company.json').flush(withName('Env Co')); + await settle(); + + expect(service.getCompanyName()).toBe('Env Co'); + }); + + it('keeps the default config when neither file loads', async () => { + http.expectOne('/assets/company-config.json').flush(null, notFound); + await settle(); + http.expectOne('/assets/config/company.json').flush(null, notFound); + await settle(); + + expect(service.getCompanyName()).toBe('Stratos'); + }); + }); }); diff --git a/src/frontend/packages/theme/stratos-branding.service.ts b/src/frontend/packages/theme/stratos-branding.service.ts index 755eff5d65..998491c21e 100644 --- a/src/frontend/packages/theme/stratos-branding.service.ts +++ b/src/frontend/packages/theme/stratos-branding.service.ts @@ -1,5 +1,6 @@ import { DestroyRef, Injectable, inject, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; import { StratosTheme, defaultTheme, darkTheme } from './theme.config'; import { CompanyConfig } from './company-config.interface'; import { BUILD_INFO } from '../core/src/environments/build-info'; @@ -195,7 +196,7 @@ export class StratosBrandingService { private async loadCompanyConfig() { try { // Try to load from company-config.json first - const config = await this.http.get('/assets/company-config.json').toPromise(); + const config = await firstValueFrom(this.http.get('/assets/company-config.json')); if (config) { this.setCompanyConfig(config); return; @@ -206,7 +207,7 @@ export class StratosBrandingService { try { // Try to load from environment-specific config - const envConfig = await this.http.get('/assets/config/company.json').toPromise(); + const envConfig = await firstValueFrom(this.http.get('/assets/config/company.json')); if (envConfig) { this.setCompanyConfig(envConfig); return; From a856facd9b708bfbb9503d8511ba411965a5b0db Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:11:34 -0700 Subject: [PATCH 06/13] fix(cf): stop the log stream retrying past its limit The application log stream tab reconnects its web socket with retryWhen(), which RxJS 7 deprecates, and the way it limited the retries had two faults: - take(3) sat on the retry notifier, so the third failure still produced a retry signal. retryWhen() opened a fourth socket on it and then, because the notifier had completed, ended the stream and closed that socket at once. The "failed permanently" handler never ran. - take(3) counted retries over the life of the tab. A successful connection reset the attempt counter used for the back-off and the messages, but not take(3). After two failures, a connection, and one more failure, the stream ended silently, still in the "retrying" state, and stopped reconnecting. Use retry({ count: maxRetries - 1, delay, resetOnSuccess: true }): three attempts in all (the first connection and two retries, 2 s and 4 s apart), counted afresh after each successful connection. The delay callback reports each retry; the third failure reaches the catchError, which marks the stream failed. The retry count comes from retry() itself, so the connectionAttempts field goes. New specs replace the web socket with a fake that counts connections. Before this change, three failures opened 4 sockets instead of 3, and the socket opened after fail, fail, connect, fail was closed at once. Also convert the existing spec in this file from detectChanges() to await fixture.whenStable(). --- .../log-stream-tab.component.spec.ts | 105 +++++++++++++++++- .../log-stream-tab.component.ts | 55 ++++----- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts index 2224e65ca7..d563866958 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts @@ -1,12 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Observable, Subject, Subscription } from 'rxjs'; import { TabNavService } from '@stratosui/core'; import { generateTestApplicationServiceProvider, generateCfStoreModules, ApplicationStateService, ApplicationEnvVarsHelper } from '@test-framework/cf'; +import { ApplicationService } from '../../../../application.service'; import { LogStreamTabComponent } from './log-stream-tab.component'; +// Each subscription to the log stream opens a new fake web socket. `closed` +// records whether the component has since unsubscribed from it. +interface FakeSocket { + socket: Subject; + closed: boolean; +} +const sockets = vi.hoisted(() => [] as FakeSocket[]); +vi.mock('rxjs-websockets', async () => { + const { Subject } = await import('rxjs'); + return { + normalClosureMessage: 'Normal closure', + default: () => new Observable(subscriber => { + const fake: FakeSocket = { socket: new Subject(), closed: false }; + sockets.push(fake); + const sub = fake.socket.subscribe(subscriber); + return () => { + fake.closed = true; + sub.unsubscribe(); + }; + }), + }; +}); + describe('LogStreamTabComponent', () => { let component: LogStreamTabComponent; let fixture: ComponentFixture; @@ -31,10 +56,86 @@ describe('LogStreamTabComponent', () => { fixture = TestBed.createComponent(LogStreamTabComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should be created', () => { expect(component).toBeTruthy(); }); }); + +describe('LogStreamTabComponent reconnection', () => { + let component: LogStreamTabComponent; + let subs: Subscription[]; + + const socket = (i: number) => sockets[i].socket; + // A web socket opens by sending its response function. + const connect = (i: number) => socket(i).next(() => new Subject()); + const fail = (i: number) => socket(i).error(new Error('connection lost')); + + beforeEach(async () => { + sockets.length = 0; + TestBed.configureTestingModule({ + imports: [LogStreamTabComponent], + providers: [ + { provide: ApplicationService, useValue: { cfGuid: 'cf-1', appGuid: 'app-1' } }, + provideZonelessChangeDetection(), + ] + }); + // Render nothing: the specs subscribe to the streams the way the log + // viewer does, so every socket below is one the component opened. + TestBed.overrideComponent(LogStreamTabComponent, { set: { template: '', imports: [] } }); + const fixture = TestBed.createComponent(LogStreamTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.useFakeTimers(); + subs = [component.messages.subscribe(), component.connectionStatus.subscribe()]; + }); + + afterEach(() => { + subs.forEach(s => s.unsubscribe()); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('gives up after three failed attempts, backing off 2s then 4s', () => { + expect(sockets.length).toBe(1); + fail(0); + vi.advanceTimersByTime(1999); + expect(sockets.length).toBe(1); + vi.advanceTimersByTime(1); + expect(sockets.length).toBe(2); + + fail(1); + vi.advanceTimersByTime(4000); + expect(sockets.length).toBe(3); + + fail(2); + vi.advanceTimersByTime(60000); + expect(sockets.length).toBe(3); + expect(component.connectionStatusSignal()).toBe(-1); + }); + + it('starts counting again after a connection succeeds', () => { + fail(0); + vi.advanceTimersByTime(2000); + fail(1); + vi.advanceTimersByTime(4000); + connect(2); + fail(2); + // The first failure after a good connection waits the first back-off + // step again, and the stream keeps the new socket. + vi.advanceTimersByTime(2000); + expect(sockets.length).toBe(4); + expect(sockets[3].closed).toBe(false); + + fail(3); + vi.advanceTimersByTime(4000); + expect(sockets.length).toBe(5); + expect(sockets[4].closed).toBe(false); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts index c451a82b8c..8119668a89 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts @@ -4,7 +4,7 @@ import { NgModel } from '@angular/forms'; import { format } from 'date-fns'; import { EMPTY, NEVER, Observable, Subject, of, timer } from 'rxjs'; import makeWebSocketObservable, { GetWebSocketResponses } from 'rxjs-websockets'; -import { catchError, debounceTime, map, share, startWith, switchMap, tap, retryWhen, delayWhen, take } from 'rxjs/operators'; +import { catchError, debounceTime, map, retry, share, startWith, switchMap, tap, take } from 'rxjs/operators'; import { AnsiColorizer, LogViewerComponent } from '@stratosui/core'; import { ApplicationService } from '../../../../application.service'; @@ -42,7 +42,6 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { private connectionStatusSubject = new Subject(); public connectionStatus!: Observable; private socketError = false; - private connectionAttempts = 0; private maxRetries = 3; private retryDelayMs = 2000; private lastError: ConnectionError | null = null; @@ -92,55 +91,39 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { const socket$ = makeWebSocketObservable(streamUrl).pipe( tap(() => { - // Reset connection tracking on successful connection - this.connectionAttempts = 0; + // Reset error state on successful connection this.socketError = false; this.lastError = null; console.log('WebSocket connected successfully'); this.cdr.markForCheck(); }), - retryWhen(errors => errors.pipe( - tap(error => { - this.connectionAttempts++; + // maxRetries attempts in all: the first connection and maxRetries - 1 + // retries. A successful connection starts the count again. + retry({ + count: this.maxRetries - 1, + resetOnSuccess: true, + delay: (error, retryCount) => { const errorMessage = this.parseWebSocketError(error); - console.warn( - `WebSocket connection attempt ${this.connectionAttempts} failed:`, - errorMessage - ); - + console.warn(`WebSocket connection attempt ${retryCount} failed:`, errorMessage); this.lastError = { message: errorMessage, - retryable: this.connectionAttempts < this.maxRetries, + retryable: true, timestamp: Date.now() }; - - if (this.connectionAttempts >= this.maxRetries) { - this.socketError = true; - this.connectionStatusSubject.next(-1); - this.connectionStatusSignal.set(-1); - console.error( - `WebSocket connection failed after ${this.maxRetries} attempts. Reason: ${errorMessage}` - ); - this.cdr.markForCheck(); - } else { - // Inform user of retry attempt - this.connectionStatusSubject.next(-2); // -2 indicates retrying - this.connectionStatusSignal.set(-2); - this.cdr.markForCheck(); - } - }), - delayWhen(() => { - // Exponential backoff: 2s, 4s, 8s - const delay = this.retryDelayMs * Math.pow(2, this.connectionAttempts - 1); + // Inform user of retry attempt + this.connectionStatusSubject.next(-2); // -2 indicates retrying + this.connectionStatusSignal.set(-2); + this.cdr.markForCheck(); + // Exponential backoff: 2s, then 4s + const delay = this.retryDelayMs * Math.pow(2, retryCount - 1); console.log(`Retrying WebSocket connection in ${delay}ms...`); return timer(delay); - }), - take(this.maxRetries) - )), + }, + }), catchError(e => { const errorMessage = this.parseWebSocketError(e); console.error( - 'WebSocket connection failed permanently: ' + errorMessage + `WebSocket connection failed after ${this.maxRetries} attempts. Reason: ${errorMessage}` ); this.socketError = true; this.lastError = { From 64d2a2dcf4d064747ab42ff30100a8b07d3991ca Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:20:02 -0700 Subject: [PATCH 07/13] refactor(frontend): replace publishReplay and refCount with shareReplay publishReplay() and refCount() are deprecated in RxJS 7 and removed in RxJS 8. All 29 uses were the pair publishReplay(1), refCount(). The replacement is not exactly equivalent. publishReplay(1) keeps its replay buffer when the subscriber count drops to zero, so a later subscriber gets the old value at once and the source is subscribed again. shareReplay({ bufferSize: 1, refCount: true }) drops the buffer and starts over. The two only differ for a source that has not completed and whose subscriber count can reach zero and recover. 28 sites use shareReplay({ bufferSize: 1, refCount: true }). At each one the source completes after one value (take(1) or an HTTP call), or a subscriber that lives as long as the owning component keeps the count above zero, or the source replays its current value on subscribe (toObservable, observeSignal, startWith), so a reset buffer is refilled at once. GlobalEventService is the exception. It is a root service; the page-header banner and the events page subscribe to events$ and leave on every navigation, and its event stream is debounced by 100 ms. With refCount: true each new page would see no events (no banner, no unread count) for the first 100 ms. It uses refCount: false, which keeps the subscription and the last value for the life of the app. A new spec subscribes, unsubscribes and subscribes again, and expects the events at once: it passed before this change, fails with refCount: true and passes with refCount: false. A comment in AppDeleteSelectionService that named the publishReplay caches now says shareReplay. --- .../autoscaler-metric-page.component.ts | 5 ++-- ...autoscaler-scale-history-page.component.ts | 5 ++-- .../edit-autoscaler-policy.component.ts | 5 ++-- .../card-autoscaler-default.component.ts | 8 +++--- .../app-delete-selection.service.ts | 2 +- .../applications/application.service.ts | 8 +++--- .../deploy-application-step2.component.ts | 9 +++---- .../deploy-application-steps.types.ts | 11 +++----- .../src/features/cf/cf.helpers.ts | 5 ++-- .../cloud-foundry-endpoint.service.ts | 4 +-- .../manage-users-set-usernames.component.ts | 11 +++----- .../select-plan-step.component.ts | 6 ++--- ...specify-user-provided-details.component.ts | 5 ++-- .../metrics-endpoint-details.component.ts | 5 ++-- .../metrics/services/metrics-service.ts | 11 +++----- .../page-header-events.component.ts | 5 ++-- .../src/shared/global-events.service.spec.ts | 27 +++++++++++++++++++ .../core/src/shared/global-events.service.ts | 9 ++++--- .../tabs/catalog-tab/catalog-tab.component.ts | 5 ++-- .../kubernetes-resource-viewer.component.ts | 5 ++-- .../services/kubernetes-node.service.ts | 5 ++-- .../helm-release-resource-graph.component.ts | 5 ++-- 22 files changed, 81 insertions(+), 80 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts index 0c95813931..b5066dd4c4 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-metric-page/autoscaler-metric-page.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, WritableSignal, computed, inject, signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { Observable } from 'rxjs'; -import { map, publishReplay, refCount } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; import { ApplicationService } from '../../../../cloud-foundry/src/features/applications/application.service'; import { CustomIconComponent, SignalListComponent, SignalListConfig } from '@stratosui/core'; @@ -94,8 +94,7 @@ export class AutoscalerMetricPageComponent implements OnInit { ngOnInit() { this.applicationName$ = this.applicationService.app$.pipe( map(({ entity }) => entity ? entity.entity.name : null), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); void this.chartConfig.loadAll(); this.cdr.markForCheck(); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts index 5d6b9f389c..35edaba189 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-scale-history-page/autoscaler-scale-history-page.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, Writable import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { Observable } from 'rxjs'; -import { map, publishReplay, refCount } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; import { ApplicationService } from '@stratosui/cloud-foundry'; import { CustomIconComponent, PageHeaderModule, SignalListComponent, SignalListConfig, SignalListPillColor } from '@stratosui/core'; @@ -102,8 +102,7 @@ export class AutoscalerScaleHistoryPageComponent implements OnInit { ngOnInit() { this.applicationName$ = this.applicationService.app$.pipe( map(({ entity }) => entity ? entity.entity.name : null), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); void this.eventsConfig.loadAll(); this.cdr.markForCheck(); diff --git a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts index 5c0d18966b..1165826196 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/edit-autoscaler-policy/edit-autoscaler-policy.component.ts @@ -13,7 +13,7 @@ import { import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@stratosui/core'; import { Observable, Subscription, firstValueFrom } from 'rxjs'; -import { map, publishReplay, refCount } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; import { ApplicationService } from '@stratosui/cloud-foundry'; import { @@ -215,8 +215,7 @@ export class EditAutoscalerPolicyComponent implements OnInit, OnDestroy { ngOnInit() { this.applicationName$ = this.applicationService.app$.pipe( map(({ entity }) => entity ? entity.entity.name : null), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.isCreate = this.route.snapshot.queryParams.create; this.cdr.markForCheck(); diff --git a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts index 736097c931..0b1953ccad 100644 --- a/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/shared/card-autoscaler-default/card-autoscaler-default.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Injector, Input, OnInit, ViewChild, computed, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; -import { map, publishReplay, refCount } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; import { ApplicationService, RunningInstancesComponent } from '@stratosui/cloud-foundry'; import { MetadataItemComponent } from '@stratosui/core'; @@ -66,14 +66,12 @@ export class CardAutoscalerDefaultComponent implements OnInit { }); this.appAutoscalerPolicy$ = toObservable(triState, { injector: this.injector }).pipe( - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); this.applicationInstances$ = this.applicationService.app$.pipe( map(({ entity }) => entity ? entity.entity.instances : null), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.cdr.markForCheck(); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/app-delete-selection.service.ts b/src/frontend/packages/cloud-foundry/src/features/applications/app-delete-selection.service.ts index a84e90d2e3..4e126be356 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/app-delete-selection.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/app-delete-selection.service.ts @@ -6,7 +6,7 @@ import type { StRoute, StServiceCredentialBinding } from '../../services/endpoin * Resolved display names the wizard already had on hand when the user * pressed Confirm. The parent's "Are you sure?" dialog uses these * directly instead of racing the data service's observables — by the - * time the parent is recreated post-navigation, its publishReplay + * time the parent is recreated post-navigation, its shareReplay * caches are gone with the old instance, so the race loses and the * dialog renders appGuid + "?" for org/space. */ diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application.service.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application.service.ts index 16ac0ed0e6..efc7157161 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application.service.ts @@ -1,7 +1,7 @@ import { Injectable, computed, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { Observable, of as observableOf } from 'rxjs'; -import { filter, publishReplay, refCount } from 'rxjs/operators'; +import { filter, shareReplay } from 'rxjs/operators'; import { APP_GUID, CF_GUID } from '@stratosui/core'; import { @@ -68,8 +68,7 @@ export class ApplicationService { */ waitForAppEntity$: Observable>> = this.app$.pipe( filter(info => !!info.entity), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); /** @@ -178,8 +177,7 @@ export class ApplicationService { }) ).pipe( filter(data => !!data.app), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); // --------------------------------------------------------------------------- diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts index 1fb43f8019..73a3e982db 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.ts @@ -29,8 +29,7 @@ import { filter, map, pairwise, - publishReplay, - refCount, + shareReplay, startWith, switchMap, take, @@ -310,8 +309,7 @@ export class DeployApplicationStep2Component ]).pipe( filter(([cfGuid, sourceType]) => !!cfGuid && !!sourceType), switchMap(([cfGuid, sourceType]) => this.appDeploySourceTypes.canDeployType(cfGuid, sourceType.id)), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.stepperText$ = this.canDeployType$.pipe( @@ -373,8 +371,7 @@ export class DeployApplicationStep2Component ); }), map(([branches, _branchName]) => branches), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); const updateBranchAndCommit = observableCombineLatest([ diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-steps.types.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-steps.types.ts index 9a0d2b208c..cdd3fb3038 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-steps.types.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-steps.types.ts @@ -3,7 +3,7 @@ import { Injectable, Injector, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { Observable, of } from 'rxjs'; -import { filter, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; +import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { PermissionConfig, CurrentUserPermissionsService } from '@stratosui/core'; import { GitSCM, GitSCMService, GIT_ENDPOINT_SUB_TYPES, GIT_ENDPOINT_TYPE } from '@stratosui/git'; @@ -157,8 +157,7 @@ export class ApplicationDeploySourceTypes { return newTypes; }), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); } @@ -178,15 +177,13 @@ export class ApplicationDeploySourceTypes { // So fetch the feature flags for the cf, which is the blocker, first before checking if we `.can` const fetchedFeatureFlags$ = this.http.get(`/pp/v1/cf/feature_flags/${cfId}`).pipe( map(resp => !!resp), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); const canDeployWithDocker$ = this.perms.can( new PermissionConfig(CfPermissionTypes.FEATURE_FLAG, CFFeatureFlagTypes.diego_docker), cfId ).pipe( - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); return fetchedFeatureFlags$.pipe( diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts index c676b96738..025b13c7b8 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cf.helpers.ts @@ -1,6 +1,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { combineLatest, Observable } from 'rxjs'; -import { take, filter, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; +import { take, filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { CurrentUserPermissionsService, @@ -304,8 +304,7 @@ export function waitForCFPermissions( return cfRoles.cfEndpointRolesState$(cfGuid).pipe( filter(cf => cf && cf.state.initialised), take(1), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts index 1511e89fd3..fe12819e2b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, Signal, computed, inject, signal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EMPTY, Observable, Subscription, from } from 'rxjs'; -import { catchError, take, filter, map, publishReplay, refCount } from 'rxjs/operators'; +import { catchError, take, filter, map, shareReplay } from 'rxjs/operators'; import { of } from 'rxjs'; import { CfInfoDataRegistry } from '../../../services/endpoint-data/cf-info-data.registry'; @@ -242,7 +242,7 @@ export class CloudFoundryEndpointService { this.currentUser$ = this.endpoint$.pipe( map(e => e.entity.user), filter((user): user is EndpointUser => !!user), - take(1), publishReplay(1), refCount()); + take(1), shareReplay({ bufferSize: 1, refCount: true })); } public getAppsInOrgViaAllApps(org: APIResource): Observable[]> { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-set-usernames/manage-users-set-usernames.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-set-usernames/manage-users-set-usernames.component.ts index 6a4ef46ec7..cf4659bd18 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-set-usernames/manage-users-set-usernames.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-set-usernames/manage-users-set-usernames.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest, Observable, of } from 'rxjs'; -import { take, defaultIfEmpty, map, publishReplay, refCount, startWith, switchMap } from 'rxjs/operators'; +import { take, defaultIfEmpty, map, shareReplay, startWith, switchMap } from 'rxjs/operators'; import { AppInputDirective, @@ -86,14 +86,12 @@ export class ManageUsersSetUsernamesComponent implements OnInit { this.canAdd$ = waitForCFPermissions(cfRoles, activeRouteCfOrgSpace.cfGuid).pipe( switchMap(() => userPerms.can(ffSetPermConfig, activeRouteCfOrgSpace.cfGuid)), take(1), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.canRemove$ = waitForCFPermissions(cfRoles, activeRouteCfOrgSpace.cfGuid).pipe( switchMap(() => userPerms.can(ffRemovePermConfig, activeRouteCfOrgSpace.cfGuid)), take(1), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); const canAddRemove = combineLatest([this.canAdd$, this.canRemove$]); @@ -106,8 +104,7 @@ export class ManageUsersSetUsernamesComponent implements OnInit { map(() => false), take(1), startWith(true), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts index 77c282e418..5897475d52 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts @@ -10,8 +10,7 @@ import { distinctUntilChanged, filter, map, - publishReplay, - refCount, + shareReplay, startWith, switchMap, tap, @@ -166,8 +165,7 @@ export class SelectPlanStepComponent implements OnDestroy { } }), map(plans => [...plans].sort((a, b) => naturalCompare(this.getDisplayName(a), this.getDisplayName(b)))), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); this.selectedPlan$ = observableCombineLatest([ diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/specify-user-provided-details/specify-user-provided-details.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/specify-user-provided-details/specify-user-provided-details.component.ts index 3c6c841183..a9856d2965 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/specify-user-provided-details/specify-user-provided-details.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/specify-user-provided-details/specify-user-provided-details.component.ts @@ -23,7 +23,7 @@ import { StatefulIconComponent, safeUnsubscribe, urlValidationExpression, enviro import { AppNameUniqueDirective } from '../../../directives/app-name-unique.directive/app-name-unique.directive'; import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest as obsCombineLatest, Observable, of as observableOf, Subscription, from } from 'rxjs'; -import { take, filter, map, publishReplay, refCount, startWith, switchMap, withLatestFrom } from 'rxjs/operators'; +import { take, filter, map, shareReplay, startWith, switchMap, withLatestFrom } from 'rxjs/operators'; import { IUserProvidedServiceInstanceData } from '../../../../cf-api-svc.types'; import { AppDetailDataService } from '../../../../features/applications/app-detail-data.service'; @@ -337,8 +337,7 @@ export class SpecifyUserProvidedDetailsComponent implements OnDestroy { }); }), startWith(null), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); } diff --git a/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts b/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts index 031a2848bd..bc29a106e1 100644 --- a/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts +++ b/src/frontend/packages/core/src/features/metrics/metrics-endpoint-details/metrics-endpoint-details.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, inject, Input, signal } from '@ang import { toObservable } from '@angular/core/rxjs-interop'; import { EndpointModel } from '@stratosui/store'; import { combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, publishReplay, refCount } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; import { EndpointListDetailsComponent } from '../../../shared/components/endpoint-list/endpoint-list.helpers'; import { mapMetricsData } from '../metrics.helpers'; @@ -61,8 +61,7 @@ export class MetricsEndpointDetailsComponent extends EndpointListDetailsComponen filter(provider => !!provider), filter(data => data.provider.connectionStatus === 'connected'), map((provider) => this.processProvider(provider)), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); } diff --git a/src/frontend/packages/core/src/features/metrics/services/metrics-service.ts b/src/frontend/packages/core/src/features/metrics/services/metrics-service.ts index 5af1f68de4..a80da28893 100644 --- a/src/frontend/packages/core/src/features/metrics/services/metrics-service.ts +++ b/src/frontend/packages/core/src/features/metrics/services/metrics-service.ts @@ -8,7 +8,7 @@ import { getFullEndpointApiUrl, } from '@stratosui/store'; import { Observable } from 'rxjs'; -import { map, publishReplay, refCount } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; export interface MetricsEndpointProvider { provider: EndpointModel; @@ -53,8 +53,7 @@ export class MetricsService { }); return result; }), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); this.haveNoMetricsEndpoints$ = this.endpoints$.pipe( @@ -62,8 +61,7 @@ export class MetricsService { const metrics = endpoints.filter(e => e.cnsi_type === 'metrics'); return metrics.length === 0; }), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); this.haveNoConnectedMetricsEndpoints$ = this.endpoints$.pipe( @@ -72,8 +70,7 @@ export class MetricsService { const connected = metrics.filter(e => !!e.user); return connected.length === 0; }), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); } } diff --git a/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts b/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts index 884392e939..0ac7033b68 100644 --- a/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts +++ b/src/frontend/packages/core/src/shared/components/page-header/page-header-events/page-header-events.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input, OnInit, inject } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { combineLatest, Observable, of as observableOf } from 'rxjs'; -import { take, map, publishReplay, refCount, share } from 'rxjs/operators'; +import { take, map, shareReplay, share } from 'rxjs/operators'; import { endpointEventKey, GlobalEventService, IGlobalEvent } from '../../../global-events.service'; import { CustomIconComponent } from '../../../../shared/components/custom-material/custom-material.component'; @@ -71,8 +71,7 @@ export class PageHeaderEventsComponent implements OnInit { }); return filteredEvents; }), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.errorMessage$ = this.events$.pipe( // Fixme this emits a lot, we should fix this. diff --git a/src/frontend/packages/core/src/shared/global-events.service.spec.ts b/src/frontend/packages/core/src/shared/global-events.service.spec.ts index c75138f777..4c2dbf652d 100644 --- a/src/frontend/packages/core/src/shared/global-events.service.spec.ts +++ b/src/frontend/packages/core/src/shared/global-events.service.spec.ts @@ -25,4 +25,31 @@ describe('GlobalEventsService', () => { const service: GlobalEventService = TestBed.inject(GlobalEventService); expect(service).toBeTruthy(); }); + + describe('events$ across page changes', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // Pages subscribe to events$ (the page-header banner, the events page) + // and unsubscribe when the user navigates away, so the subscriber count + // regularly drops to zero. The next page must get the current events at + // once, not after the event stream's 100 ms debounce. + it('replays the latest events to a subscriber that arrives after all others left', () => { + vi.useFakeTimers(); + const service = TestBed.inject(GlobalEventService); + + let emissions = 0; + const first = service.events$.subscribe(() => emissions++); + TestBed.tick(); + vi.advanceTimersByTime(100); + expect(emissions).toBeGreaterThan(0); + first.unsubscribe(); + + emissions = 0; + const second = service.events$.subscribe(() => emissions++); + expect(emissions).toBe(1); + second.unsubscribe(); + }); + }); }); diff --git a/src/frontend/packages/core/src/shared/global-events.service.ts b/src/frontend/packages/core/src/shared/global-events.service.ts index 4b91941410..07e033d174 100644 --- a/src/frontend/packages/core/src/shared/global-events.service.ts +++ b/src/frontend/packages/core/src/shared/global-events.service.ts @@ -5,7 +5,7 @@ import { } from '@stratosui/store'; import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest, Observable, ReplaySubject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, publishReplay, refCount, startWith } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, shareReplay, startWith } from 'rxjs/operators'; export type GlobalEventTypes = 'warning' | 'error' | 'process' | 'complete'; @@ -312,8 +312,11 @@ export class GlobalEventService { return eventsAndPriority; }, [[], null] as [IGlobalEvent[], GlobalEventTypes | null]); }), - publishReplay(1), - refCount(), + // refCount: false keeps the latest events across the moments when no + // page is subscribed: this is a root service, and every navigation + // drops its subscribers to zero. With refCount: true the next page + // would wait for the 100 ms debounce before it saw any event. + shareReplay({ bufferSize: 1, refCount: false }), ); } diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts index aee8e73c75..701e957151 100644 --- a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts @@ -6,7 +6,7 @@ import { toObservable } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; -import { filter, map, publishReplay, refCount, take } from 'rxjs/operators'; +import { filter, map, shareReplay, take } from 'rxjs/operators'; import { EndpointsSignalService } from '../../../../../core/src/core/signals/endpoints-signal.service'; import { AppInputDirective, CustomFormFieldComponent } from '../../../../../core/src/shared/components/custom-form-field/custom-form-field.component'; @@ -95,8 +95,7 @@ export class CatalogTabComponent implements OnDestroy { } return haveArtifactHub && haveHelmRepo; }), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: true }), ); // Repo-name lists filtered by the sidebar search. diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts index 763a6d9e96..78e00c0951 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts @@ -13,7 +13,7 @@ import { import { RouterLink } from '@angular/router'; import { formatDistance } from 'date-fns'; import { Observable, of } from 'rxjs'; -import { take, filter, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; +import { take, filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { EndpointsService } from '../../../../core/src/core/endpoints.service'; import { ConfirmationDialogConfig } from '../../../../core/src/shared/components/confirmation-dialog.config'; @@ -244,8 +244,7 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, } return resource; }), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.hasPodMetrics$ = props.resourceKind === 'pod' ? diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts index 6520a514b5..745575b92a 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts @@ -2,7 +2,7 @@ import { Injectable, Injector, inject, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; -import { filter, map, publishReplay, refCount, take } from 'rxjs/operators'; +import { filter, map, shareReplay, take } from 'rxjs/operators'; import { getIdFromRoute } from '../../../../core/src/core/utils.service'; import { MetricQueryConfig } from '../../../../store/src/actions/metrics.actions'; @@ -53,8 +53,7 @@ export class KubernetesNodeService { filter((n): n is NonNullable => !!n), take(1), map(n => n as unknown as KubernetesNode), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); this.node$ = this.nodeEntity$.pipe( diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts index 7f2bbe0cad..71e0c3d743 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts @@ -7,7 +7,7 @@ import { CustomTooltipDirective } from '../../../../../../../core/src/shared/com import { Edge, GraphComponent, LayoutService, NgxGraphZoomOptions } from '@swimlane/ngx-graph'; import { SidePanelService } from '@stratosui/core'; import { combineLatest, Observable, Subject, Subscription } from 'rxjs'; -import { take, distinctUntilChanged, filter, map, publishReplay, refCount, startWith } from 'rxjs/operators'; +import { take, distinctUntilChanged, filter, map, shareReplay, startWith } from 'rxjs/operators'; import { PageSubNavComponent } from '../../../../../../../core/src/shared/components/page-sub-nav/page-sub-nav.component'; import { AnalysisReportRunnerComponent } from '../../../../analysis-report-viewer/analysis-report-runner/analysis-report-runner.component'; @@ -113,8 +113,7 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { private analysisReportUpdated$ = this.analysisReportUpdated.pipe( startWith(null), distinctUntilChanged(), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); protected helper = inject(HelmReleaseHelperService); public analyzerService = inject(KubernetesAnalysisService); From 26f7971b529f366428a1c59e78f7c0f780b696a3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:20:59 -0700 Subject: [PATCH 08/13] chore(lint): reject RxJS APIs deprecated in RxJS 7 The previous commits removed every use of the RxJS 7 deprecations listed below and of NgZone and HttpClientModule. Two ESLint rules, both at "error", stop them coming back: - no-restricted-imports: publishReplay, refCount, publish, publishLast, publishBehavior, multicast and retryWhen from rxjs or rxjs/operators; the combineLatest operator from rxjs/operators; HttpClientModule; NgZone. - no-restricted-syntax: .toPromise(); subscribe() with more than one argument; combineLatest() or observableCombineLatest() with more than one argument; throwError() whose argument is not an inline function. typescript-eslint's no-deprecated rule would catch these without a list, but it needs type-aware linting and also reports hundreds of other deprecations (NoopAnimationsModule, RouterTestingModule and others) that are outside this change. The throwError rule cannot tell a function variable from an error value, so write-with-job.ts now passes its abort error factory as throwError(() => abortError()) instead of throwError(abortError). Behaviour is the same. Add src/frontend/packages/theme/ to the lint paths. It was not linted before, and lints clean. --- eslint.config.mjs | 49 +++++++++++++++++++ package.json | 2 +- .../src/services/async-jobs/write-with-job.ts | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1621135919..925d48b42f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -69,6 +69,55 @@ export default tseslint.config( // A snackbar service injected but never called is dead weight that // misleads readers about what the component can do (#5603). "stratos/no-unused-snackbar-service": "error", + + // APIs deprecated in RxJS 7 (removed in RxJS 8) and Angular APIs the + // zoneless app has no use for. All call sites were replaced in #6022; + // these keep them from coming back. + "no-restricted-imports": ["error", { paths: [ + ...["rxjs", "rxjs/operators"].map(name => ({ + name, + importNames: ["publishReplay", "refCount", "publish", "publishLast", "publishBehavior", "multicast"], + message: "Deprecated in RxJS 7: use shareReplay({ bufferSize: 1, refCount: true }), share() or connectable().", + })), + ...["rxjs", "rxjs/operators"].map(name => ({ + name, + importNames: ["retryWhen"], + message: "Deprecated in RxJS 7: use retry({ count, delay }).", + })), + { + name: "rxjs/operators", + importNames: ["combineLatest"], + message: "Deprecated in RxJS 7: use the combineLatestWith operator.", + }, + { + name: "@angular/common/http", + importNames: ["HttpClientModule"], + message: "Deprecated: the app configures HttpClient with provideHttpClient() in AppModule.", + }, + { + name: "@angular/core", + importNames: ["NgZone"], + message: "The app is zoneless: NgZone.run() and runOutsideAngular() only call their callback.", + }, + ] }], + "no-restricted-syntax": ["error", + { + selector: "CallExpression[callee.property.name='toPromise']", + message: "Deprecated in RxJS 7: use firstValueFrom() or lastValueFrom().", + }, + { + selector: "CallExpression[callee.property.name='subscribe'][arguments.length>1]", + message: "Deprecated in RxJS 7: pass one observer, subscribe({ next, error, complete }).", + }, + { + selector: "CallExpression[callee.name=/^(combineLatest|observableCombineLatest)$/][arguments.length>1]", + message: "Deprecated in RxJS 7: pass the sources as one array, combineLatest([a, b]).", + }, + { + selector: "CallExpression[callee.name='throwError'][arguments.0.type!=/^(ArrowFunctionExpression|FunctionExpression)$/]", + message: "Deprecated in RxJS 7: pass an inline error factory, throwError(() => error).", + }, + ], }, }, { diff --git a/package.json b/package.json index ebb60f2c99..ba602668cb 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test-frontend:kubernetes": "vitest run --project=kubernetes", "test-frontend:git": "vitest run --project=git", "posttest": "echo 'Coverage reports in coverage/frontend/ directory'", - "lint": "eslint --report-unused-disable-directives-severity warn src/frontend/packages/{core,store,cloud-foundry,kubernetes,cf-autoscaler,git,shared,extension}/src/ e2e/ && bun test --config=scripts/tests/bunfig.toml tools/eslint-rules/tests scripts/tests && bun scripts/lint-e2e-data-test.mjs && bun scripts/lint-e2e-ratchet.mjs && bun scripts/lint-nested-pins.mjs", + "lint": "eslint --report-unused-disable-directives-severity warn src/frontend/packages/{core,store,cloud-foundry,kubernetes,cf-autoscaler,git,shared,extension}/src/ src/frontend/packages/theme/ e2e/ && bun test --config=scripts/tests/bunfig.toml tools/eslint-rules/tests scripts/tests && bun scripts/lint-e2e-data-test.mjs && bun scripts/lint-e2e-ratchet.mjs && bun scripts/lint-nested-pins.mjs", "lint:docs": "bun scripts/lint-docs.mjs", "e2e": "playwright test", "e2e:ui": "playwright test --ui", diff --git a/src/frontend/packages/cloud-foundry/src/services/async-jobs/write-with-job.ts b/src/frontend/packages/cloud-foundry/src/services/async-jobs/write-with-job.ts index e0bec97fad..e1233bed9c 100644 --- a/src/frontend/packages/cloud-foundry/src/services/async-jobs/write-with-job.ts +++ b/src/frontend/packages/cloud-foundry/src/services/async-jobs/write-with-job.ts @@ -181,7 +181,7 @@ function settle(source: Observable, signal: AbortSignal | undefined): Prom return Promise.reject(abortError()); } const aborted$ = fromEvent(signal, 'abort').pipe( - switchMap(() => throwError(abortError)), + switchMap(() => throwError(() => abortError())), ); return firstValueFrom(race(source, aborted$)); } From 494c5d94a07fa9586d2fd24a47b89a357880510a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 12:32:24 -0700 Subject: [PATCH 09/13] docs(changelog): RxJS 7 deprecation sweep --- changelog.d/0021-rxjs7-deprecations.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 changelog.d/0021-rxjs7-deprecations.md diff --git a/changelog.d/0021-rxjs7-deprecations.md b/changelog.d/0021-rxjs7-deprecations.md new file mode 100644 index 0000000000..3889a0f4c3 --- /dev/null +++ b/changelog.d/0021-rxjs7-deprecations.md @@ -0,0 +1,14 @@ +[BugFixes] +- The application log stream tab now stops after three failed + connection attempts instead of opening a fourth connection and + closing it at once, and it starts counting again after a successful + connection. Before, a stream that connected and later dropped could + stop reconnecting. + +[Maintainability] +- Replaced the RxJS APIs deprecated in RxJS 7 (publishReplay/refCount, + retryWhen, toPromise, multi-argument combineLatest and subscribe, + the combineLatest operator, throwError with a value), the deprecated + HttpClientModule, and the NgZone wrappers that do nothing in the + zoneless app. Lint rules now reject them, and the theme package is + linted. From ec4076bf32257b429d497c895380a17c847e8ae3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 16:07:04 -0700 Subject: [PATCH 10/13] fix(cf): show the log stream's reconnect status The application Log Stream tab never told the user it was retrying or that it had given up. The log viewer already has messages for both, but the status the tab passes it only ever said "connecting" and then "connected". The retrying and failed codes went to a subject and a signal that nothing read, so after a failed connection the tab said "Connecting to log stream..." for good. The subject has been write-only since 2019, when the status passed to the viewer became a value derived from the socket. The retry and its codes came later, in 2025, written to that subject. Pass the subject itself to the viewer, debounced as before so the "connecting" message does not flash. The tab now shows, in turn: - "Connecting to log stream..." - "Connection lost. Retrying (attempt 1/2)..." and "(attempt 2/2)..." - "Unable to connect to log stream. Please check that the backend service is running and try refreshing the page." and clears the message once connected. The retry count starts again after each good connection. The viewer counted retries out of a fixed 5; it now takes the number as an input, which the tab sets to its own 2. Its message sits in a role="status" region so screen readers announce it, and errors show in the danger colour. The unread signal goes. --- .../log-stream-tab.component.html | 3 +- .../log-stream-tab.component.spec.ts | 79 ++++++++++++++++++- .../log-stream-tab.component.ts | 49 +++--------- .../log-viewer/log-viewer.component.html | 6 +- .../log-viewer/log-viewer.component.ts | 4 +- 5 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.html index 7af6fbd3cf..1ba2855008 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.html @@ -3,5 +3,6 @@ [filter]="filter" class="app-logstream-viewer flex flex-1 min-h-0" [logStream]="messages" - [status]="connectionStatus"> + [status]="connectionStatus" + [maxReconnectAttempts]="maxRetries - 1">
diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts index d563866958..c295e6efec 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.spec.ts @@ -67,6 +67,7 @@ describe('LogStreamTabComponent', () => { describe('LogStreamTabComponent reconnection', () => { let component: LogStreamTabComponent; let subs: Subscription[]; + let lastStatus: number | undefined; const socket = (i: number) => sockets[i].socket; // A web socket opens by sending its response function. @@ -93,7 +94,8 @@ describe('LogStreamTabComponent reconnection', () => { vi.spyOn(console, 'warn').mockImplementation(() => { }); vi.spyOn(console, 'error').mockImplementation(() => { }); vi.useFakeTimers(); - subs = [component.messages.subscribe(), component.connectionStatus.subscribe()]; + lastStatus = undefined; + subs = [component.messages.subscribe(), component.connectionStatus.subscribe(s => lastStatus = s)]; }); afterEach(() => { @@ -117,7 +119,7 @@ describe('LogStreamTabComponent reconnection', () => { fail(2); vi.advanceTimersByTime(60000); expect(sockets.length).toBe(3); - expect(component.connectionStatusSignal()).toBe(-1); + expect(lastStatus).toBe(-1); }); it('starts counting again after a connection succeeds', () => { @@ -139,3 +141,76 @@ describe('LogStreamTabComponent reconnection', () => { expect(sockets[4].closed).toBe(false); }); }); + +// These render the real log viewer, whose buffering polls on a 0 ms +// interval that fake timers cannot run through, so they use real timers +// and a short back-off. +describe('LogStreamTabComponent connection status', () => { + let fixture: ComponentFixture; + + const connect = (i: number) => sockets[i].socket.next(() => new Subject()); + const fail = (i: number) => sockets[i].socket.error(new Error('connection lost')); + const status = () => { + const region = fixture.nativeElement.querySelector('[role="status"]') as HTMLElement | null; + expect(region).not.toBeNull(); + return region!.textContent!.trim(); + }; + const shows = (text: string) => vi.waitFor(() => expect(status()).toBe(text), { timeout: 2000 }); + const retrying = (attempt: number) => `Connection lost. Retrying (attempt ${attempt}/2)...`; + const failed = + 'Unable to connect to log stream. Please check that the backend service is running and try refreshing the page.'; + + beforeEach(async () => { + sockets.length = 0; + TestBed.configureTestingModule({ + imports: [LogStreamTabComponent], + providers: [ + { provide: ApplicationService, useValue: { cfGuid: 'cf-1', appGuid: 'app-1' } }, + provideZonelessChangeDetection(), + ] + }); + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + fixture = TestBed.createComponent(LogStreamTabComponent); + (fixture.componentInstance as any).retryDelayMs = 10; + await fixture.whenStable(); + }); + + afterEach(() => { + fixture.destroy(); + vi.restoreAllMocks(); + }); + + it('says it is connecting until the socket opens', async () => { + await shows('Connecting to log stream...'); + connect(0); + await shows(''); + }); + + it('shows each retry with its attempt', async () => { + fail(0); + await shows(retrying(1)); + fail(1); + await shows(retrying(2)); + }); + + it('says it could not connect once the last attempt fails', async () => { + fail(0); + await shows(retrying(1)); + fail(1); + await shows(retrying(2)); + fail(2); + await shows(failed); + expect(sockets.length).toBe(3); + }); + + it('clears the message and counts afresh after a reconnect', async () => { + fail(0); + await shows(retrying(1)); + connect(1); + await shows(''); + fail(1); + await shows(retrying(1)); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts index 8119668a89..3b808ea49a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/log-stream-tab/log-stream-tab.component.ts @@ -1,10 +1,10 @@ -import { Component, OnInit, ViewChild, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, signal, inject } from '@angular/core'; +import { Component, OnInit, ViewChild, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, inject } from '@angular/core'; import { NgModel } from '@angular/forms'; import { format } from 'date-fns'; -import { EMPTY, NEVER, Observable, Subject, of, timer } from 'rxjs'; +import { BehaviorSubject, EMPTY, NEVER, Observable, Subject, timer } from 'rxjs'; import makeWebSocketObservable, { GetWebSocketResponses } from 'rxjs-websockets'; -import { catchError, debounceTime, map, retry, share, startWith, switchMap, tap, take } from 'rxjs/operators'; +import { catchError, debounceTime, map, retry, share, switchMap, tap } from 'rxjs/operators'; import { AnsiColorizer, LogViewerComponent } from '@stratosui/core'; import { ApplicationService } from '../../../../application.service'; @@ -39,16 +39,18 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { private cdr = inject(ChangeDetectorRef); public messages!: Observable; - private connectionStatusSubject = new Subject(); - public connectionStatus!: Observable; + // 0 connecting, 1 connected, -2 retrying, -1 failed: the codes the log + // viewer turns into its status message. + private connectionStatusSubject = new BehaviorSubject(0); + public connectionStatus: Observable = this.connectionStatusSubject.pipe( + // Ensure the connection message doesn't flash onscreen. + debounceTime(250) + ); private socketError = false; - private maxRetries = 3; + protected readonly maxRetries = 3; private retryDelayMs = 2000; private lastError: ConnectionError | null = null; - // Signal for connection status tracking - connectionStatusSignal = signal(0); - // strict: populated by Angular's @ViewChild query after view init. @ViewChild('searchFilter', { static: false }) searchFilter!: NgModel; @@ -66,17 +68,14 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { } ngOnInit() { - this.connectionStatusSubject.next(0); - this.connectionStatusSignal.set(0); if (!this.applicationService.cfGuid || !this.applicationService.appGuid) { this.messages = NEVER; - this.connectionStatus = of(-1); // Indicate invalid configuration this.lastError = { message: 'Invalid configuration: Missing application or Cloud Foundry endpoint information', retryable: false, timestamp: Date.now() }; - this.connectionStatusSignal.set(-1); + this.connectionStatusSubject.next(-1); // Indicate invalid configuration this.cdr.markForCheck(); } else { // Use window.location.protocol to construct proper WebSocket URL @@ -94,6 +93,7 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { // Reset error state on successful connection this.socketError = false; this.lastError = null; + this.connectionStatusSubject.next(1); console.log('WebSocket connected successfully'); this.cdr.markForCheck(); }), @@ -112,7 +112,6 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { }; // Inform user of retry attempt this.connectionStatusSubject.next(-2); // -2 indicates retrying - this.connectionStatusSignal.set(-2); this.cdr.markForCheck(); // Exponential backoff: 2s, then 4s const delay = this.retryDelayMs * Math.pow(2, retryCount - 1); @@ -132,7 +131,6 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { timestamp: Date.now() }; this.connectionStatusSubject.next(-1); - this.connectionStatusSignal.set(-1); this.cdr.markForCheck(); // Return EMPTY to complete the stream gracefully return EMPTY; @@ -158,7 +156,6 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { timestamp: Date.now() }; this.connectionStatusSubject.next(-1); - this.connectionStatusSignal.set(-1); this.cdr.markForCheck(); return EMPTY; }), @@ -167,26 +164,6 @@ export class LogStreamTabComponent implements OnInit, OnDestroy { this.cdr.markForCheck(); }) ); - - this.connectionStatus = socket$.pipe( - take(1), - map(() => { - this.connectionStatusSubject.next(1); - this.connectionStatusSignal.set(1); - console.log('WebSocket connection status: Connected'); - this.cdr.markForCheck(); - return 1; - }), - startWith(0), - // Ensure the connection message doesn't flash onscreen. - debounceTime(250), - catchError(() => { - this.connectionStatusSubject.next(-1); - this.connectionStatusSignal.set(-1); - this.cdr.markForCheck(); - return of(-1); - }) - ); } } diff --git a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.html b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.html index d9dd0fe5ef..cdf6537475 100644 --- a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.html +++ b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.html @@ -10,10 +10,12 @@
@if (statusMessage$ | async; as message) { -
+
@if (message.message) {
-
{{ message.message }}
+
{{ message.message }}
}
diff --git a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts index b614d54c17..9c0ebe7bdd 100644 --- a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts +++ b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.ts @@ -58,6 +58,9 @@ export class LogViewerComponent implements OnInit, OnDestroy { @Input() status!: Observable; + // How many times the parent retries before it reports the stream failed + @Input() maxReconnectAttempts = 5; + private logStreamSubject = new BehaviorSubject>(NEVER); // A different stream is a new log: what the last one showed goes @@ -89,7 +92,6 @@ export class LogViewerComponent implements OnInit, OnDestroy { // Connection state tracking private connectionAttempts = 0; - private maxReconnectAttempts = 5; private lastConnectionError: ConnectionErrorInfo | null = null; public maxLogLines = 1000; From 2fb5bc7f5111df48c785df85681a93489923f3be Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 16:07:13 -0700 Subject: [PATCH 11/13] docs(changelog): note the log stream reconnect status Add the log stream tab's retrying and failed messages to the RxJS 7 fragment's bug fixes. --- changelog.d/0021-rxjs7-deprecations.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog.d/0021-rxjs7-deprecations.md b/changelog.d/0021-rxjs7-deprecations.md index 3889a0f4c3..3b8557b184 100644 --- a/changelog.d/0021-rxjs7-deprecations.md +++ b/changelog.d/0021-rxjs7-deprecations.md @@ -4,6 +4,9 @@ closing it at once, and it starts counting again after a successful connection. Before, a stream that connected and later dropped could stop reconnecting. +- The application log stream tab now says when it is retrying the + connection (with the attempt number) and when it has given up, + instead of showing "Connecting to log stream..." for good. [Maintainability] - Replaced the RxJS APIs deprecated in RxJS 7 (publishReplay/refCount, From f7c2887e84955275ac62be13809d5429a548a2a3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 21 Oct 2026 16:09:08 -0700 Subject: [PATCH 12/13] test(kubernetes): drop unused test setup exports HelmBaseTestModules, HelmBaseTestProviders and WorkloadsBaseTestingModule had no importers anywhere in the frontend. The only mention was a commented-out line in the Helm release values tab spec, which goes too, along with the imports only these lists used. Both files keep their other exports, which specs still import. --- .../src/helm/helm-testing.module.ts | 21 ------------------- .../helm-release-values-tab.component.spec.ts | 1 - .../workloads/workloads.testing.module.ts | 18 +--------------- 3 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts index 9344039bc3..06335daad0 100644 --- a/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts +++ b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts @@ -1,10 +1,5 @@ -import { HttpClient, HttpHandler } from '@angular/common/http'; import { NgModule } from '@angular/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CoreModule, SharedModule } from '@stratosui/core'; -import { CoreTestingModule } from '../../../core/test-framework/core-test.modules'; import { CATALOGUE_ENTITIES, @@ -13,7 +8,6 @@ import { TestEntityCatalog, } from '../../../store/src/public-api'; import { generateStratosEntities } from '../../../store/src/stratos-entity-generator'; -import { createBasicStoreModule } from '../../../store/testing/public-api'; import { HelmReleaseGuid } from '../kubernetes/workloads/workload.types'; import { generateHelmEntities } from './helm-entity-generator'; @@ -59,18 +53,3 @@ export const HelmReleaseGuidMock = { } }; -export const HelmBaseTestModules = [ - CoreTestingModule, - createBasicStoreModule(), - HelmTestingModule, - RouterTestingModule, - CoreModule, - NoopAnimationsModule, - SharedModule -].filter(Boolean); - -export const HelmBaseTestProviders = [ - HttpClient, - HttpHandler -]; - diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts index ddaaa5791f..df3bfdac9d 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts @@ -18,7 +18,6 @@ describe('HelmReleaseValuesTabComponent', () => { HelmReleaseValuesTabComponent, ], providers: [ - // ...HelmBaseTestProviders, ...HelmReleaseProviders, TabNavService, diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts index 14eef886fb..8b9bea47ca 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts @@ -1,9 +1,5 @@ import { NgModule } from '@angular/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CoreModule, SharedModule } from '../../../../core/src/public-api'; -import { AppTestModule } from '../../../../core/test-framework/core-test.helper'; import { CATALOGUE_ENTITIES, entityCatalog, @@ -11,9 +7,8 @@ import { TestEntityCatalog, } from '../../../../store/src/public-api'; import { generateStratosEntities } from '../../../../store/src/stratos-entity-generator'; -import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '../../../../store/testing/public-api'; +import { STORE_TEST_PROVIDERS } from '../../../../store/testing/public-api'; import { generateHelmEntities } from '../../helm/helm-entity-generator'; -import { HelmTestingModule } from '../../helm/helm-testing.module'; import { kubeEntityCatalog } from '../kubernetes-entity-generator'; @NgModule({ @@ -38,14 +33,3 @@ import { kubeEntityCatalog } from '../kubernetes-entity-generator'; ] }) export class WorkloadsTestingModule { } - -export const WorkloadsBaseTestingModule = [ - AppTestModule, - RouterTestingModule, - CoreModule, - createBasicStoreModule(), - NoopAnimationsModule, - SharedModule, - HelmTestingModule, - WorkloadsTestingModule -]; From 6a34127aaaf9b035ec2d9ed4b55978562ad37ea0 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 22 Oct 2026 11:05:39 -0700 Subject: [PATCH 13/13] test(metrics): cover the range selector's window poll The metrics range selector polls every 10 seconds when a time window is chosen, re-requesting the window so the chart keeps moving, and ticks the app because the poll runs from a plain setInterval. This branch removed the NgZone wrappers around that interval. The manager's spec only checked that it could be created, and the selector spec checks a single tick and the stop on destroy. Check that every poll sends a new request whose one-hour window has moved on by the poll interval, and ticks the app each time; and that choosing a custom range stops the poll. Removing the request, the tick or the stop each turns one of these red. --- ...ics-range-selector-manager.service.spec.ts | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.spec.ts b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.spec.ts index 40cd9dadd3..d23c01a123 100644 --- a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.spec.ts +++ b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { ApplicationRef, provideZonelessChangeDetection } from '@angular/core'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MetricQueryConfig, MetricQueryType, MetricsRequest } from '@stratosui/store'; import { MetricsRangeSelectorManagerService } from './metrics-range-selector-manager.service'; import { MetricsRangeSelectorService } from './metrics-range-selector.service'; @@ -9,7 +10,6 @@ describe('MetricsRangeSelectorManagerService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ - MetricsRangeSelectorManagerService, MetricsRangeSelectorService, provideZonelessChangeDetection(), @@ -17,8 +17,66 @@ describe('MetricsRangeSelectorManagerService', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should be created', () => { const service = TestBed.inject(MetricsRangeSelectorManagerService); expect(service).toBeTruthy(); }); + + it('re-requests a moving window and ticks the app on every poll', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-22T12:00:00Z')); + const manager = TestBed.inject(MetricsRangeSelectorManagerService); + const tick = vi.spyOn(TestBed.inject(ApplicationRef), 'tick').mockImplementation(() => undefined); + const requests: MetricsRequest[] = []; + manager.request$.subscribe(request => requests.push(request)); + + manager.init({ + endpointGuid: 'e1', + url: '/pp/v1/metrics/test', + query: new MetricQueryConfig('up'), + queryType: MetricQueryType.QUERY, + windowValue: '1:hour', + }); + // Choosing the window commits the first request at once. + expect(requests.length).toBe(1); + expect(tick).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(manager.pollInterval * 2); + + expect(requests.length).toBe(3); + expect(tick).toHaveBeenCalledTimes(2); + const ends = requests.map(request => request.query.params?.end); + expect(ends[1] - ends[0]).toBe(manager.pollInterval / 1000); + expect(ends[2] - ends[1]).toBe(manager.pollInterval / 1000); + for (const request of requests) { + expect(request.windowValue).toBe('1:hour'); + expect(request.query.params?.end - request.query.params?.start).toBe(60 * 60); + } + }); + + it('stops polling when a custom range replaces the window', () => { + vi.useFakeTimers(); + const manager = TestBed.inject(MetricsRangeSelectorManagerService); + const tick = vi.spyOn(TestBed.inject(ApplicationRef), 'tick').mockImplementation(() => undefined); + const requests = vi.fn(); + manager.request$.subscribe(requests); + manager.init({ + endpointGuid: 'e1', + url: '/pp/v1/metrics/test', + query: new MetricQueryConfig('up'), + queryType: MetricQueryType.QUERY, + windowValue: '1:hour', + }); + + manager.selectedTimeRange = manager.times.find(time => !time.value)!; + requests.mockClear(); + vi.advanceTimersByTime(manager.pollInterval * 3); + + expect(requests).not.toHaveBeenCalled(); + expect(tick).not.toHaveBeenCalled(); + }); });