diff --git a/changelog.d/0021-rxjs7-deprecations.md b/changelog.d/0021-rxjs7-deprecations.md new file mode 100644 index 0000000000..3b8557b184 --- /dev/null +++ b/changelog.d/0021-rxjs7-deprecations.md @@ -0,0 +1,17 @@ +[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. +- 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, + 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. 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/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/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..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 @@ -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); @@ -318,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), ); @@ -330,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/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 2224e65ca7..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 @@ -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,161 @@ 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[]; + let lastStatus: number | undefined; + + 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(); + lastStatus = undefined; + subs = [component.messages.subscribe(), component.connectionStatus.subscribe(s => lastStatus = s)]; + }); + + 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(lastStatus).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); + }); +}); + +// 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 c451a82b8c..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, share, startWith, switchMap, tap, retryWhen, delayWhen, 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,17 +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 connectionAttempts = 0; - 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; @@ -67,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 @@ -92,55 +90,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; + this.connectionStatusSubject.next(1); 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.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 = { @@ -149,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; @@ -175,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; }), @@ -184,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/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..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, @@ -270,11 +269,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), ); @@ -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,16 +371,15 @@ export class DeployApplicationStep2Component ); }), map(([branches, _branchName]) => branches), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }) ); - 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/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 79eef89c8d..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, @@ -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)) ); } @@ -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/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-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/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/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/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..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 @@ -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); @@ -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/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$)); } 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..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,13 +165,12 @@ 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( + 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/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/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/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/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/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/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/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/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/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..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'; @@ -53,16 +53,15 @@ 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'), 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/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/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/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/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 5c9c81de87..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 @@ -13,7 +13,7 @@ import { import { buffer, catchError, - combineLatest, + combineLatestWith, distinctUntilChanged, filter, map, @@ -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; @@ -204,7 +206,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 +224,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 +305,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/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/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/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..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'; @@ -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? @@ -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/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/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/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(); + }); }); 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/helm/helm-testing.module.ts b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts index cb29b55f1b..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, HttpClientModule, 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,19 +53,3 @@ export const HelmReleaseGuidMock = { } }; -export const HelmBaseTestModules = [ - CoreTestingModule, - createBasicStoreModule(), - HelmTestingModule, - RouterTestingModule, - CoreModule, - NoopAnimationsModule, - HttpClientModule, - SharedModule -].filter(Boolean); - -export const HelmBaseTestProviders = [ - HttpClient, - HttpHandler -]; - 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/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/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(); }); } 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/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 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..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); @@ -133,10 +132,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/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 a969b792be..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,10 +1,5 @@ -import { HttpClientModule } from '@angular/common/http'; 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, @@ -12,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({ @@ -39,15 +33,3 @@ import { kubeEntityCatalog } from '../kubernetes-entity-generator'; ] }) export class WorkloadsTestingModule { } - -export const WorkloadsBaseTestingModule = [ - AppTestModule, - RouterTestingModule, - CoreModule, - createBasicStoreModule(), - NoopAnimationsModule, - HttpClientModule, - SharedModule, - HelmTestingModule, - WorkloadsTestingModule -]; 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/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/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) { 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; 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 { }