From 5c6535e9251f66aae3d7f1e666494e2af3cc598f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:02:03 -0700 Subject: [PATCH 001/121] refactor(store): add subscription-scoped signal helpers toObservable() creates its effect when called and ends it only when the injector is destroyed, so every call with the root injector leaves an effect running for the rest of the session. observeSignal() creates the effect on subscribe and destroys it on unsubscribe, with the same first-emission timing. combineLatestOrEmpty() emits [] for an empty list, where combineLatest([]) completes without a value and leaves take(1) and loading indicators waiting. createSignalWrapper() is a shared copy of the callable signal wrapper that four files define privately. Its asObservable() is backed by a BehaviorSubject: synchronous, one emission per write, no injection context, and it outlives the view that created it. Callers move to these helpers in the following commits. --- .../store/src/helpers/signal-interop.spec.ts | 199 ++++++++++++++++++ .../store/src/helpers/signal-interop.ts | 69 ++++++ src/frontend/packages/store/src/public-api.ts | 1 + 3 files changed, 269 insertions(+) create mode 100644 src/frontend/packages/store/src/helpers/signal-interop.spec.ts create mode 100644 src/frontend/packages/store/src/helpers/signal-interop.ts diff --git a/src/frontend/packages/store/src/helpers/signal-interop.spec.ts b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts new file mode 100644 index 0000000000..7437ae7e34 --- /dev/null +++ b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts @@ -0,0 +1,199 @@ +import { computed, Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of, Subject } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; +import { describe, expect, it } from 'vitest'; + +import { combineLatestOrEmpty, createSignalWrapper, observeSignal } from './signal-interop'; + +describe('signal-interop', () => { + + describe('observeSignal', () => { + // Reads the source through a computed that counts its evaluations, so a + // still-running effect shows up as extra evaluations after a write. + function countedSource(initial: number) { + const source = signal(initial); + let reads = 0; + const counted = computed(() => { + reads++; + return source(); + }); + return { source, counted, reads: () => reads }; + } + + it('emits the current value on the first flush, then later writes', () => { + const injector = TestBed.inject(Injector); + const source = signal(1); + const values: number[] = []; + + const sub = observeSignal(source, injector).subscribe(v => values.push(v)); + // Same timing as toObservable: nothing before the effect first runs. + expect(values).toEqual([]); + + TestBed.tick(); + expect(values).toEqual([1]); + + source.set(2); + TestBed.tick(); + source.set(3); + TestBed.tick(); + expect(values).toEqual([1, 2, 3]); + + sub.unsubscribe(); + }); + + it('stops reading the source after unsubscribe', () => { + const injector = TestBed.inject(Injector); + const { source, counted, reads } = countedSource(1); + const values: number[] = []; + + const sub = observeSignal(counted, injector).subscribe(v => values.push(v)); + TestBed.tick(); + expect(values).toEqual([1]); + const readsWhileSubscribed = reads(); + + sub.unsubscribe(); + source.set(2); + TestBed.tick(); + source.set(3); + TestBed.tick(); + + expect(reads()).toBe(readsWhileSubscribed); + expect(values).toEqual([1]); + }); + + it('creates no effect until subscribed', () => { + const injector = TestBed.inject(Injector); + const { source, counted, reads } = countedSource(1); + + observeSignal(counted, injector); + TestBed.tick(); + source.set(2); + TestBed.tick(); + + expect(reads()).toBe(0); + }); + + it('gives each subscriber its own effect', () => { + const injector = TestBed.inject(Injector); + const source = signal('a'); + const obs$ = observeSignal(source, injector); + const first: string[] = []; + const second: string[] = []; + + const sub1 = obs$.subscribe(v => first.push(v)); + const sub2 = obs$.subscribe(v => second.push(v)); + TestBed.tick(); + sub1.unsubscribe(); + source.set('b'); + TestBed.tick(); + + expect(first).toEqual(['a']); + expect(second).toEqual(['a', 'b']); + sub2.unsubscribe(); + }); + }); + + describe('combineLatestOrEmpty', () => { + it('emits [] once and completes for no sources', async () => { + const emitted = await firstValueFrom(combineLatestOrEmpty([]).pipe(toArray())); + expect(emitted).toEqual([[]]); + }); + + it('behaves like combineLatest for non-empty sources', () => { + const a = new Subject(); + const b = new Subject(); + const values: (number | string)[][] = []; + let completed = false; + + combineLatestOrEmpty([a, b]).subscribe({ + next: v => values.push(v), + complete: () => (completed = true), + }); + a.next(1); + expect(values).toEqual([]); + b.next('x'); + a.next(2); + a.complete(); + b.complete(); + + expect(values).toEqual([[1, 'x'], [2, 'x']]); + expect(completed).toBe(true); + }); + + it('works with take(1) on synchronous sources', async () => { + const result = await firstValueFrom(combineLatestOrEmpty([of(1), of(2)]).pipe(take(1))); + expect(result).toEqual([1, 2]); + }); + }); + + describe('createSignalWrapper', () => { + it('reflects writes in the signal read', () => { + const wrapper = createSignalWrapper(1); + expect(wrapper()).toBe(1); + + wrapper.set(2); + expect(wrapper()).toBe(2); + wrapper.next(3); + expect(wrapper()).toBe(3); + expect(wrapper.getValue()).toBe(3); + wrapper.update(v => v + 1); + expect(wrapper()).toBe(4); + expect(wrapper.asReadonly()()).toBe(4); + }); + + it('asObservable works outside an injection context', () => { + const wrapper = createSignalWrapper('a'); + const values: string[] = []; + + wrapper.asObservable().subscribe(v => values.push(v)); + + expect(values).toEqual(['a']); + }); + + it('delivers every synchronous write to a subscriber', () => { + const wrapper = createSignalWrapper(0); + const values: number[] = []; + wrapper.asObservable().subscribe(v => values.push(v)); + + wrapper.next(1); + wrapper.set(2); + + expect(values).toEqual([0, 1, 2]); + }); + + it('re-notifies the same reference with equal: () => false', () => { + const state = { count: 0 }; + const wrapper = createSignalWrapper(state, { equal: () => false }); + const observed: number[] = []; + wrapper.asObservable().subscribe(v => observed.push(v.count)); + let signalRuns = 0; + const tracked = computed(() => { + wrapper(); + return ++signalRuns; + }); + expect(tracked()).toBe(1); + + state.count++; + wrapper.next(state); + + expect(tracked()).toBe(2); + expect(observed).toEqual([0, 1]); + }); + + it('dedupes the same reference in the signal by default', () => { + const state = { count: 0 }; + const wrapper = createSignalWrapper(state); + let signalRuns = 0; + const tracked = computed(() => { + wrapper(); + return ++signalRuns; + }); + expect(tracked()).toBe(1); + + wrapper.next(state); + + expect(tracked()).toBe(1); + }); + }); +}); diff --git a/src/frontend/packages/store/src/helpers/signal-interop.ts b/src/frontend/packages/store/src/helpers/signal-interop.ts new file mode 100644 index 0000000000..43950093a4 --- /dev/null +++ b/src/frontend/packages/store/src/helpers/signal-interop.ts @@ -0,0 +1,69 @@ +import { effect, Injector, signal, Signal, untracked, ValueEqualityFn, WritableSignal } from '@angular/core'; +import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; + +/** + * Observe a signal for as long as the subscription lives. + * + * Unlike toObservable(), which creates its effect when it is called and only + * ends it when the injector is destroyed (unsubscribing does nothing), this + * creates the effect on subscribe and destroys it on unsubscribe. That makes + * it safe with a long-lived injector such as the root one, where + * toObservable() would leave an effect running forever per call. + * + * Timing matches toObservable(): the first value comes from the effect's + * first run, not synchronously on subscribe. + */ +export function observeSignal(source: Signal, injector: Injector): Observable { + return new Observable(subscriber => { + const ref = effect(() => { + const value = source(); + untracked(() => subscriber.next(value)); + }, { injector, manualCleanup: true }); + return () => ref.destroy(); + }); +} + +/** + * combineLatest() that emits [] for an empty list. combineLatest([]) completes + * without emitting, which leaves take(1) and loading indicators waiting. + */ +export function combineLatestOrEmpty(sources: Observable[]): Observable { + return sources.length ? combineLatest(sources) : of([]); +} + +/** + * A writable signal that also offers the BehaviorSubject API (next, getValue, + * asObservable), for code moving from subjects to signals. + * + * asObservable() is backed by a BehaviorSubject updated on every write, so it + * is synchronous, emits every write (no coalescing per change detection + * pass), needs no injection context and outlives the view that created it. + * + * Pass `equal: () => false` when callers mutate a value in place and write + * the same reference again; the default equality would drop those updates + * from the signal. + */ +export function createSignalWrapper(initialValue: T, options?: { equal?: ValueEqualityFn }) { + const _signal = signal(initialValue, options?.equal ? { equal: options.equal } : undefined); + const _subject = new BehaviorSubject(initialValue); + const write = (value: T) => { + _signal.set(value); + _subject.next(value); + }; + const wrapper = Object.assign( + () => _signal(), + { + set: write, + update: (fn: (value: T) => T) => write(fn(_signal())), + asReadonly: () => _signal.asReadonly(), + next: write, + getValue: () => _signal(), + asObservable: () => _subject.asObservable(), + } + ); + return wrapper as WritableSignal & { + next: (value: T) => void; + getValue: () => T; + asObservable: () => Observable; + }; +} diff --git a/src/frontend/packages/store/src/public-api.ts b/src/frontend/packages/store/src/public-api.ts index ae5f440320..4fbf9f2a53 100644 --- a/src/frontend/packages/store/src/public-api.ts +++ b/src/frontend/packages/store/src/public-api.ts @@ -60,6 +60,7 @@ export { export { RequestTypes, APIResponse } from './actions/request.actions'; export { LocalPaginationHelpers } from './helpers/local-list.helpers'; export { pick } from './helpers/reducer.helper'; +export { observeSignal, combineLatestOrEmpty, createSignalWrapper } from './helpers/signal-interop'; export type { ApiKey } from './apiKey.types'; export type { PaginationPageIteratorConfig, From fc8237870be426eddaa64dbf8cd1cba737c9893c Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:22:28 -0700 Subject: [PATCH 002/121] fix(cf): stop leaking feature-flag signal watches The feature-flag check built a toObservable() on the root injector for every subscription, and each one kept an effect running for the rest of the session (non-admin users, on every CF endpoint page, orgs list and manage-users). Watch the flags with observeSignal(), which ends with the subscription. The build-after-load factory stays. --- .../cf-user-permissions-checkers.spec.ts | 56 ++++++++++++++++++- .../cf-user-permissions-checkers.ts | 9 ++- .../user-permissions/feature-flags-cache.ts | 7 ++- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts index 526bd849e3..07ef36f938 100644 --- a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { computed, provideZonelessChangeDetection, Signal, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CUSTOM_USER_PERMISSION_CHECKERS, @@ -11,10 +11,15 @@ import { } from '@stratosui/core'; import { EndpointsDataService, SessionData } from '@stratosui/store'; import { firstValueFrom, of } from 'rxjs'; -import { describe, expect, it, beforeEach } from 'vitest'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { CFFeatureFlagTypes } from '../cf-api.types'; +import { StFeatureFlag } from '../services/endpoint-data/stratos-types'; import { CfCurrentUserRolesSignalService } from './cf-current-user-roles-signal.service'; import { CfUserPermissionsChecker } from './cf-user-permissions-checkers'; +import { CfPermissionTypes } from './cf-user-permissions.types'; +import { getFeatureFlagsSource } from './feature-flags-cache'; /** * Checker contract (#5570 / #5571): a checker signals "this config is not @@ -114,3 +119,50 @@ describe('CurrentUserPermissionsService with both checkers registered', () => { ).resolves.toBe(false); }); }); + +describe('CfUserPermissionsChecker feature flag check', () => { + // The checker is a root service, so whatever watches the flags signal must + // end with the subscription, not with the (permanent) root injector. + it('stops reading the feature flags after unsubscribe', async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: CfCurrentUserRolesSignalService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + CfUserPermissionsChecker, + ], + }); + const checker = TestBed.inject(CfUserPermissionsChecker); + + // Pre-seed the shared cache entry so load() resolves without a request, + // and read the flags through a computed that counts its evaluations. + const guid = 'ff-unsubscribe-cf'; + const src = getFeatureFlagsSource(guid, TestBed.inject(HttpClient)); + vi.spyOn(src, 'load').mockResolvedValue(); + const flags = signal([{ name: CFFeatureFlagTypes.set_roles_by_username, enabled: true } as StFeatureFlag]); + let reads = 0; + (src as { items: Signal }).items = computed(() => { + reads++; + return flags(); + }); + + const values: boolean[] = []; + const check$ = checker.getSimpleCheck( + new PermissionConfig(CfPermissionTypes.FEATURE_FLAG, CFFeatureFlagTypes.set_roles_by_username), + guid, + ); + const sub = check$!.subscribe(v => values.push(v)); + await src.load(); + await Promise.resolve(); + TestBed.tick(); + expect(values).toEqual([true]); + const readsWhileSubscribed = reads; + + sub.unsubscribe(); + flags.set([{ name: CFFeatureFlagTypes.set_roles_by_username, enabled: false } as StFeatureFlag]); + TestBed.tick(); + + expect(reads).toBe(readsWhileSubscribed); + expect(values).toEqual([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 be9c679754..bd734eb76f 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 @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest, Observable, of } from 'rxjs'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { @@ -16,7 +15,7 @@ import { PermissionConfigLink, PermissionTypes, } from '@stratosui/core'; -import { PermissionValues } from '@stratosui/store'; +import { observeSignal, PermissionValues } from '@stratosui/store'; import { CFFeatureFlagTypes } from '../cf-api.types'; import { CF_ENDPOINT_TYPE } from '../cf-types'; import { StFeatureFlag } from '../services/endpoint-data/stratos-types'; @@ -270,10 +269,14 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker // take(1) consumer would otherwise capture the pre-load empty list // and resolve to a false negative (set/unset-roles-by-username // wrongly appearing disabled despite the flag being enabled). + // + // observeSignal, not toObservable: this checker lives on the root + // injector, so a toObservable effect would outlive every + // subscription and run for the rest of the session. const src = getFeatureFlagsSource(guid, this.http); return featureFlagsAfterLoad$( () => src.load(), - () => toObservable(src.items, { injector: this.injector }), + () => observeSignal(src.items, this.injector), ); }; return combineLatest(guids.map(createFFObs)); diff --git a/src/frontend/packages/cloud-foundry/src/user-permissions/feature-flags-cache.ts b/src/frontend/packages/cloud-foundry/src/user-permissions/feature-flags-cache.ts index f7ac30e19e..c9a9ba8556 100644 --- a/src/frontend/packages/cloud-foundry/src/user-permissions/feature-flags-cache.ts +++ b/src/frontend/packages/cloud-foundry/src/user-permissions/feature-flags-cache.ts @@ -10,7 +10,7 @@ import type { StFeatureFlag } from '../services/endpoint-data/stratos-types'; // effects with no Injector, and CfUserPermissionsChecker — an Injectable) // share this so the FF list is fetched once per CF endpoint per session. // CnsiFeatureFlagsSource is signal-bearing; consumers either await load() -// for completion-gating or read items() / toObservable(items) for data. +// for completion-gating or read items() / observeSignal(items) for data. const cache = new Map(); export function getFeatureFlagsSource( @@ -35,7 +35,10 @@ export function getFeatureFlagsSource( // pre-built observable. This matters for toObservable(signal): if created // while items is still empty it buffers that empty value in its replay and // emits it first regardless of subscription timing. Building it post-load -// means its first emission reflects the populated signal. +// means its first emission reflects the populated signal. The checker's +// factory now returns observeSignal(items), which reads on subscribe and so +// does not buffer early, but the factory keeps the contract safe for any +// stream a caller passes. export function featureFlagsAfterLoad$( load: () => Promise, itemsFactory: () => Observable, From 2595e2fa32d0da7b4c1a6bc5e3e83f96d923d745 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:22:28 -0700 Subject: [PATCH 003/121] fix(autoscaler): stop leaking availability watches isAutoscalerEnabled() and fetchAutoscalerInfo() created a root toObservable() effect at call time, one per app page visit, even when nothing subscribed. Use observeSignal(), which starts on subscribe and stops on unsubscribe. --- .../autoscaler-available.spec.ts | 93 +++++++++++++++++++ .../autoscaler-available.ts | 15 +-- 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.spec.ts diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.spec.ts new file mode 100644 index 0000000000..d190b1d35d --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.spec.ts @@ -0,0 +1,93 @@ +import { computed, Injector, provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AutoscalerInfoDataService } from '../../services/domain-data/autoscaler-info-data.service'; +import { fetchAutoscalerInfo, isAutoscalerEnabled, setAutoscalerHelperInjector } from './autoscaler-available'; + +// The helpers run off the root injector captured by the package module, so +// whatever watches the data service signals must end with the subscription, +// not with the (permanent) root injector. +describe('autoscaler-available helpers', () => { + // Each data service signal is read through a computed that counts its + // evaluations, so a still-running watcher shows up as extra reads after a + // write to the backing signal. + let available: ReturnType>; + let info: ReturnType>; + let reads: number; + + beforeEach(() => { + available = signal(false); + info = signal(null); + reads = 0; + const counted = (read: () => T) => computed(() => { + reads++; + return read(); + }); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { + provide: AutoscalerInfoDataService, + useValue: { + load: vi.fn().mockResolvedValue(undefined), + isAvailable: () => counted(() => available()), + info: () => counted(() => info()), + error: () => computed(() => null), + loading: () => computed(() => false), + }, + }, + ], + }); + setAutoscalerHelperInjector(TestBed.inject(Injector)); + }); + + describe('isAutoscalerEnabled', () => { + it('does not read the availability signal until subscribed', () => { + isAutoscalerEnabled('ep-1'); + TestBed.tick(); + available.set(true); + TestBed.tick(); + + expect(reads).toBe(0); + }); + + it('stops reading the availability signal after unsubscribe', () => { + const values: boolean[] = []; + const sub = isAutoscalerEnabled('ep-1').subscribe(v => values.push(v)); + TestBed.tick(); + available.set(true); + TestBed.tick(); + // startWith(false) sits after distinctUntilChanged, so the seed and the + // first signal value both come through. + expect(values).toEqual([false, false, true]); + const readsWhileSubscribed = reads; + + sub.unsubscribe(); + available.set(false); + TestBed.tick(); + + expect(reads).toBe(readsWhileSubscribed); + expect(values).toEqual([false, false, true]); + }); + }); + + describe('fetchAutoscalerInfo', () => { + it('stops reading the info signal after unsubscribe', () => { + const builds: unknown[] = []; + const sub = fetchAutoscalerInfo('ep-1').subscribe(v => builds.push(v.entity?.entity)); + TestBed.tick(); + info.set({ build: '1' }); + TestBed.tick(); + expect(builds).toEqual([undefined, { build: '1' }]); + const readsWhileSubscribed = reads; + + sub.unsubscribe(); + info.set({ build: '2' }); + TestBed.tick(); + + expect(reads).toBe(readsWhileSubscribed); + expect(builds).toEqual([undefined, { build: '1' }]); + }); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.ts index 295c027185..45023d75df 100644 --- a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.ts +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-available.ts @@ -1,8 +1,8 @@ -import { Injector, computed, runInInjectionContext } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Injector, computed } from '@angular/core'; import { Observable, of } from 'rxjs'; import { catchError, distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { observeSignal } from '@stratosui/store'; import { APIResource, EntityInfo } from '../../../../store/src/types/api.types'; import { AutoscalerInfoDataService } from '../../services/domain-data/autoscaler-info-data.service'; import { AutoscalerInfo } from '../../store/app-autoscaler.types'; @@ -18,6 +18,11 @@ import { AutoscalerInfo } from '../../store/app-autoscaler.types'; // args) and the cloud-foundry summary card. The `esf` arg is retained // for backwards compat but is unused — the data service is fetched via // the module-level injector captured by CfAutoscalerPackageModule. +// +// That injector is the root one, which is never destroyed, so the signals +// are watched with observeSignal: the watcher starts on subscribe and stops +// on unsubscribe. toObservable would start one at call time that runs for +// the rest of the session. let helperInjector: Injector | null = null; @@ -67,7 +72,7 @@ function asEntityInfoObservable( : null, } as EntityInfo>; }); - return runInInjectionContext(requireInjector(), () => toObservable(envelope)); + return observeSignal(envelope, requireInjector()); } export const fetchAutoscalerInfo = ( @@ -90,9 +95,7 @@ export const isAutoscalerEnabled = ( ): Observable => { const svc = getDataService(); void svc.load(endpointGuid); - return runInInjectionContext(requireInjector(), () => - toObservable(svc.isAvailable(endpointGuid)), - ).pipe( + return observeSignal(svc.isAvailable(endpointGuid), requireInjector()).pipe( distinctUntilChanged(), map(value => !!value), catchError(() => of(false)), From 6dca3725ebe5ddb0b089b3a72e9b29bc91dac8eb Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:22:28 -0700 Subject: [PATCH 004/121] fix(git): stop leaking endpoint watches per SCM call Every API lookup for a registered private or enterprise git endpoint created a root toObservable() effect that take(1) could not end; the deploy-from-git name validator did it once per debounced keystroke. Use observeSignal() so take(1) ends the watch. --- .../git/src/shared/scm/scm-base.spec.ts | 40 +++++++++++++++++++ .../packages/git/src/shared/scm/scm-base.ts | 8 ++-- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 src/frontend/packages/git/src/shared/scm/scm-base.spec.ts diff --git a/src/frontend/packages/git/src/shared/scm/scm-base.spec.ts b/src/frontend/packages/git/src/shared/scm/scm-base.spec.ts new file mode 100644 index 0000000000..0cb1af1433 --- /dev/null +++ b/src/frontend/packages/git/src/shared/scm/scm-base.spec.ts @@ -0,0 +1,40 @@ +import { computed, Injector, provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; + +import { EndpointModel } from '../../../../store/src/public-api'; +import { EndpointsDataService } from '../../../../store/src/services/endpoints-data.service'; +import { GitApiRequest } from './scm-base'; +import { GitLabSCM } from './gitlab-scm'; + +// The SCM gets the root injector from its factory, and getAPI() runs per +// request (the deploy-from-git name check runs it per keystroke), so the +// endpoint lookup must stop watching the endpoint list once it has answered. +describe('BaseSCM endpoint lookup', () => { + it('stops reading the endpoint list after the request resolves', () => { + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); + const list = signal([{ guid: 'git-1', user: { name: 'u' } } as EndpointModel]); + let reads = 0; + const endpointsData = { + endpointsList: computed(() => { + reads++; + return list(); + }), + } as unknown as EndpointsDataService; + const scm = new GitLabSCM('git-1', undefined, endpointsData, TestBed.inject(Injector)); + + const requests: GitApiRequest[] = []; + scm.getAPI().subscribe(r => requests.push(r)); + TestBed.tick(); + expect(requests.map(r => r.url)).toEqual(['/api/v1/proxy/git-1']); + const readsWhileResolving = reads; + + list.set([]); + TestBed.tick(); + list.set([{ guid: 'git-1' } as EndpointModel]); + TestBed.tick(); + + expect(reads).toBe(readsWhileResolving); + expect(requests).toHaveLength(1); + }); +}); diff --git a/src/frontend/packages/git/src/shared/scm/scm-base.ts b/src/frontend/packages/git/src/shared/scm/scm-base.ts index 48ce275777..d80fcdd63d 100644 --- a/src/frontend/packages/git/src/shared/scm/scm-base.ts +++ b/src/frontend/packages/git/src/shared/scm/scm-base.ts @@ -1,11 +1,10 @@ import { Observable, of } from 'rxjs'; import { take, map } from 'rxjs/operators'; -import { toObservable } from '@angular/core/rxjs-interop'; import { Injector } from '@angular/core'; import { HttpOptions } from '../../../../core/src/core/core.types'; import { environment } from '../../../../core/src/environments/environment'; -import { EndpointModel } from '../../../../store/src/public-api'; +import { EndpointModel, observeSignal } from '../../../../store/src/public-api'; import { EndpointsDataService } from '../../../../store/src/services/endpoints-data.service'; @@ -76,11 +75,12 @@ export abstract class BaseSCM { // tolerate misses (e.g. an app deployed from a since-deleted // private github endpoint). The signal-native projection updates // synchronously, so the take(1) consumer pattern downstream is - // unchanged. + // unchanged. observeSignal ends the watch when take(1) unsubscribes; + // toObservable would keep one running on the root injector per call. if (!this.endpointsData || !this.injector) { throw new Error('BaseSCM requires EndpointsDataService + Injector — supply them via the subclass constructor.'); } - return toObservable(this.endpointsData.endpointsList, { injector: this.injector }).pipe( + return observeSignal(this.endpointsData.endpointsList, this.injector).pipe( map(endpoints => endpoints?.find(e => e.guid === endpointGuid)) ); } From f70dc6e0b0f8824fc13acc6003299330e024a1b6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:22:28 -0700 Subject: [PATCH 005/121] fix(core): end the permission fallback endpoint watch The fallback branch of CurrentUserPermissionsService watched a fresh endpointById() computed through a root toObservable() per call, which outlived its subscribers. Use observeSignal(). --- .../current-user-permissions.service.spec.ts | 50 +++++++++++++++++-- .../current-user-permissions.service.ts | 9 ++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts index 400714a7fb..9de406835f 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts @@ -1,8 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { computed, provideZonelessChangeDetection, signal } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { firstValueFrom } from 'rxjs'; -import { AppState, BaseEntityValues, CurrentUserRolesDataService, EntityCatalogEntityConfig, EntityCatalogTestModule, EndpointModel, PaginationEntityState, TEST_CATALOGUE_ENTITIES, endpointEntityType, generateStratosEntities, stratosEntityFactory } from '@stratosui/store'; +import { AppState, BaseEntityValues, CurrentUserRolesDataService, EndpointsDataService, EntityCatalogEntityConfig, EntityCatalogTestModule, EndpointModel, PaginationEntityState, TEST_CATALOGUE_ENTITIES, endpointEntityType, generateStratosEntities, stratosEntityFactory } from '@stratosui/store'; import { createBasicStoreModule, createEntityStoreState, @@ -10,7 +10,8 @@ import { STORE_TEST_PROVIDERS, } from '@stratosui/store/testing'; import { AppTestModule } from '@test-framework'; -import { PermissionConfig } from './current-user-permissions.config'; +import { CurrentUserRolesSignalService } from '../signals/current-user-roles-signal.service'; +import { PermissionConfig, PermissionConfigLink } from './current-user-permissions.config'; import { CurrentUserPermissionsService } from './current-user-permissions.service'; import { StratosPermissionStrings, StratosPermissionTypes, StratosScopeStrings } from './stratos-user-permissions.checker'; @@ -570,3 +571,46 @@ describe('CurrentUserPermissionsService', () => { }); + +describe('CurrentUserPermissionsService fallback permission', () => { + // The service is a root singleton, so the endpoint watch behind a fallback + // check must end with the subscription, not with the root injector. + it('stops reading the endpoint after unsubscribe', () => { + const version = signal(0); + let reads = 0; + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + CurrentUserPermissionsService, + { provide: CurrentUserRolesSignalService, useValue: {} }, + { + provide: EndpointsDataService, + // Unknown endpoint: the fallback branch answers false without + // asking a checker. + useValue: { + endpointById: () => computed(() => { + reads++; + version(); + return null; + }), + }, + }, + ], + }); + const service = TestBed.inject(CurrentUserPermissionsService); + + // A link to no known config leaves only the fallback branch. + const values: boolean[] = []; + const sub = service.can(new PermissionConfigLink('no-such-permission'), 'ep-1') + .subscribe(v => values.push(v)); + TestBed.tick(); + expect(values).toEqual([false]); + const readsWhileSubscribed = reads; + + sub.unsubscribe(); + version.set(1); + TestBed.tick(); + + expect(reads).toBe(readsWhileSubscribed); + }); +}); diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts index 8ec579e578..71960d989e 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts @@ -1,6 +1,5 @@ import { EnvironmentInjector, Injectable, inject, runInInjectionContext } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { EndpointsDataService } from '@stratosui/store'; +import { EndpointsDataService, observeSignal } from '@stratosui/store'; import { combineLatest, Observable, of } from 'rxjs'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; @@ -89,8 +88,10 @@ export class CurrentUserPermissionsService { return this.getSimplePermission(actionConfig, endpointGuid, ...args); } else if (endpointGuid) { // W36-B Wave 3: read endpoint via EndpointsDataService signal - // bridge instead of the legacy EntityMonitor.entity$. - return toObservable(this.endpointsData.endpointById(endpointGuid), { injector: this.injector }).pipe( + // bridge instead of the legacy EntityMonitor.entity$. observeSignal + // ties the watch to the subscription; this service's injector is the + // root one, so toObservable would keep one running per call. + return observeSignal(this.endpointsData.endpointById(endpointGuid), this.injector).pipe( // strict: a registered endpoint always carries a cnsi_type; preserve the // original guard (call through when an endpoint resolves, else emit false). switchMap(endpoint => endpoint ? From 387031da7731bcddf57d54643f04e4b9e2da4a2a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:22:28 -0700 Subject: [PATCH 006/121] fix(kube): end helm release watches with the tab The release shell is a reused route, so its helper service lives across tab switches. Each tab visit and graph node click built a new toObservable() on that injector, and the effects piled up until the user left the release. Build the per-call streams with observeSignal() so they end with their subscription. --- .../tabs/helm-release-helper.service.spec.ts | 68 ++++++++++++++++++- .../tabs/helm-release-helper.service.ts | 18 +++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts index 034584c8a2..00b0917840 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts @@ -1,14 +1,15 @@ -import { provideZonelessChangeDetection } from '@angular/core'; +import { computed, provideZonelessChangeDetection, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { firstValueFrom } from 'rxjs'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CATALOGUE_ENTITIES } from '@stratosui/store'; import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; import { generateHelmEntities } from '../../../../helm/helm-entity-generator'; import { KubePodDataService } from '../../../../services/domain-data/kube-pod-data.service'; +import { KubeHelmDataService } from '../../../../services/endpoint-data/kube-helm-data.service'; import { HelmReleaseDataService } from '../helm-release-data.service'; import { HelmReleaseGraph, HelmReleaseGuid, HelmReleaseResources } from '../../workload.types'; import { HelmReleaseHelperService, Version } from './helm-release-helper.service'; @@ -75,6 +76,69 @@ describe('HelmReleaseHelperService', () => { }); }); + // The helper is provided on the release shell, which is reused across tab + // switches, and these methods run per tab visit or per graph node click. + // Each returned stream must stop watching its signal on unsubscribe rather + // than when the shell is destroyed. + describe('per-call streams', () => { + let source: ReturnType>; + let reads: number; + // A computed over `source` that counts its evaluations; `value` maps the + // counter into whatever the stream's filter lets through. + const counted = (value: (n: number) => T) => computed(() => { + reads++; + return value(source()); + }); + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [...KubernetesBaseTestModules], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + HelmReleaseHelperService, + { provide: HelmReleaseGuid, useValue: { guid: RELEASE_GUID } }, + ], + }).compileComponents(); + source = signal(0); + reads = 0; + const releaseData = TestBed.inject(HelmReleaseDataService); + const helmData = TestBed.inject(KubeHelmDataService); + vi.spyOn(releaseData, 'graph').mockReturnValue(counted(n => ({ n }) as unknown as HelmReleaseGraph)); + vi.spyOn(releaseData, 'resources').mockReturnValue(counted(n => ({ n }) as unknown as HelmReleaseResources)); + vi.spyOn(releaseData, 'history').mockReturnValue(counted(() => []) as any); + vi.spyOn(releaseData, 'loadHistory').mockResolvedValue(undefined as any); + vi.spyOn(TestBed.inject(KubePodDataService), 'podsInWorkload').mockReturnValue(counted(() => []) as any); + vi.spyOn(helmData, 'monocularCharts').mockReturnValue(counted(() => []) as any); + vi.spyOn(helmData, 'loadCharts').mockResolvedValue(undefined as any); + }); + + it.each([ + ['fetchReleaseGraph'], + ['fetchReleaseResources'], + ['fetchReleaseChartStats'], + ['getCharts'], + ['fetchReleaseHistory'], + ] as const)('%s stops reading its signal after unsubscribe', method => { + const helper = TestBed.inject(HelmReleaseHelperService); + let emitted = 0; + const sub = (helper[method]() as ReturnType) + .subscribe(() => emitted++); + TestBed.tick(); + expect(emitted).toBe(1); + const readsWhileSubscribed = reads; + + sub.unsubscribe(); + source.set(1); + TestBed.tick(); + + expect(reads).toBe(readsWhileSubscribed); + expect(emitted).toBe(1); + }); + }); + describe('fetchReleaseChartStats', () => { beforeEach(async () => { TestBed.resetTestingModule(); 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 959de767ae..6753b366a3 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 @@ -1,5 +1,6 @@ -import { Injectable, Injector, inject, runInInjectionContext } from '@angular/core'; +import { Injectable, Injector, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; +import { observeSignal } from '@stratosui/store'; import { combineLatest, Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -145,23 +146,28 @@ export class HelmReleaseHelperService { return this.guid.replace(':', '/').replace(':', '/'); } + // The fetch*/getCharts streams are built per call (per tab visit, per graph + // node click) on the release shell, which is reused across tab switches, + // so they use observeSignal: the watch ends with the subscription instead + // of piling up until the user leaves the release. The constructor streams + // above are built once per shell and keep toObservable. public fetchReleaseGraph(): Observable { // Socket-fed graph signal (written by HelmReleaseSocketService). - return toObservable(this.releaseData.graph(this.guid), { injector: this.injector }).pipe( + return observeSignal(this.releaseData.graph(this.guid), this.injector).pipe( filter((graph): graph is HelmReleaseGraph => !!graph) ); } public fetchReleaseResources(): Observable { // Socket-fed resources signal (written by HelmReleaseSocketService). - return toObservable(this.releaseData.resources(this.guid), { injector: this.injector }).pipe( + return observeSignal(this.releaseData.resources(this.guid), this.injector).pipe( filter((resources): resources is HelmReleaseResources => !!resources) ); } public fetchReleaseChartStats(): Observable { const podsSig = this.podData.podsInWorkload(this.endpointGuid, this.namespace, this.releaseTitle); - return runInInjectionContext(this.injector, () => toObservable(podsSig)).pipe( + return observeSignal(podsSig, this.injector).pipe( map((pods: KubePod[]) => this.mapPods(pods as any)), ); } @@ -169,7 +175,7 @@ export class HelmReleaseHelperService { // Check to see if a workload has updates available public getCharts() { void this.helmData.loadCharts(); - return toObservable(this.helmData.monocularCharts(), { injector: this.injector }).pipe( + return observeSignal(this.helmData.monocularCharts(), this.injector).pipe( filter((charts: any) => !!charts) ); } @@ -178,7 +184,7 @@ export class HelmReleaseHelperService { // Get the history for a Helm release (signal-native; replaces the ngrx // `history` entity-catalog getEntityService). void this.releaseData.loadHistory(this.endpointGuid, this.namespace, this.releaseTitle); - return toObservable(this.releaseData.history(this.guid), { injector: this.injector }).pipe( + return observeSignal(this.releaseData.history(this.guid), this.injector).pipe( filter((revisions): revisions is HelmReleaseRevision[] => !!revisions) ); } From e9bceeddbbda35f7d1a5634f2c87a552a08fd5bf Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:37:22 -0700 Subject: [PATCH 007/121] fix(kube): keep a kube config import moving The import passed each row's signal wrapper to subscribe() as the observer. RxJS took the callable as next(), and the wrapper's callable only reads its signal, so every connect status was dropped: each Connect row stalled and the clusters after it were never imported. Forward each status to the row explicitly, complete the row as failed when the connect errors, and move on to the next action when a row has no user or no usable auth data. --- .../kube-config-import.component.spec.ts | 89 ++++++++++++++++++- .../kube-config-import.component.ts | 16 +++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts index 690db30d1b..0d00b9700f 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -3,10 +3,13 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EndpointsDataService } from '@stratosui/store'; import { EndpointsSignalConfigService, } from '../../../../../core/src/features/endpoints/endpoints-page/endpoints-signal-config.service'; import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubeConfigAuthHelper } from '../kube-config-auth.helper'; +import { KubeConfigFileCluster } from '../kube-config.types'; import { KubeConfigImportComponent } from './kube-config-import.component'; function makeStubEndpointsSignalConfig() { @@ -18,6 +21,32 @@ function makeStubEndpointsSignalConfig() { }; } +function makeCluster(name: string): KubeConfigFileCluster { + return { + name, + cluster: { + 'certificate-authority': '', + 'certificate-authority-data': '', + 'insecure-skip-tls-verify': true, + server: `https://${name}.example.com`, + }, + _user: 'admin', + _users: [{ name: 'admin', user: { token: 't' }, _authData: { authType: 'token', subType: '', values: {} } as any }], + _selected: true, + _invalid: false, + _state: undefined as any, + _status: '', + _guid: '', + _additionalUserInfo: false, + _id: name, + }; +} + +// Row wrappers are typed as Subject-or-signal; these are signal wrappers. +const read = (wrapper: unknown) => (wrapper as () => unknown)(); + +const AUTH_DATA = { authType: 'token', authVal: { token: 't' }, systemShared: false, bodyContent: '' }; + describe('KubeConfigImportComponent', () => { let component: KubeConfigImportComponent; let fixture: ComponentFixture; @@ -46,13 +75,69 @@ describe('KubeConfigImportComponent', () => { ]}).compileComponents(); }); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(KubeConfigImportComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + describe('import of several clusters', () => { + let connect: ReturnType; + + beforeEach(() => { + connect = vi.fn().mockResolvedValue({ busy: false, error: false, message: '' }); + vi.spyOn(TestBed.inject(EndpointsDataService), 'connect').mockImplementation(connect as any); + }); + + async function runImport(clusters: KubeConfigFileCluster[]) { + component.onEnter(clusters); + const actions = [...component.data()]; + component.onNext(1, undefined as any); + await vi.waitFor(() => expect(component.done()).toBe(true)); + await fixture.whenStable(); + return actions; + } + + it('connects each cluster and moves on to the next', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(actions.map(a => a.action)).toEqual([ + 'Register endpoint', 'Connect endpoint', 'Register endpoint', 'Connect endpoint', + ]); + expect(read(actions[1].actionState)).toEqual({ busy: false, error: false, completed: true, message: '' }); + expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledTimes(2); + expect(component.busy()).toBe(false); + }); + + it('marks a failed connect and still imports the next cluster', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); + connect.mockRejectedValueOnce(new Error('connect blew up')); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(read(actions[1].actionState)).toMatchObject({ error: true, completed: true }); + expect(read(actions[1].state)).toEqual({ message: 'connect blew up', error: true }); + expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledTimes(2); + }); + + it('moves on when a cluster has no usable auth data', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect') + .mockReturnValueOnce(null) + .mockReturnValue(AUTH_DATA as any); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(read(actions[1].state)).toEqual({ message: 'Can not connect - could not get user auth data', error: true }); + expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts index 5dd6463da3..bc6e6e2cf5 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -274,6 +274,7 @@ export class KubeConfigImportComponent implements OnDestroy { private doConnect(connect: KubeConfigImportAction, next: KubeConfigImportAction[]) { if (!connect.user) { connect.state.next({ message: 'Can not connect - no user specified', error: true }); + this.processAction(next); return; } const helper = new KubeConfigAuthHelper(); @@ -281,8 +282,18 @@ export class KubeConfigImportComponent implements OnDestroy { if (data) { const obs$ = this.connectEndpoint(connect, data); - // Echo obs$ to the behaviour subject - this.subs.push(obs$.subscribe(connect.actionState)); + // Echo obs$ to the row's action state. The wrapper is not an observer + // (calling it reads the signal), so forward each value explicitly; a + // failed connect completes the row as an error so the import goes on. + this.subs.push(obs$.subscribe({ + next: status => connect.actionState.next(status), + error: err => connect.actionState.next({ + busy: false, + error: true, + completed: true, + message: err?.message ?? 'Failed to connect endpoint', + }), + })); this.subs.push(connect.actionState.asObservable().pipe( filter((status: IActionMonitorComponentState) => status.completed), @@ -295,6 +306,7 @@ export class KubeConfigImportComponent implements OnDestroy { })); } else { connect.state.next({ message: 'Can not connect - could not get user auth data', error: true }); + this.processAction(next); } } From 04f4dbbc2d0338e4de82c4b3dd8e0d3139b3ec09 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:37:22 -0700 Subject: [PATCH 008/121] fix(store): return one observable from a signal wrapper asObservable() returned a new observable on every call. A template that binds it directly would resubscribe on every change detection pass and get the BehaviorSubject's last value replayed each time. Build it once, as the private copies did. --- .../packages/store/src/helpers/signal-interop.spec.ts | 8 ++++++++ src/frontend/packages/store/src/helpers/signal-interop.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/store/src/helpers/signal-interop.spec.ts b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts index 7437ae7e34..95ad30bdb7 100644 --- a/src/frontend/packages/store/src/helpers/signal-interop.spec.ts +++ b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts @@ -151,6 +151,14 @@ describe('signal-interop', () => { expect(values).toEqual(['a']); }); + it('returns the same observable on every asObservable() call', () => { + // Templates bind asObservable() directly; a new instance per change + // detection pass would resubscribe and replay the last value. + const wrapper = createSignalWrapper(0); + + expect(wrapper.asObservable()).toBe(wrapper.asObservable()); + }); + it('delivers every synchronous write to a subscriber', () => { const wrapper = createSignalWrapper(0); const values: number[] = []; diff --git a/src/frontend/packages/store/src/helpers/signal-interop.ts b/src/frontend/packages/store/src/helpers/signal-interop.ts index 43950093a4..b1e050dbf5 100644 --- a/src/frontend/packages/store/src/helpers/signal-interop.ts +++ b/src/frontend/packages/store/src/helpers/signal-interop.ts @@ -38,6 +38,7 @@ export function combineLatestOrEmpty(sources: Observable[]): Observable false` when callers mutate a value in place and write * the same reference again; the default equality would drop those updates @@ -46,6 +47,7 @@ export function combineLatestOrEmpty(sources: Observable[]): Observable(initialValue: T, options?: { equal?: ValueEqualityFn }) { const _signal = signal(initialValue, options?.equal ? { equal: options.equal } : undefined); const _subject = new BehaviorSubject(initialValue); + const _observable = _subject.asObservable(); const write = (value: T) => { _signal.set(value); _subject.next(value); @@ -58,7 +60,7 @@ export function createSignalWrapper(initialValue: T, options?: { equal?: Valu asReadonly: () => _signal.asReadonly(), next: write, getValue: () => _signal(), - asObservable: () => _subject.asObservable(), + asObservable: () => _observable, } ); return wrapper as WritableSignal & { From c2252aaa9c980b61a3f5e466005a5613a3ec198f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:37:23 -0700 Subject: [PATCH 009/121] fix(deploy): keep deploy updates after leaving the page The deployer's signal wrappers used toObservable() on the step's injector. Leaving the deploy page destroyed that injector in the same pass that subscribed the completion notice, so the stream completed empty: no success or failure snackbar and no close(). toObservable() also coalesced writes per change detection pass, so a burst of log frames lost lines in the deploy log. Use the shared BehaviorSubject-backed createSignalWrapper(), and build the deploy state stream once instead of a new toObservable() on every open(). --- .../deploy-application-deployer.spec.ts | 33 +++++++++- .../deploy-application-deployer.ts | 64 ++++++------------- ...deploy-application-step3.component.spec.ts | 48 +++++++++++++- 3 files changed, 97 insertions(+), 48 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index 6d58ca0f58..e4f0d2c88b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { Injector } from '@angular/core'; +import { ApplicationRef, Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SocketEventTypes } from '../../../store/types/deploy-application.types'; @@ -21,4 +21,35 @@ describe('DeployApplicationDeployer', () => { expect(deployer.streamTitle).toBe('Deploy Failed - No manifest present!'); expect(deployer.status$().errorMsg).toContain('enter an application name'); }); + + it('delivers every log line of a synchronous burst', async () => { + const received: string[] = []; + deployer.messages.asObservable().subscribe(m => received.push(m)); + const lines = ['one\n', 'two\n', 'three\n', 'four\n']; + + // The socket handler writes each completed line as its frame arrives, + // several within one task when frames come in together. + lines.forEach(line => deployer.messages.next(line)); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(received.filter(m => !!m)).toEqual(lines); + }); + + it('reports each status change of a burst', async () => { + const deploying: boolean[] = []; + deployer.status$.asObservable().subscribe(s => deploying.push(s.deploying)); + + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(deploying).toEqual([false, true, false]); + }); + + it('returns the same observable on every asObservable() call', () => { + // Templates bind asObservable() directly; a new instance per change + // detection pass would resubscribe and replay the last value. + expect(deployer.messages.asObservable()).toBe(deployer.messages.asObservable()); + expect(deployer.fileTransferStatus$.asObservable()).toBe(deployer.fileTransferStatus$.asObservable()); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index bb9ceb7ca4..aa15f945a4 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -1,5 +1,4 @@ -import { Injector, signal, WritableSignal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Injector } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import websocketConnect from 'rxjs-websockets'; import { take, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'; @@ -11,45 +10,18 @@ import { OverrideAppDetails, SocketEventTypes } from '../../../../../cloud-foundry/src/store/types/deploy-application.types'; import { environment } from '../../../../../core/src/environments/environment.prod'; +import { createSignalWrapper, observeSignal } from '@stratosui/store'; import { CfDeployAppDataService } from '../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; import { FileScannerInfo } from './deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DEPLOY_TYPES_IDS } from './deploy-application-steps.types'; -// Helper function to create a signal wrapper compatible with BehaviorSubject API -// The wrapper provides BehaviorSubject-like API (.next, .getValue, .asObservable) -// while being backed by a Signal for fine-grained reactivity. -// -// `equal: () => false` makes every set() emit, restoring BehaviorSubject's -// "always notify" semantics. The deployer code mutates state objects in place -// (e.g. fileTransferStatus.filesSent++) and re-emits the same reference; -// without this, the default Object.is dedupe silently drops every update -// after the first and the upload progress UI freezes. -function createSignalWrapper(initialValue: T, injector: Injector) { - const _signal = signal(initialValue, { equal: () => false }); - // Create the observable once during initialization, outside of any reactive context - // This prevents NG0602 errors when asObservable() is called from reactive contexts - const _observable = toObservable(_signal, { injector }); - - const wrapper = Object.assign( - // Make it callable like a signal - () => _signal(), - { - // WritableSignal methods - set: (value: T) => _signal.set(value), - update: (fn: (value: T) => T) => _signal.update(fn), - asReadonly: () => _signal.asReadonly(), - // BehaviorSubject compatibility methods - next: (value: T) => _signal.set(value), - getValue: () => _signal(), - asObservable: () => _observable } - ); - return wrapper as WritableSignal & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable; - }; -} +// `equal: () => false` on every wrapper below makes each write notify the +// signal too. The deployer mutates state objects in place (e.g. +// fileTransferStatus.filesSent++) and re-emits the same reference; the +// default Object.is dedupe would drop every update after the first and the +// upload progress UI would freeze. +const alwaysNotify = { equal: () => false }; export interface DeployApplicationDeployerStatus { error: boolean; @@ -131,6 +103,8 @@ export class DeployApplicationDeployer { private inputStream: any; + private deployState$: Observable; + private isOpen = false; // The backend can follow a specific close message with a generic one; @@ -150,13 +124,17 @@ export class DeployApplicationDeployer { this.status$ = createSignalWrapper({ error: false, deploying: false - }, this.injector); + }, alwaysNotify); + + this.applicationGuid$ = createSignalWrapper(null, alwaysNotify); - this.applicationGuid$ = createSignalWrapper(null, this.injector); + this.fileTransferStatus$ = createSignalWrapper(undefined, alwaysNotify); - this.fileTransferStatus$ = createSignalWrapper(undefined, this.injector); + this.messages = createSignalWrapper('', alwaysNotify); - this.messages = createSignalWrapper('', this.injector); + // Built once; each subscription runs its own effect and ends it on + // unsubscribe, so repeated open()/close() leaves nothing behind. + this.deployState$ = observeSignal(this.injector.get(CfDeployAppDataService).state, this.injector); } updateStatus(error = false, errorMsg?: string) { @@ -199,9 +177,7 @@ export class DeployApplicationDeployer { }; this.isOpen = true; this.closeReported = false; - const deployData = this.injector.get(CfDeployAppDataService); - const deployState$ = toObservable(deployData.state, { injector: this.injector }); - this.connectSub = deployState$.pipe( + this.connectSub = this.deployState$.pipe( filter((appDetail): appDetail is DeployApplicationState & { cloudFoundryDetails: NonNullable; } => !!appDetail && !!appDetail.cloudFoundryDetails && readyFilter(appDetail)), take(1), @@ -262,7 +238,7 @@ export class DeployApplicationDeployer { ).subscribe(); // Watch for updates to the app overrides - use case is app overrides being set after source file/folder upload - this.updateSub = deployState$.pipe( + this.updateSub = this.deployState$.pipe( filter((appDetail): appDetail is DeployApplicationState => !!appDetail && !!appDetail.cloudFoundryDetails && readyFilter(appDetail)), tap((appDetail) => { if (appDetail.applicationOverrides) { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 37f20778a2..def07b8899 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -1,13 +1,16 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; +import { ApplicationRef, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; +import { NEVER } from 'rxjs'; import { generateCfBaseTestModulesNoShared } from "@test-framework/cf"; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; +import { TailwindSnackBarService } from '@stratosui/core'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; +import { SocketEventTypes } from '../../../../store/types/deploy-application.types'; import { DeployApplicationStep3Component } from "./deploy-application-step3.component"; describe('DeployApplicationStep3Component', () => { @@ -32,10 +35,49 @@ describe('DeployApplicationStep3Component', () => { fixture = TestBed.createComponent(DeployApplicationStep3Component); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('reports a deploy that finishes after the user leaves the page', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); + + component.onEnter(undefined as any); + await fixture.whenStable(); + const deployer = component.deployer; + const close = vi.spyOn(deployer, 'close'); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + await fixture.whenStable(); + + // Leave the page mid-deploy, then let the deploy finish. + fixture.destroy(); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(show).toHaveBeenCalledWith('Application deployment complete', 'View', 10000, true); + expect(close).toHaveBeenCalled(); + }); + + it('reports a deploy that fails after the user leaves the page', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); + + component.onEnter(undefined as any); + await fixture.whenStable(); + const deployer = component.deployer; + const close = vi.spyOn(deployer, 'close'); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + await fixture.whenStable(); + + fixture.destroy(); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_PUSH_ERROR, message: 'boom' }); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(show).toHaveBeenCalledWith(expect.stringContaining('Reason: boom'), 'Dismiss'); + expect(close).toHaveBeenCalled(); + }); }); From 26782531a3e0f227e79833fa6418d5deb2c569c6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:37:23 -0700 Subject: [PATCH 010/121] refactor(kube): use the shared signal wrapper Drop the private createSignalWrapper() copies in the kube config helper, the config import and the namespaces filter for the store one. The import's per-row observable cache goes too: asObservable() now returns the same instance on every call. --- .../kube-config-import.component.ts | 55 ++----------------- .../kube-config.helper.ts | 42 +------------- .../kube-namespaces-filter-config.service.ts | 34 +----------- 3 files changed, 11 insertions(+), 120 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts index bc6e6e2cf5..72477f1632 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -1,10 +1,10 @@ import { ChangeDetectionStrategy, Component, EnvironmentInjector, Injector, OnDestroy, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; import { UntypedFormBuilder } from '@angular/forms'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; import { filter, map, startWith, take } from 'rxjs/operators'; -import { EndpointsDataService } from '@stratosui/store'; +import { createSignalWrapper, EndpointsDataService } from '@stratosui/store'; import { EndpointsService } from '../../../../../core/src/core/endpoints.service'; import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; @@ -38,43 +38,6 @@ import { KubeConfigFileCluster, KubeConfigImportAction } from '../kube-config.ty const REGISTER_ACTION = 'Register endpoint'; const CONNECT_ACTION = 'Connect endpoint'; -/** - * Signal wrapper utility for BehaviorSubject compatibility - * Provides dual API: Signal methods + BehaviorSubject compatibility (.next(), .asObservable()) - * Enables zero-breaking-change migration from BehaviorSubject to Signal - */ -function createSignalWrapper(initialValue: T) { - const _signal = signal(initialValue); - // asObservable() must be callable outside injection contexts (wrappers are - // created and read during import execution, from step event handlers), so - // it is backed by a BehaviorSubject kept in sync on every write instead of - // a lazy toObservable(), which throws NG0203 there. - const _subject = new BehaviorSubject(initialValue); - const write = (value: T) => { - _signal.set(value); - _subject.next(value); - }; - const wrapper = Object.assign( - // Make it callable like a signal - () => _signal(), - { - // WritableSignal methods - set: write, - update: (fn: (value: T) => T) => write(fn(_signal())), - asReadonly: () => _signal.asReadonly(), - // BehaviorSubject compatibility methods - next: write, - getValue: () => _signal(), - asObservable: () => _subject.asObservable(), - } - ); - return wrapper as WritableSignal & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable; - }; -} - @Component({ selector: 'app-kube-config-import', templateUrl: './kube-config-import.component.html', @@ -159,17 +122,11 @@ export class KubeConfigImportComponent implements OnDestroy { ]; } - // Stable per-row Observable for the action-monitor icon. `actionState`'s - // asObservable() mints a fresh observable each call; cache by action so the - // template binding doesn't re-subscribe every change-detection pass. - private readonly actionStateObs = new WeakMap>(); + // Per-row Observable for the action-monitor icon. asObservable() returns + // the same instance on every call, so the template binding does not + // re-subscribe each change-detection pass. actionStateFor(row: KubeConfigImportAction): Observable { - let obs = this.actionStateObs.get(row); - if (!obs) { - obs = row.actionState.asObservable(); - this.actionStateObs.set(row, obs); - } - return obs; + return row.actionState.asObservable(); } subs: Subscription[] = []; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts index 1144e53b11..8bcd4fb2e5 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts @@ -1,52 +1,16 @@ -import { Injectable, signal, WritableSignal, inject } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import * as yaml from 'js-yaml'; -import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; +import { combineLatest, Observable, of } from 'rxjs'; import { filter, map, take, tap } from 'rxjs/operators'; import { EndpointsService } from '../../../../core/src/core/endpoints.service'; import { createGuid } from '../../../../core/src/core/utils.service'; import { RowState } from '../../../../core/src/shared/components/signal-list/row-state.types'; import { getFullEndpointApiUrl } from '../../../../store/src/endpoint-utils'; -import { EndpointModel } from '../../../../store/src/public-api'; +import { createSignalWrapper, EndpointModel } from '../../../../store/src/public-api'; import { KubeConfigAuthHelper } from './kube-config-auth.helper'; import { KubeConfigFile, KubeConfigFileCluster } from './kube-config.types'; -/** - * Signal wrapper providing dual API compatibility with BehaviorSubject - * Allows gradual migration from BehaviorSubject to Signal patterns - */ -function createSignalWrapper(initialValue: T) { - const _signal = signal(initialValue); - // asObservable() must be callable outside injection contexts (wrappers are - // created and read from event handlers, e.g. the file-input parse), so it - // is backed by a BehaviorSubject kept in sync on every write instead of a - // lazy toObservable(), which throws NG0203 there. - const _subject = new BehaviorSubject(initialValue); - const write = (value: T) => { - _signal.set(value); - _subject.next(value); - }; - const wrapper = Object.assign( - // Make it callable like a signal - () => _signal(), - { - // WritableSignal methods - set: write, - update: (fn: (value: T) => T) => write(fn(_signal())), - asReadonly: () => _signal.asReadonly(), - // BehaviorSubject compatibility methods - next: write, - getValue: () => _signal(), - asObservable: () => _subject.asObservable(), - } - ); - return wrapper as WritableSignal & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable; - }; -} - /** * Helper to parse the kubeconfig and transform it into data * that we can display in a table for selection diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts index 1b0f9402dc..b726b4effe 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts @@ -1,7 +1,7 @@ -import { Injectable, OnDestroy, signal, WritableSignal, computed, Injector, inject, runInInjectionContext } from '@angular/core'; +import { Injectable, OnDestroy, signal, computed, Injector, inject, runInInjectionContext } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { EndpointsSignalService, naturalCompare, safeUnsubscribe } from '@stratosui/core'; -import { EndpointModel } from '@stratosui/store'; +import { createSignalWrapper, EndpointModel } from '@stratosui/store'; import { Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, @@ -15,36 +15,6 @@ import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes-entity-factory'; import { KubeNamespaceDataService } from '../../../services/domain-data/kube-namespace-data.service'; import { KubeNamespace } from '../../../services/endpoint-data/kube-types'; -// Helper function to create a signal wrapper compatible with IListMultiFilterConfig -// The wrapper provides BehaviorSubject-like API (.next, .getValue, .asObservable) -// while being backed by a Signal -// MUST be called within an injection context (constructor, field initializer, or runInInjectionContext) -function createSignalWrapper(initialValue: T) { - const _signal = signal(initialValue); - // Convert signal to observable within injection context once - const _observable = toObservable(_signal); - - const wrapper = Object.assign( - // Make it callable like a signal - () => _signal(), - { - // WritableSignal methods - set: (value: T) => _signal.set(value), - update: (fn: (value: T) => T) => _signal.update(fn), - asReadonly: () => _signal.asReadonly(), - // BehaviorSubject compatibility methods - next: (value: T) => _signal.set(value), - getValue: () => _signal(), - asObservable: () => _observable, - } - ); - return wrapper as WritableSignal & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable; - }; -} - export interface KubernetesNamespacesFilterItem { list$: Observable; loading$: Observable; From 830712a59da4f5a4335af639218e5c5248e09890 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:56:17 -0700 Subject: [PATCH 011/121] fix(permissions): answer checks over no endpoints With no CF endpoint connected, the per-endpoint scope, role and feature-flag checks combined an empty list with combineLatest(), which completes without a value, so they never answered. can([]) did the same in CurrentUserPermissionsService. Use combineLatestOrEmpty(): an empty set answers false, and an empty permission config is denied. reduceChecks([]) answered true for both combinators, so with no CF connected the admin check said "admin" and every org, space and complex CF permission without an endpoint passed (the app wall showed Create Application). An empty list now gives some([]) = false for || and every([]) = true for &&. --- .../cf-user-permissions-checkers.spec.ts | 63 ++++++++++++++++++- .../cf-user-permissions-checkers.ts | 8 +-- .../current-user-permissions.service.spec.ts | 33 +++++++++- .../current-user-permissions.service.ts | 9 +-- .../current-user-permissions.types.ts | 3 +- 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts index 07ef36f938..fbe7af6d92 100644 --- a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts @@ -10,7 +10,7 @@ import { StratosCurrentUserPermissions, } from '@stratosui/core'; import { EndpointsDataService, SessionData } from '@stratosui/store'; -import { firstValueFrom, of } from 'rxjs'; +import { firstValueFrom, Observable, of, timeout } from 'rxjs'; import { describe, expect, it, beforeEach, vi } from 'vitest'; import { CFFeatureFlagTypes } from '../cf-api.types'; @@ -18,7 +18,7 @@ import { StFeatureFlag } from '../services/endpoint-data/stratos-types'; import { CfCurrentUserRolesSignalService } from './cf-current-user-roles-signal.service'; import { CfUserPermissionsChecker } from './cf-user-permissions-checkers'; -import { CfPermissionTypes } from './cf-user-permissions.types'; +import { CfPermissionStrings, CfPermissionTypes, CfScopeStrings } from './cf-user-permissions.types'; import { getFeatureFlagsSource } from './feature-flags-cache'; /** @@ -166,3 +166,62 @@ describe('CfUserPermissionsChecker feature flag check', () => { expect(values).toEqual([true]); }); }); + +describe('CfUserPermissionsChecker across all connected CF endpoints', () => { + function setup(connectedGuids: string[], hasScope: (guid: string) => boolean = () => false) { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { + provide: CfCurrentUserRolesSignalService, + useValue: { + connectedCfEndpointGuids$: () => of(connectedGuids), + cfEndpointHasScope$: (guid: string) => of(hasScope(guid)), + }, + }, + { provide: HttpClient, useValue: {} }, + CfUserPermissionsChecker, + ], + }); + return TestBed.inject(CfUserPermissionsChecker); + } + + // combineLatest([]) completes without a value, so a check over zero + // endpoints must still answer. + const answer = (check$: Observable) => firstValueFrom(check$.pipe(timeout(1000))); + + it('denies an endpoint scope when no CF endpoint is connected', async () => { + const checker = setup([]); + const check$ = checker.getSimpleCheck( + new PermissionConfig(CfPermissionTypes.ENDPOINT_SCOPE, CfScopeStrings.CF_ADMIN_GROUP), + ); + await expect(answer(check$!)).resolves.toBe(false); + }); + + it('denies a feature flag when no CF endpoint is connected', async () => { + const checker = setup([]); + const check$ = checker.getSimpleCheck( + new PermissionConfig(CfPermissionTypes.FEATURE_FLAG, CFFeatureFlagTypes.route_creation), + ); + await expect(answer(check$!)).resolves.toBe(false); + }); + + it('denies an org role with no org guid when no CF endpoint is connected', async () => { + const checker = setup([]); + const check$ = checker.getSimpleCheck( + new PermissionConfig(CfPermissionTypes.ORGANIZATION, CfPermissionStrings.ORG_MANAGER), + ); + await expect(answer(check$!)).resolves.toBe(false); + }); + + it('answers the org role check itself when no CF endpoint is connected', async () => { + const checker = setup([]); + // With zero endpoints the read-only check (every([]) is true) denies + // before getSimpleCheck reads the role check, so read it directly. + const check$ = checker['getCfCheck']( + new PermissionConfig(CfPermissionTypes.ORGANIZATION, CfPermissionStrings.ORG_MANAGER), + ); + await expect(answer(check$)).resolves.toBe(false); + }); + +}); 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 bd734eb76f..1c6e6e8a9f 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 @@ -15,7 +15,7 @@ import { PermissionConfigLink, PermissionTypes, } from '@stratosui/core'; -import { observeSignal, PermissionValues } from '@stratosui/store'; +import { combineLatestOrEmpty, observeSignal, PermissionValues } from '@stratosui/store'; import { CFFeatureFlagTypes } from '../cf-api.types'; import { CF_ENDPOINT_TYPE } from '../cf-types'; import { StFeatureFlag } from '../services/endpoint-data/stratos-types'; @@ -205,7 +205,7 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker private getEndpointScopesCheck(permission: CfScopeStrings, endpointGuid?: string): Observable { const endpointGuids$ = this.getEndpointGuidObservable(endpointGuid); return endpointGuids$.pipe( - switchMap(guids => combineLatest(guids.map(_guid => this.check(CfPermissionTypes.ENDPOINT_SCOPE, permission, endpointGuid)))), + switchMap(guids => combineLatestOrEmpty(guids.map(_guid => this.check(CfPermissionTypes.ENDPOINT_SCOPE, permission, endpointGuid)))), map(checks => checks.some(check => check)), distinctUntilChanged() ); @@ -240,7 +240,7 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker } else if (!actualGuid) { const endpointGuids$ = this.getEndpointGuidObservable(endpointGuid); return endpointGuids$.pipe( - switchMap(guids => combineLatest(guids.map(guid => this.checkAllOfType(guid, type as CfPermissionTypes, cfPermissions)))), + switchMap(guids => combineLatestOrEmpty(guids.map(guid => this.checkAllOfType(guid, type as CfPermissionTypes, cfPermissions)))), map(checks => checks.some(check => check)), distinctUntilChanged() ); @@ -279,7 +279,7 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker () => observeSignal(src.items, this.injector), ); }; - return combineLatest(guids.map(createFFObs)); + return combineLatestOrEmpty(guids.map(createFFObs)); }), map(endpointFeatureFlags => endpointFeatureFlags.some(featureFlags => this.checkFeatureFlag(featureFlags, permission))), distinctUntilChanged(), diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts index 9de406835f..f960943ffe 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { computed, provideZonelessChangeDetection, signal } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, timeout } from 'rxjs'; import { AppState, BaseEntityValues, CurrentUserRolesDataService, EndpointsDataService, EntityCatalogEntityConfig, EntityCatalogTestModule, EndpointModel, PaginationEntityState, TEST_CATALOGUE_ENTITIES, endpointEntityType, generateStratosEntities, stratosEntityFactory } from '@stratosui/store'; import { createBasicStoreModule, @@ -13,6 +13,7 @@ import { AppTestModule } from '@test-framework'; import { CurrentUserRolesSignalService } from '../signals/current-user-roles-signal.service'; import { PermissionConfig, PermissionConfigLink } from './current-user-permissions.config'; import { CurrentUserPermissionsService } from './current-user-permissions.service'; +import { BaseCurrentUserPermissionsChecker } from './current-user-permissions.types'; import { StratosPermissionStrings, StratosPermissionTypes, StratosScopeStrings } from './stratos-user-permissions.checker'; @@ -614,3 +615,33 @@ describe('CurrentUserPermissionsService fallback permission', () => { expect(reads).toBe(readsWhileSubscribed); }); }); + +describe('CurrentUserPermissionsService empty complex config', () => { + // An empty config array leaves no check groups. combineLatest([]) completes + // without a value, so the service must still answer. + it('denies an empty permission config', async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + CurrentUserPermissionsService, + { provide: CurrentUserRolesSignalService, useValue: {} }, + { provide: EndpointsDataService, useValue: {} }, + ], + }); + const service = TestBed.inject(CurrentUserPermissionsService); + + await expect(firstValueFrom(service.can([]).pipe(timeout(1000)))).resolves.toBe(false); + }); +}); + +describe('BaseCurrentUserPermissionsChecker.reduceChecks', () => { + // An empty list answers with the identity of its combinator, as + // some([]) / every([]) do. + it('answers false for an empty list combined with ||', async () => { + await expect(firstValueFrom(BaseCurrentUserPermissionsChecker.reduceChecks([], '||'))).resolves.toBe(false); + }); + + it('answers true for an empty list combined with &&', async () => { + await expect(firstValueFrom(BaseCurrentUserPermissionsChecker.reduceChecks([], '&&'))).resolves.toBe(true); + }); +}); diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts index 71960d989e..a3a3171c68 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.ts @@ -1,6 +1,6 @@ import { EnvironmentInjector, Injectable, inject, runInInjectionContext } from '@angular/core'; -import { EndpointsDataService, observeSignal } from '@stratosui/store'; -import { combineLatest, Observable, of } from 'rxjs'; +import { combineLatestOrEmpty, EndpointsDataService, observeSignal } from '@stratosui/store'; +import { Observable, of } from 'rxjs'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { @@ -160,8 +160,9 @@ export class CurrentUserPermissionsService { const reducedChecks = checkCombiners.map(combiner => BaseCurrentUserPermissionsChecker.reduceChecks(combiner.checks, combiner.combineType) ); - return combineLatest(reducedChecks).pipe( - map(checks => checks.every(check => check)) + return combineLatestOrEmpty(reducedChecks).pipe( + // No check groups (empty config): deny rather than grant by default. + map(checks => checks.length > 0 && checks.every(check => check)) ); } diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.types.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.types.ts index 7719113520..620743da69 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.types.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.types.ts @@ -53,7 +53,8 @@ export abstract class BaseCurrentUserPermissionsChecker { public static reduceChecks(checks: Observable[], type: IPermissionCheckCombineTypes = '||') { const func = type === '||' ? 'some' : 'every'; if (!checks || !checks.length) { - return of(true); + // Identity of the combinator, as some([]) / every([]) give. + return of(type === '&&'); } return combineLatest(checks).pipe( map(flags => flags[func](flag => flag)), From 4001eea95a818a09fa4dd0508c6a2d98c825154a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 05:56:17 -0700 Subject: [PATCH 012/121] fix(cf): check endpoint scopes on each connected CF A scope check across all connected CF endpoints mapped each guid to a check of the outer, undefined endpoint guid, so every element answered false and a user with the scope on a connected CF was denied (e.g. the read-only admin and global auditor entries of APPLICATION_VIEW and SPACE_VIEW, FIREHOSE_VIEW). Check each endpoint's own guid. --- .../user-permissions/cf-user-permissions-checkers.spec.ts | 7 +++++++ .../src/user-permissions/cf-user-permissions-checkers.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts index fbe7af6d92..56d776373f 100644 --- a/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/user-permissions/cf-user-permissions-checkers.spec.ts @@ -224,4 +224,11 @@ describe('CfUserPermissionsChecker across all connected CF endpoints', () => { await expect(answer(check$)).resolves.toBe(false); }); + it('checks an endpoint scope on each connected endpoint', async () => { + const checker = setup(['cf-1', 'cf-2'], guid => guid === 'cf-2'); + const check$ = checker.getSimpleCheck( + new PermissionConfig(CfPermissionTypes.ENDPOINT_SCOPE, CfScopeStrings.CF_ADMIN_GROUP), + ); + await expect(answer(check$!)).resolves.toBe(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 1c6e6e8a9f..bbedebe31e 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 @@ -205,7 +205,7 @@ export class CfUserPermissionsChecker extends BaseCurrentUserPermissionsChecker private getEndpointScopesCheck(permission: CfScopeStrings, endpointGuid?: string): Observable { const endpointGuids$ = this.getEndpointGuidObservable(endpointGuid); return endpointGuids$.pipe( - switchMap(guids => combineLatestOrEmpty(guids.map(_guid => this.check(CfPermissionTypes.ENDPOINT_SCOPE, permission, endpointGuid)))), + switchMap(guids => combineLatestOrEmpty(guids.map(guid => this.check(CfPermissionTypes.ENDPOINT_SCOPE, permission, guid)))), map(checks => checks.some(check => check)), distinctUntilChanged() ); From cc1ddf6dfb800f27f0a1b8f7763ad3937450a73c Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:10:46 -0700 Subject: [PATCH 013/121] fix(setup): check UAA credentials before going on Step 1 started the scope check, then read the setup state through a toObservable() stream that still replayed the previous value, so the first click succeeded with no scopes whatever the credentials were, and a retry reported the previous attempt. Finish could then save an empty admin scope, which leaves the backend in setup mode. Finish waited with take(10) on replayed streams; when the backend never confirmed it ended in an EmptyError without clearing the "Saving configuration" overlay, which hid the error until a reload. Await each request and read the state it left. Step 1 fails when the UAA returns no scopes; Finish is enabled only for a returned scope. After the save, waitForSetupConfirmed() polls session verification with a 30 s deadline, and any failure clears the overlay so the error shows. --- .../src/features/setup/setup-confirmation.ts | 51 ++++ .../console-uaa-wizard.component.html | 5 +- .../console-uaa-wizard.component.spec.ts | 229 +++++++++++++++--- .../console-uaa-wizard.component.ts | 143 +++++------ 4 files changed, 307 insertions(+), 121 deletions(-) create mode 100644 src/frontend/packages/core/src/features/setup/setup-confirmation.ts diff --git a/src/frontend/packages/core/src/features/setup/setup-confirmation.ts b/src/frontend/packages/core/src/features/setup/setup-confirmation.ts new file mode 100644 index 0000000000..db707740cc --- /dev/null +++ b/src/frontend/packages/core/src/features/setup/setup-confirmation.ts @@ -0,0 +1,51 @@ +import { AuthDataService } from '@stratosui/store'; + +export const SETUP_CONFIRM_INTERVAL_MS = 2000; +export const SETUP_CONFIRM_TIMEOUT_MS = 30000; + +export const SETUP_NOT_CONFIRMED_MESSAGE = + 'The configuration was saved, but the backend did not confirm the setup in time. Reload the page to check it.'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * After a setup save, poll session verification until the backend leaves + * setup mode (the verify no longer answers "setup required"). + * + * Resolves `false` when the backend has not confirmed within `timeoutMs`, + * including when a verify request itself never answers, so callers can + * always clear their busy state. + */ +export async function waitForSetupConfirmed( + authData: AuthDataService, + intervalMs = SETUP_CONFIRM_INTERVAL_MS, + timeoutMs = SETUP_CONFIRM_TIMEOUT_MS, +): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + + const poll = async (): Promise => { + while (!timedOut) { + await authData.verifySession(true, true); + const session = authData.sessionData(); + if (session && !session.uaaError) { + return true; + } + await sleep(intervalMs); + } + return false; + }; + + const deadline = new Promise(resolve => { + timer = setTimeout(() => { + timedOut = true; + resolve(false); + }, timeoutMs); + }); + + try { + return await Promise.race([poll(), deadline]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.html b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.html index e0965700a0..c3197ac042 100644 --- a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.html +++ b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.html @@ -94,8 +94,9 @@

{{title}} Setup with Cloud Foundry UAA

- + + @for (scope of uaaScopes(); track scope) { diff --git a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts index d7a32b7fac..44f78c776a 100644 --- a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts +++ b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts @@ -1,57 +1,208 @@ +import { HttpHeaders } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@test-framework/core-test.helper'; -import { CoreTestingModule } from '@test-framework/core-test.modules'; -import { CoreModule } from '../../../core/core.module'; -import { MDAppModule } from '../../../core/md.module'; -import { CurrentUserPermissionsService } from '../../../core/permissions/current-user-permissions.service'; -import { PageHeaderModule } from '../../../shared/components/page-header/page-header.module'; -import { SharedModule } from '../../../shared/shared.module'; -import { TabNavService } from '../../../tab-nav.service'; -import { SetupModule } from '../setup.module'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BASE_TEST_PROVIDERS, STORE_TEST_PROVIDERS } from '@test-framework/core-test.helper'; +import { configureZonelessTestBed } from '@test-framework/zoneless-testbed.helper'; +import { APP_TITLE } from '../../../core/core.types'; import { ConsoleUaaWizardComponent } from './console-uaa-wizard.component'; +const CHECK_URL = '/pp/v1/setup/check'; +const SAVE_URL = '/pp/v1/setup/save'; +const VERIFY_URL = '/api/v1/auth/verify'; + describe('ConsoleUaaWizardComponent', () => { let component: ConsoleUaaWizardComponent; let fixture: ComponentFixture; + let httpMock: HttpTestingController; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - CoreModule, - SharedModule, - SetupModule, - RouterTestingModule, - FormsModule, - PageHeaderModule, - ReactiveFormsModule, - MDAppModule, - CoreTestingModule, - createBasicStoreModule(), - NoopAnimationsModule, - ], - providers: [ - ...(STORE_TEST_PROVIDERS || []), - TabNavService, - CurrentUserPermissionsService, - provideZonelessChangeDetection(), - ] + const el = (): HTMLElement => fixture.nativeElement; + const nextButton = () => el().querySelector('#stepper_next')!; + const scopeSelect = () => el().querySelector('#uaaScope'); + const overlayShown = () => !!el().querySelector('.loading-page__overlay'); + const errorSnack = () => document.querySelector('.snackbar-error .snackbar-message')?.textContent ?? ''; + + // A flushed response resolves the step through a chain of promises that + // no pending task tracks, so drain the microtasks before waiting for CD. + const settle = async () => { + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + }; + + const fillForm = () => { + component.uaaForm.setValue({ + apiUrl: 'https://uaa.example.com', + skipSSL: false, + clientId: 'console', + clientSecret: '', + adminUsername: 'admin', + adminPassword: 'secret', + useSSO: false, }); - TestBed.compileComponents(); - }); + }; + + const flushScopes = (scope: string[]) => + httpMock.expectOne(CHECK_URL).flush({ scope, exp: 0, user_id: 'u', user_name: 'admin' }); + + const flushCheckError = (error: string) => + httpMock.expectOne(CHECK_URL).flush({ error }, { status: 400, statusText: 'Bad Request' }); + + const reachScopeStep = async (scope: string[]) => { + fillForm(); + await fixture.whenStable(); + nextButton().click(); + flushScopes(scope); + await settle(); + }; + + beforeEach(async () => { + await configureZonelessTestBed({ + imports: [ConsoleUaaWizardComponent, NoopAnimationsModule], + providers: [ + ...BASE_TEST_PROVIDERS, + ...STORE_TEST_PROVIDERS, + { provide: APP_TITLE, useValue: 'Stratos' }, + ], + }).compileComponents(); - beforeEach(() => { + httpMock = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(ConsoleUaaWizardComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.querySelectorAll('.snackbar-error').forEach(s => s.remove()); }); it('should be created', () => { expect(component).toBeTruthy(); }); + + describe('UAA endpoint step', () => { + // Regression (#6020): the step read the replayed state of the previous + // request, so the first click succeeded with no scopes before the check + // had even answered. + it('does not advance when the scope check fails, then reports the next attempt', async () => { + fillForm(); + await fixture.whenStable(); + + nextButton().click(); + flushCheckError('Failed to authenticate with UAA - check Client ID, Secret and credentials'); + await settle(); + + expect(scopeSelect()).toBeNull(); + expect(errorSnack()).toContain('Failed to authenticate with UAA'); + + nextButton().click(); + flushScopes(['openid', 'stratos.admin']); + await settle(); + + const select = scopeSelect(); + expect(select).not.toBeNull(); + expect(Array.from(select!.options).map(o => o.value)).toContain('stratos.admin'); + expect(component.selectedScope()).toBe('stratos.admin'); + }); + + it('reports the result of this attempt on a retry, not the previous one', async () => { + await reachScopeStep(['openid', 'stratos.admin']); + expect(scopeSelect()).not.toBeNull(); + + el().querySelector('#stepper_previous')!.click(); + await settle(); + expect(scopeSelect()).toBeNull(); + + nextButton().click(); + flushCheckError('Failed to authenticate with UAA - check Client ID, Secret and credentials'); + await settle(); + + expect(scopeSelect()).toBeNull(); + expect(errorSnack()).toContain('Failed to authenticate with UAA'); + }); + + it('does not advance when the UAA returns no scopes for the admin user', async () => { + await reachScopeStep([]); + + expect(scopeSelect()).toBeNull(); + expect(errorSnack()).toContain('no scopes'); + }); + }); + + describe('Admin scope step', () => { + it('blocks Finish until an admin scope is chosen', async () => { + await reachScopeStep(['openid', 'uaa.user']); + + expect(nextButton().disabled).toBe(true); + expect(component.selectedScope()).toBe(''); + + const select = scopeSelect()!; + select.value = 'uaa.user'; + select.dispatchEvent(new Event('change')); + await settle(); + + expect(component.selectedScope()).toBe('uaa.user'); + expect(nextButton().disabled).toBe(false); + }); + + // Regression (#6020): a failed save left the full-screen overlay up + // forever, with the error snackbar hidden behind it. + it('clears the overlay and shows the error when the save fails', async () => { + await reachScopeStep(['openid', 'stratos.admin']); + + nextButton().click(); + await settle(); + expect(overlayShown()).toBe(true); + + httpMock.expectOne(SAVE_URL).flush({ error: 'database unavailable' }, { status: 500, statusText: 'Error' }); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('database unavailable'); + }); + + it('saves the chosen scope and reloads once the backend leaves setup mode', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); + await reachScopeStep(['openid', 'stratos.admin']); + + nextButton().click(); + const save = httpMock.expectOne(SAVE_URL); + expect(save.request.body.get('console_admin_scope')).toBe('stratos.admin'); + save.flush(null); + await settle(); + httpMock.expectOne(VERIFY_URL).flush(null, { status: 401, statusText: 'Unauthorized' }); + await settle(); + + expect(assign).toHaveBeenCalledWith(`${window.location.protocol}//${window.location.host}`); + expect(errorSnack()).toBe(''); + }); + + it('clears the overlay and shows an error when the backend never confirms the setup', async () => { + await reachScopeStep(['openid', 'stratos.admin']); + + vi.useFakeTimers(); + nextButton().click(); + httpMock.expectOne(SAVE_URL).flush(null); + + // The backend keeps answering "setup required". + for (let i = 0; i < 40; i++) { + await vi.advanceTimersByTimeAsync(1000); + httpMock.match(VERIFY_URL).forEach(req => req.flush(null, { + status: 503, + statusText: 'Service Unavailable', + headers: new HttpHeaders({ 'Stratos-Setup-Required': 'true' }), + })); + } + vi.useRealTimers(); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('did not confirm'); + }); + }); }); diff --git a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.ts b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.ts index b44c51267a..d00ee54372 100644 --- a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.ts +++ b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.ts @@ -1,16 +1,12 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormsModule, FormControl, FormGroup, Validators } from '@angular/forms'; -import { - AuthState, -} from '@stratosui/store'; -import { combineLatest, firstValueFrom, Subscription } from 'rxjs'; -import { delay, filter, skipWhile, take } from 'rxjs/operators'; +import { AuthDataService } from '@stratosui/store'; +import { Subscription } from 'rxjs'; -import { AuthSignalService } from '../../../core/signals/auth-signal.service'; -import { UaaSetupSignalService } from '../../../core/signals/uaa-setup-signal.service'; -import { UaaSetupDataService, UaaSetupState } from '../../../core/uaa-setup-data.service'; +import { UaaSetupData, UaaSetupDataService } from '../../../core/uaa-setup-data.service'; +import { SETUP_NOT_CONFIRMED_MESSAGE, waitForSetupConfirmed } from '../setup-confirmation'; interface UAAWizardForm { apiUrl: FormControl; @@ -33,6 +29,9 @@ import { ProductNameComponent } from '../../../shared/components/product-name.cc import { ShowHideButtonComponent } from '../../../core/show-hide-button/show-hide-button.component'; import { LoadingPageComponent } from '../../../shared/components/loading-page/loading-page.component'; +// Pre-selected, in order, when the admin user's token carries them. +const PREFERRED_ADMIN_SCOPES = ['stratos.admin', 'cloud_controller.admin']; + @Component({ selector: 'app-console-uaa-wizard', templateUrl: './console-uaa-wizard.component.html', @@ -52,16 +51,10 @@ import { LoadingPageComponent } from '../../../shared/components/loading-page/lo changeDetection: ChangeDetectionStrategy.OnPush }) export class ConsoleUaaWizardComponent implements OnInit, OnDestroy { - private cdr = inject(ChangeDetectorRef); - private authSignals = inject(AuthSignalService); - private uaaSetupSignals = inject(UaaSetupSignalService); + private authData = inject(AuthDataService); private uaaSetupData = inject(UaaSetupDataService); title = inject(APP_TITLE); - // Bridge signals → observables in injection context (used by step handlers below). - private uaaSetup$ = toObservable(this.uaaSetupSignals.uaaSetup); - private auth$ = toObservable(this.authSignals.auth); - protected clientRedirectURI: string; constructor() { @@ -70,8 +63,8 @@ export class ConsoleUaaWizardComponent implements OnInit, OnDestroy { } uaaForm!: FormGroup; - uaaScopes: string[] = []; - selectedScope = ''; + uaaScopes = signal([]); + selectedScope = signal(''); applyingSetup = signal(false); // app-loading-page takes an Observable — bridge the signal. applyingSetup$ = toObservable(this.applyingSetup); @@ -88,83 +81,59 @@ export class ConsoleUaaWizardComponent implements OnInit, OnDestroy { // FWT-959 Part 2 (Partition A) — SignalStepHandle wiring. // - // Two-step UAA setup. Step 1 collects credentials and dispatches - // SetupConsoleGetScopes; step 2 picks an admin scope and dispatches - // SetupSaveConfig then hard-reloads on success. Cross-step state - // (`uaaScopes`, `selectedScope`) lives on this parent and is read - // directly by step 2's template, so we don't need to plumb it through - // signals — the previous and migrated paths share the same fields. + // Two-step UAA setup. Step 1 collects credentials and fetches the admin + // user's scopes; step 2 picks an admin scope, saves the config, waits for + // the backend to leave setup mode, then hard-reloads. Cross-step state + // (`uaaScopes`, `selectedScope`) lives on this parent as signals so step + // 2's OnPush template re-renders when step 1 sets them after an await. uaaFormStepHandle: SignalStepHandle = { valid: this.uaaFormValid.asReadonly(), submit: async () => { - void this.uaaSetupData.checkScopes({ - uaa_endpoint: this.uaaForm.controls.apiUrl.value, - console_client: this.uaaForm.controls.clientId.value, - password: this.uaaForm.controls.adminPassword.value, - skip_ssl_validation: this.uaaForm.controls.skipSSL.value, - username: this.uaaForm.controls.adminUsername.value, - console_client_secret: this.uaaForm.controls.clientSecret.value, - use_sso: this.uaaForm.controls.useSSO.value, - console_admin_scope: '' - }); - const state = await firstValueFrom( - this.uaaSetup$.pipe( - skipWhile((s: UaaSetupState) => s.settingUp), - take(1), - ) - ); + this.uaaScopes.set([]); + this.selectedScope.set(''); + // Await this request and read the state it left behind. A replayed + // stream still holds the previous request's state at this point. + await this.uaaSetupData.checkScopes(this.setupData('')); + const state = this.uaaSetupData.state(); if (state.error) { throw new Error(state.message || 'Failed to fetch UAA scopes'); } - this.uaaScopes = state.payload?.scope ?? []; - if (this.uaaScopes.find(scope => scope === 'stratos.admin')) { - this.selectedScope = 'stratos.admin'; - } else if (this.uaaScopes.find(scope => scope === 'cloud_controller.admin')) { - this.selectedScope = 'cloud_controller.admin'; + const scopes = state.payload?.scope ?? []; + if (!scopes.length) { + throw new Error('The UAA returned no scopes for the admin user, so there is no admin scope to choose. Check the admin username and password.'); + } + this.uaaScopes.set(scopes); + const preferred = PREFERRED_ADMIN_SCOPES.find(scope => scopes.includes(scope)); + if (preferred) { + this.selectedScope.set(preferred); } - // OnPush: uaaScopes / selectedScope are plain fields read by step 2's - // template. The setter assignments above don't trigger CD on their - // own, so step 2 may render with an empty scope dropdown until - // something else marks the view dirty. Force a tick. - this.cdr.markForCheck(); }, }; + // The backend stays in setup mode until an admin scope is saved, so + // Finish is only enabled once one of the returned scopes is chosen. + private adminScopeChosen = computed(() => { + const scope = this.selectedScope(); + return !!scope && this.uaaScopes().includes(scope); + }); + uaaScopeStepHandle: SignalStepHandle = { - valid: signal(true).asReadonly(), + valid: this.adminScopeChosen, submit: async () => { - void this.uaaSetupData.saveConfig({ - uaa_endpoint: this.uaaForm.controls.apiUrl.value, - console_client: this.uaaForm.controls.clientId.value, - password: this.uaaForm.controls.adminPassword.value, - skip_ssl_validation: this.uaaForm.controls.skipSSL.value, - username: this.uaaForm.controls.adminUsername.value, - console_client_secret: this.uaaForm.controls.clientSecret.value, - use_sso: this.uaaForm.controls.useSSO.value, - console_admin_scope: this.selectedScope - }); - this.applyingSetup.set(true); - const state = await firstValueFrom( - combineLatest([this.uaaSetup$, this.auth$] as [typeof this.uaaSetup$, typeof this.auth$]).pipe( - filter(([uaa, auth]: [UaaSetupState, AuthState | undefined]) => !!auth && !(uaa.settingUp || auth.verifying)), - delay(2000), - take(10), - filter(([_uaa, auth]: [UaaSetupState, AuthState | undefined]) => { - const validUAASessionData = !!auth?.sessionData && !auth.sessionData.uaaError; - if (!validUAASessionData) { - this.authSignals.verifySession(true, true); - } - return validUAASessionData; - }), - take(1), - ) - ); - const [uaa] = state; - if (uaa.error) { + try { + await this.uaaSetupData.saveConfig(this.setupData(this.selectedScope())); + const saved = this.uaaSetupData.state(); + if (saved.error) { + throw new Error(saved.message || 'Failed to save UAA configuration'); + } + if (!await waitForSetupConfirmed(this.authData)) { + throw new Error(SETUP_NOT_CONFIRMED_MESSAGE); + } + } catch (err) { this.applyingSetup.set(false); - throw new Error(uaa.message || 'Failed to save UAA configuration'); + throw err; } // Hard reload of the app on success — preserves the legacy behaviour. const loc = window.location; @@ -173,6 +142,20 @@ export class ConsoleUaaWizardComponent implements OnInit, OnDestroy { }, }; + private setupData(adminScope: string): UaaSetupData { + const form = this.uaaForm.controls; + return { + uaa_endpoint: form.apiUrl.value, + console_client: form.clientId.value, + password: form.adminPassword.value, + skip_ssl_validation: form.skipSSL.value, + username: form.adminUsername.value, + console_client_secret: form.clientSecret.value, + use_sso: form.useSSO.value, + console_admin_scope: adminScope, + }; + } + ngOnInit() { this.uaaForm = new FormGroup({ apiUrl: new FormControl('', { nonNullable: true, validators: [Validators.required] }), From 6f3cd73c97ca99bbffc9f0eb58f2274fbc8de31e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:10:46 -0700 Subject: [PATCH 014/121] fix(setup): clear the local account saving overlay The local account Finish waited on replayed streams with take(10). When the save failed or the backend never left setup mode the wait ended in an EmptyError and the "Saving configuration" overlay stayed up over the error. Await the save, read its state, confirm with waitForSetupConfirmed(), and clear the overlay on any failure. --- .../local-account-wizard.component.spec.ts | 149 ++++++++++++------ .../local-account-wizard.component.ts | 79 ++++------ 2 files changed, 130 insertions(+), 98 deletions(-) diff --git a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts index 89a950c1ba..7b582e0919 100644 --- a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts +++ b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts @@ -1,65 +1,120 @@ +import { HttpHeaders } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { entityCatalog, TestEntityCatalog, generateStratosEntities } from '@stratosui/store'; -import { createBasicStoreModule, STORE_TEST_PROVIDERS } from "@test-framework/core-test.helper"; - -import { CoreModule } from '../../../core/core.module'; -import { MDAppModule } from '../../../core/md.module'; -import { CurrentUserPermissionsService } from '../../../core/permissions/current-user-permissions.service'; -import { PageHeaderModule } from '../../../shared/components/page-header/page-header.module'; -import { SharedModule } from '../../../shared/shared.module'; -import { TabNavService } from '../../../tab-nav.service'; -import { SetupModule } from '../setup.module'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BASE_TEST_PROVIDERS, STORE_TEST_PROVIDERS } from '@test-framework/core-test.helper'; +import { configureZonelessTestBed } from '@test-framework/zoneless-testbed.helper'; +import { APP_TITLE } from '../../../core/core.types'; import { LocalAccountWizardComponent } from './local-account-wizard.component'; +const SAVE_URL = '/pp/v1/setup/save'; +const VERIFY_URL = '/api/v1/auth/verify'; + describe('LocalAccountWizardComponent', () => { let component: LocalAccountWizardComponent; let fixture: ComponentFixture; + let httpMock: HttpTestingController; - beforeEach(() => { - // Register Stratos entities before test configuration - const testEntityCatalog = entityCatalog as TestEntityCatalog; - testEntityCatalog.clear(); - generateStratosEntities().forEach(entity => entityCatalog.register(entity)); - - TestBed.configureTestingModule({ - imports: [ - CoreModule, - SharedModule, - SetupModule, - RouterTestingModule, - FormsModule, - PageHeaderModule, - ReactiveFormsModule, - MDAppModule, - createBasicStoreModule(), - NoopAnimationsModule, - ], + const el = (): HTMLElement => fixture.nativeElement; + const finishButton = () => el().querySelector('#stepper_next')!; + const overlayShown = () => !!el().querySelector('.loading-page__overlay'); + const errorSnack = () => document.querySelector('.snackbar-error .snackbar-message')?.textContent ?? ''; + + // A flushed response resolves the step through a chain of promises that + // no pending task tracks, so drain the microtasks before waiting for CD. + const settle = async () => { + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + }; + + const submitPassword = async () => { + component.passwordForm.setValue({ adminPassword: 'secret', adminPasswordConfirm: 'secret' }); + await fixture.whenStable(); + finishButton().click(); + await settle(); + }; + + beforeEach(async () => { + await configureZonelessTestBed({ + imports: [LocalAccountWizardComponent, NoopAnimationsModule], providers: [ - ...(STORE_TEST_PROVIDERS || []), - TabNavService, - CurrentUserPermissionsService, - provideHttpClient(), - provideHttpClientTesting(), - provideZonelessChangeDetection(), - ] - }); - TestBed.compileComponents(); - }); + ...BASE_TEST_PROVIDERS, + ...STORE_TEST_PROVIDERS, + { provide: APP_TITLE, useValue: 'Stratos' }, + ], + }).compileComponents(); - beforeEach(() => { + httpMock = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(LocalAccountWizardComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.querySelectorAll('.snackbar-error').forEach(s => s.remove()); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('shows the saving overlay while the save is in flight', async () => { + await submitPassword(); + + expect(overlayShown()).toBe(true); + httpMock.expectOne(SAVE_URL); + }); + + // Regression (#6020): a failed save left the full-screen overlay up + // forever, with the error snackbar hidden behind it. + it('clears the overlay and shows the error when the save fails', async () => { + await submitPassword(); + + httpMock.expectOne(SAVE_URL).flush({ error: 'database unavailable' }, { status: 500, statusText: 'Error' }); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('database unavailable'); + }); + + it('clears the overlay and shows an error when the backend never confirms the setup', async () => { + await submitPassword(); + + vi.useFakeTimers(); + httpMock.expectOne(SAVE_URL).flush(null); + + // The backend keeps answering "setup required". + for (let i = 0; i < 40; i++) { + await vi.advanceTimersByTimeAsync(1000); + httpMock.match(VERIFY_URL).forEach(req => req.flush(null, { + status: 503, + statusText: 'Service Unavailable', + headers: new HttpHeaders({ 'Stratos-Setup-Required': 'true' }), + })); + } + vi.useRealTimers(); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('did not confirm'); + }); + + it('reloads the app once the backend leaves setup mode', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); + await submitPassword(); + + httpMock.expectOne(SAVE_URL).flush(null); + await settle(); + httpMock.expectOne(VERIFY_URL).flush(null, { status: 401, statusText: 'Unauthorized' }); + await settle(); + + expect(assign).toHaveBeenCalledWith(`${window.location.protocol}//${window.location.host}`); + expect(errorSnack()).toBe(''); + }); }); diff --git a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.ts b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.ts index 3dd8d4dc1f..7758f7e8ab 100644 --- a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.ts +++ b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.ts @@ -2,16 +2,13 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, signal, inject, Injector, runInInjectionContext, ChangeDetectionStrategy } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, ReactiveFormsModule, ValidatorFn, Validators, FormControl, FormGroup } from '@angular/forms'; -import { - AuthState, -} from '@stratosui/store'; -import { combineLatest, Observable, firstValueFrom } from 'rxjs'; -import { delay, filter, map, take } from 'rxjs/operators'; +import { AuthDataService } from '@stratosui/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { APP_TITLE } from '../../../core/core.types'; -import { AuthSignalService } from '../../../core/signals/auth-signal.service'; -import { UaaSetupSignalService } from '../../../core/signals/uaa-setup-signal.service'; -import { LocalAdminSetupData, UaaSetupDataService, UaaSetupState } from '../../../core/uaa-setup-data.service'; +import { LocalAdminSetupData, UaaSetupDataService } from '../../../core/uaa-setup-data.service'; +import { SETUP_NOT_CONFIRMED_MESSAGE, waitForSetupConfirmed } from '../setup-confirmation'; import { SignalStepHandle } from '../../../shared/components/stepper/step/step.component'; import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component'; import { ShowPageHeaderComponent } from '../../../shared/components/page-header/show-page-header/show-page-header.component'; @@ -47,15 +44,10 @@ selector: 'app-local-account-wizard', export class LocalAccountWizardComponent implements OnInit { private injector = inject(Injector); - private authSignals = inject(AuthSignalService); - private uaaSetupSignals = inject(UaaSetupSignalService); + private authData = inject(AuthDataService); private uaaSetupData = inject(UaaSetupDataService); public title = inject(APP_TITLE); - // Bridge signals → observables in injection context for use in `next` handler. - private uaaSetup$ = toObservable(this.uaaSetupSignals.uaaSetup); - private auth$ = toObservable(this.authSignals.auth); - passwordForm!: FormGroup; validateLocalAuthForm!: Observable; applyingSetup = signal(false); @@ -82,14 +74,7 @@ export class LocalAccountWizardComponent implements OnInit { ); this.signalHandle = { valid: validSignal, - submit: async () => { - const result = await firstValueFrom(this.next() as Observable<{ success: boolean; message?: string }>); - if (!result.success) { - throw new Error(result.message || 'Failed to apply local account setup'); - } - // Success path triggers a hard window reload inside `next()`; no - // router navigation needed here. - }, + submit: () => this.next(), }; } @@ -110,40 +95,32 @@ export class LocalAccountWizardComponent implements OnInit { }; } - next = () => { + next = async (): Promise => { const data: LocalAdminSetupData = { local_admin_password: this.passwordForm.controls.adminPassword.value, }; this.applyingSetup.set(true); - void this.uaaSetupData.saveConfig(data); - return combineLatest([this.uaaSetup$, this.auth$]).pipe( - filter(([uaa, auth]: [UaaSetupState, AuthState | undefined]) => { - return !!auth && !(uaa.settingUp || auth.verifying); - }), - delay(2000), - take(10), - filter(([_uaa, auth]: [UaaSetupState, AuthState | undefined]) => { - const validUAASessionData = !!auth?.sessionData && !auth.sessionData.uaaError; - if (!validUAASessionData) { - this.authSignals.verifySession(true, true); - } - return validUAASessionData; - }), - map(([uaa]: [UaaSetupState, AuthState | undefined]) => { - if (!uaa.error) { - // Do a hard reload of the app - const loc = window.location; - const reload = loc.protocol + '//' + loc.host; - window.location.assign(reload); - } else { - this.applyingSetup.set(false); - } - return { - success: !uaa.error, - message: uaa.message - }; - })); + try { + // Await this save and read the state it left behind rather than a + // replayed stream, which still holds the previous state. + await this.uaaSetupData.saveConfig(data); + const saved = this.uaaSetupData.state(); + if (saved.error) { + throw new Error(saved.message || 'Failed to apply local account setup'); + } + if (!await waitForSetupConfirmed(this.authData)) { + throw new Error(SETUP_NOT_CONFIRMED_MESSAGE); + } + } catch (err) { + // Clear the overlay so the stepper's error snackbar is visible. + this.applyingSetup.set(false); + throw err; + } + // Do a hard reload of the app + const loc = window.location; + const reload = loc.protocol + '//' + loc.host; + window.location.assign(reload); }; } From 812a72cf8770406ba3cd835bb5f41da0dd60c377 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:16:05 -0700 Subject: [PATCH 015/121] refactor(core): drop the unused UAA setup signal facade UaaSetupSignalService only re-exposed UaaSetupDataService.state for the two setup wizards, which now read the data service directly. --- .../signals/uaa-setup-signal.service.spec.ts | 57 ------------------- .../core/signals/uaa-setup-signal.service.ts | 23 -------- 2 files changed, 80 deletions(-) delete mode 100644 src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.spec.ts delete mode 100644 src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.ts diff --git a/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.spec.ts b/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.spec.ts deleted file mode 100644 index ec4454d462..0000000000 --- a/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection, signal } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { UaaSetupDataService, UaaSetupState } from '../uaa-setup-data.service'; -import { UaaSetupSignalService } from './uaa-setup-signal.service'; - -function makeUaaSetupState(overrides: Partial = {}): UaaSetupState { - return { - payload: null, - setup: false, - error: false, - message: '', - settingUp: false, - ...overrides, - }; -} - -describe('UaaSetupSignalService', () => { - let stateSignal: ReturnType>; - - beforeEach(() => { - stateSignal = signal(makeUaaSetupState()); - const stubData = { state: stateSignal.asReadonly() }; - - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - { provide: UaaSetupDataService, useValue: stubData }, - UaaSetupSignalService, - ], - }); - }); - - it('exposes default uaaSetup signals', () => { - const service = TestBed.inject(UaaSetupSignalService); - expect(service.setup()).toBe(false); - expect(service.settingUp()).toBe(false); - expect(service.error()).toBe(false); - expect(service.message()).toBe(''); - expect(service.payload()).toBeNull(); - }); - - it('reflects uaaSetup updates through projected signals', () => { - stateSignal.set(makeUaaSetupState({ - setup: true, - settingUp: false, - error: true, - message: 'boom', - })); - - const service = TestBed.inject(UaaSetupSignalService); - expect(service.setup()).toBe(true); - expect(service.error()).toBe(true); - expect(service.message()).toBe('boom'); - }); -}); diff --git a/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.ts b/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.ts deleted file mode 100644 index 6daf27b6b1..0000000000 --- a/src/frontend/packages/core/src/core/signals/uaa-setup-signal.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable, Signal, computed, inject } from '@angular/core'; - -import { UaaSetupDataService, UaaSetupState } from '../uaa-setup-data.service'; - -/** - * Signal-native facade over {@link UaaSetupDataService}. Preserves the - * legacy import path for existing wizard consumers; new code should - * inject `UaaSetupDataService` directly. - */ -@Injectable({ providedIn: 'root' }) -export class UaaSetupSignalService { - private uaaData = inject(UaaSetupDataService); - - readonly uaaSetup: Signal = this.uaaData.state; - - readonly setup: Signal = computed(() => !!this.uaaSetup().setup); - readonly settingUp: Signal = computed(() => !!this.uaaSetup().settingUp); - readonly error: Signal = computed(() => !!this.uaaSetup().error); - readonly message: Signal = computed(() => this.uaaSetup().message ?? ''); - readonly payload: Signal = computed( - () => this.uaaSetup().payload ?? null - ); -} From 8e72dba843f620704a0dca95bf44f66783a18c90 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:16:05 -0700 Subject: [PATCH 016/121] fix(setup): stop waiting on a setup request that hangs A scope check or save that never answered kept the wizard busy, or the "Saving configuration" overlay up, forever. Time the requests out: the check after 130 s (the backend's own UAA call gives up at 120 s, so its clearer error arrives first) and the save after 60 s, with a message saying the server did not answer. --- .../core/src/core/uaa-setup-data.service.ts | 27 +++++++++++---- .../local-account-wizard.component.spec.ts | 17 ++++++++++ .../console-uaa-wizard.component.spec.ts | 33 +++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/core/src/core/uaa-setup-data.service.ts b/src/frontend/packages/core/src/core/uaa-setup-data.service.ts index ef9ffd577d..38bad8560f 100644 --- a/src/frontend/packages/core/src/core/uaa-setup-data.service.ts +++ b/src/frontend/packages/core/src/core/uaa-setup-data.service.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; import { Injectable, Signal, inject, signal } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, timeout, TimeoutError } from 'rxjs'; export interface UaaSetupData { console_client: string; @@ -43,6 +43,14 @@ const EMPTY_STATE: UaaSetupState = { const GET_SCOPES_URL = '/pp/v1/setup/check'; const SAVE_CONFIG_URL = '/pp/v1/setup/save'; +// Without a limit a request that never answers keeps the wizard busy +// forever. The check calls the UAA token endpoint with the backend's +// mutating HTTP client (120 s by default), so wait a little longer than +// that and let the backend's own error arrive first. The save only writes +// the config (and logs a local admin in), so it gets a shorter limit. +export const CHECK_SCOPES_TIMEOUT_MS = 130000; +export const SAVE_CONFIG_TIMEOUT_MS = 60000; + /** * Signal-native UAA setup state. Replaces the @stratosui/store * `uaaSetup` slice (effects + reducer + actions) with HttpClient calls @@ -63,14 +71,19 @@ export class UaaSetupDataService { readonly state: Signal = this._state.asReadonly(); async checkScopes(setupData: UaaSetupData | LocalAdminSetupData): Promise { - await this.post(GET_SCOPES_URL, setupData, 'Failed to save configuration.'); + await this.post(GET_SCOPES_URL, setupData, 'Failed to save configuration.', CHECK_SCOPES_TIMEOUT_MS); } async saveConfig(setupData: UaaSetupData | LocalAdminSetupData): Promise { - await this.post(SAVE_CONFIG_URL, setupData, 'Failed to setup Administrator scope.'); + await this.post(SAVE_CONFIG_URL, setupData, 'Failed to setup Administrator scope.', SAVE_CONFIG_TIMEOUT_MS); } - private async post(url: string, setupData: UaaSetupData | LocalAdminSetupData, failurePrefix: string): Promise { + private async post( + url: string, + setupData: UaaSetupData | LocalAdminSetupData, + failurePrefix: string, + timeoutMs: number, + ): Promise { this._state.set({ ...this._state(), settingUp: true, @@ -84,7 +97,7 @@ export class UaaSetupDataService { const data = await firstValueFrom( this.http.post | UaaSetupPayload | unknown>(url, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) + }).pipe(timeout(timeoutMs)) ); const merged: UaaSetupPayload = { ...(this._state().payload ?? ({} as UaaSetupPayload)), @@ -103,7 +116,9 @@ export class UaaSetupDataService { settingUp: false, setup: false, error: true, - message: `${failurePrefix} ${this.fetchError(err)}`, + message: err instanceof TimeoutError + ? `${failurePrefix} The server did not answer within ${timeoutMs / 1000} seconds.` + : `${failurePrefix} ${this.fetchError(err)}`, }); } } diff --git a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts index 7b582e0919..5495fd513f 100644 --- a/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts +++ b/src/frontend/packages/core/src/features/setup/local-account-wizard/local-account-wizard.component.spec.ts @@ -105,6 +105,23 @@ describe('LocalAccountWizardComponent', () => { expect(errorSnack()).toContain('did not confirm'); }); + it('clears the overlay and shows an error when the save never answers', async () => { + component.passwordForm.setValue({ adminPassword: 'secret', adminPasswordConfirm: 'secret' }); + await fixture.whenStable(); + + vi.useFakeTimers(); + finishButton().click(); + httpMock.expectOne(SAVE_URL); + await vi.advanceTimersByTimeAsync(61000); + vi.useRealTimers(); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('did not answer'); + httpMock.expectNone(VERIFY_URL); + }); + it('reloads the app once the backend leaves setup mode', async () => { const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); await submitPassword(); diff --git a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts index 44f78c776a..fd9aebb860 100644 --- a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts +++ b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts @@ -84,6 +84,22 @@ describe('ConsoleUaaWizardComponent', () => { }); describe('UAA endpoint step', () => { + it('stops waiting and shows an error when the scope check never answers', async () => { + fillForm(); + await fixture.whenStable(); + + vi.useFakeTimers(); + nextButton().click(); + httpMock.expectOne(CHECK_URL); + await vi.advanceTimersByTimeAsync(131000); + vi.useRealTimers(); + await settle(); + + expect(scopeSelect()).toBeNull(); + expect(nextButton().disabled).toBe(false); + expect(errorSnack()).toContain('did not answer'); + }); + // Regression (#6020): the step read the replayed state of the previous // request, so the first click succeeded with no scopes before the check // had even answered. @@ -130,6 +146,7 @@ describe('ConsoleUaaWizardComponent', () => { expect(scopeSelect()).toBeNull(); expect(errorSnack()).toContain('no scopes'); }); + }); describe('Admin scope step', () => { @@ -204,5 +221,21 @@ describe('ConsoleUaaWizardComponent', () => { expect(overlayShown()).toBe(false); expect(errorSnack()).toContain('did not confirm'); }); + + it('clears the overlay and shows an error when the save never answers', async () => { + await reachScopeStep(['openid', 'stratos.admin']); + + vi.useFakeTimers(); + nextButton().click(); + httpMock.expectOne(SAVE_URL); + await vi.advanceTimersByTimeAsync(61000); + vi.useRealTimers(); + await settle(); + + expect(component.applyingSetup()).toBe(false); + expect(overlayShown()).toBe(false); + expect(errorSnack()).toContain('did not answer'); + httpMock.expectNone(VERIFY_URL); + }); }); }); From dfc607077f20f61966059e1dad211546ac91aeba Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:16:05 -0700 Subject: [PATCH 017/121] fix(setup): word a failed UAA check as a check A failed scope check in step 1 was reported as "Failed to save configuration." although nothing was saved. Say "Failed to check the UAA credentials." instead; the save wording is unchanged. --- .../packages/core/src/core/uaa-setup-data.service.ts | 2 +- .../uaa-wizard/console-uaa-wizard.component.spec.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/core/src/core/uaa-setup-data.service.ts b/src/frontend/packages/core/src/core/uaa-setup-data.service.ts index 38bad8560f..bb7819f230 100644 --- a/src/frontend/packages/core/src/core/uaa-setup-data.service.ts +++ b/src/frontend/packages/core/src/core/uaa-setup-data.service.ts @@ -71,7 +71,7 @@ export class UaaSetupDataService { readonly state: Signal = this._state.asReadonly(); async checkScopes(setupData: UaaSetupData | LocalAdminSetupData): Promise { - await this.post(GET_SCOPES_URL, setupData, 'Failed to save configuration.', CHECK_SCOPES_TIMEOUT_MS); + await this.post(GET_SCOPES_URL, setupData, 'Failed to check the UAA credentials.', CHECK_SCOPES_TIMEOUT_MS); } async saveConfig(setupData: UaaSetupData | LocalAdminSetupData): Promise { diff --git a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts index fd9aebb860..926f70da56 100644 --- a/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts +++ b/src/frontend/packages/core/src/features/setup/uaa-wizard/console-uaa-wizard.component.spec.ts @@ -147,6 +147,16 @@ describe('ConsoleUaaWizardComponent', () => { expect(errorSnack()).toContain('no scopes'); }); + it('words a failed scope check as a credentials check, not a save', async () => { + fillForm(); + await fixture.whenStable(); + + nextButton().click(); + flushCheckError('Failed to authenticate with UAA - check Client ID, Secret and credentials'); + await settle(); + + expect(errorSnack()).toBe('Failed to check the UAA credentials. Failed to authenticate with UAA - check Client ID, Secret and credentials'); + }); }); describe('Admin scope step', () => { From c53809982913bcd259d11f4f71dbe872f9809870 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:21:12 -0700 Subject: [PATCH 018/121] fix(kube): skip a connect when its register fails A register failure only skipped the cluster's connect when the error carried a message, so a failure without one left the connect queued against an endpoint that was never registered, and the register row showed nothing. Skip on any failure and fall back to "Failed to register endpoint". --- .../kube-config-import.component.spec.ts | 15 +++++++++++++++ .../kube-config-import.component.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts index 0d00b9700f..1e3574f9fc 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -139,5 +139,20 @@ describe('KubeConfigImportComponent', () => { expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); expect(connect).toHaveBeenCalledTimes(1); }); + + + it('skips the connect of a cluster whose register fails without a message', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); + stubSignalConfig.register.mockResolvedValueOnce({ busy: false, error: true, message: '' }); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(read(actions[0].state)).toEqual({ message: 'Failed to register endpoint', error: true }); + expect(actions[1].action).toBeNull(); + expect(read(actions[1].state)).toEqual({ message: 'Skipping action as endpoint could not be registered', warning: true }); + expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledTimes(1); + expect(read(actions[3].actionState)).toMatchObject({ error: false, completed: true }); + }); }); }); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts index 72477f1632..ba8eaec2f7 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -177,7 +177,7 @@ export class KubeConfigImportComponent implements OnDestroy { const sub = reg.actionState.asObservable().subscribe((progress: IActionMonitorComponentState) => { // Not sure what the status is used for? reg.status = progress; - if (progress.error && progress.message) { + if (progress.error) { // Mark all dependency jobs as skip next.forEach(action => { if (action.depends === reg) { @@ -186,7 +186,7 @@ export class KubeConfigImportComponent implements OnDestroy { action.state.next({ message: 'Skipping action as endpoint could not be registered', warning: true }); } }); - reg.state.next({ message: progress.message, error: true }); + reg.state.next({ message: progress.message || 'Failed to register endpoint', error: true }); } if (progress.completed) { if (!progress.error) { From 44cd64c1afb1f1532c317bb70f2bdffbc3fd7e9b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:21:13 -0700 Subject: [PATCH 019/121] fix(kube): go on past an unsupported auth type getAuthDataForConnect() read authTypes[type].component for a type the catalog does not know, and the TypeError was thrown inside the previous row's subscriber, which stopped the import. Throw a clear "Unsupported authentication type" error from the helper, and have the import mark that connect failed and move on when the helper throws. --- .../kube-config-auth.helper.ts | 6 +++++- .../kube-config-import.component.spec.ts | 19 +++++++++++++++++++ .../kube-config-import.component.ts | 12 ++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts index c41573a556..7748f8fa6b 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts @@ -150,7 +150,8 @@ export class KubeConfigAuthHelper { return { message: 'Authentication mechanism is not supported', warning: true }; } - // Use the auto component to get the data in the correct format for connecting to the endpoint + // Use the auto component to get the data in the correct format for connecting to the endpoint. + // Throws when the user's auth type is not one the Kubernetes endpoint offers. public getAuthDataForConnect(environmentInjector: EnvironmentInjector, injector: Injector, fb: FormBuilder, user: KubeConfigFileUser) : ConnectEndpointData | null { @@ -159,6 +160,9 @@ export class KubeConfigAuthHelper { // Get the component to us if (user && user._authData) { const authType = this.authTypes[user._authData.authType]; + if (!authType) { + throw new Error(`Unsupported authentication type: ${user._authData.authType}`); + } const ref = createComponent(authType.component, { environmentInjector, elementInjector: injector }); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts index 1e3574f9fc..e7e403edef 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -47,6 +47,9 @@ const read = (wrapper: unknown) => (wrapper as () => unknown)(); const AUTH_DATA = { authType: 'token', authVal: { token: 't' }, systemShared: false, bodyContent: '' }; +// Taken before any test spies on it, so a spy can still defer to it. +const realGetAuthData = KubeConfigAuthHelper.prototype.getAuthDataForConnect; + describe('KubeConfigImportComponent', () => { let component: KubeConfigImportComponent; let fixture: ComponentFixture; @@ -140,6 +143,22 @@ describe('KubeConfigImportComponent', () => { expect(connect).toHaveBeenCalledTimes(1); }); + it('marks an unsupported auth type and still imports the next cluster', async () => { + // The first cluster goes through the real helper with an auth type + // the kubernetes endpoint does not offer; the second gets usable data. + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockImplementation(function (this: KubeConfigAuthHelper, ...args) { + return args[3]._authData.authType === 'no-such-auth' ? realGetAuthData.apply(this, args) : AUTH_DATA as any; + }); + const first = makeCluster('a'); + first._users[0]._authData = { authType: 'no-such-auth', subType: '', values: {} }; + + const actions = await runImport([first, makeCluster('b')]); + + expect(read(actions[1].state)).toEqual({ message: 'Unsupported authentication type: no-such-auth', error: true }); + expect(stubSignalConfig.register).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledTimes(1); + expect(read(actions[3].actionState)).toMatchObject({ error: false, completed: true }); + }); it('skips the connect of a cluster whose register fails without a message', async () => { vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts index ba8eaec2f7..a261b95892 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -234,8 +234,16 @@ export class KubeConfigImportComponent implements OnDestroy { this.processAction(next); return; } - const helper = new KubeConfigAuthHelper(); - const data = helper.getAuthDataForConnect(this.environmentInjector, this.injector, this.fb, connect.user); + // This runs inside the previous row's status callback, so a throw here + // would stop the import: fail this row and move on instead. + let data: ConnectEndpointData | null; + try { + data = new KubeConfigAuthHelper().getAuthDataForConnect(this.environmentInjector, this.injector, this.fb, connect.user); + } catch (err) { + connect.state.next({ message: (err as Error)?.message || 'Can not connect - could not get user auth data', error: true }); + this.processAction(next); + return; + } if (data) { const obs$ = this.connectEndpoint(connect, data); From fd289f16e9e3b8d5e92f369c4162220ae1cb0c26 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:25:30 -0700 Subject: [PATCH 020/121] fix(kube): always destroy the auth form component getAuthDataForConnect() creates the auth type's form component to read its values, and destroyed it only when nothing threw on the way, so a failing getValues() or getBody() left it alive. Destroy it in a finally block. --- .../kube-config-auth.helper.spec.ts | 40 ++++++++++++++ .../kube-config-auth.helper.ts | 53 ++++++++++--------- 2 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.spec.ts diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.spec.ts new file mode 100644 index 0000000000..57c973d940 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.spec.ts @@ -0,0 +1,40 @@ +import { Component, EnvironmentInjector, Injector, OnDestroy } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { KubeConfigAuthHelper } from './kube-config-auth.helper'; +import { KubeConfigFileUser } from './kube-config.types'; + +let destroyed = 0; + +// An auth form that offers body content but fails to build its values. +@Component({ selector: 'app-failing-auth-form', template: '', standalone: true }) +class FailingAuthFormComponent implements OnDestroy { + formGroup: unknown; + getBody() { return ''; } + getValues(): never { throw new Error('bad values'); } + ngOnDestroy() { destroyed += 1; } +} + +describe('KubeConfigAuthHelper', () => { + let helper: KubeConfigAuthHelper; + const user = { + name: 'admin', + user: {}, + _authData: { authType: 'failing', subType: '', values: {} }, + } as unknown as KubeConfigFileUser; + + beforeEach(() => { + destroyed = 0; + helper = new KubeConfigAuthHelper(); + helper.authTypes = { failing: { value: 'failing', name: 'Failing', form: {}, types: [], component: FailingAuthFormComponent } }; + }); + + it('destroys the auth form when it fails to build the connect data', () => { + expect(() => helper.getAuthDataForConnect( + TestBed.inject(EnvironmentInjector), TestBed.inject(Injector), new FormBuilder(), user, + )).toThrow('bad values'); + expect(destroyed).toBe(1); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts index 7748f8fa6b..3940c69f93 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts @@ -166,34 +166,37 @@ export class KubeConfigAuthHelper { const ref = createComponent(authType.component, { environmentInjector, elementInjector: injector }); - const form = fb.group({ - authType: authType.value, - systemShared: false, - authValues: fb.group(user._authData.values) - }); - - ref.instance.formGroup = form; - - // Allow the auth form to supply body content if it needs to - const endpointFormInstance = ref.instance as any; - if (endpointFormInstance.getBody && endpointFormInstance.getValues) { - data = { + // Destroy the form even when it fails to build the connect data + try { + const form = fb.group({ authType: authType.value, - authVal: endpointFormInstance.getValues(user._authData.values), systemShared: false, - bodyContent: endpointFormInstance.getBody() - }; - } else { - // Use values as is - data = { - authType: authType.value, - authVal: user._authData.values, - systemShared: false, - bodyContent: null - }; - } + authValues: fb.group(user._authData.values) + }); - ref.destroy(); + ref.instance.formGroup = form; + + // Allow the auth form to supply body content if it needs to + const endpointFormInstance = ref.instance as any; + if (endpointFormInstance.getBody && endpointFormInstance.getValues) { + data = { + authType: authType.value, + authVal: endpointFormInstance.getValues(user._authData.values), + systemShared: false, + bodyContent: endpointFormInstance.getBody() + }; + } else { + // Use values as is + data = { + authType: authType.value, + authVal: user._authData.values, + systemShared: false, + bodyContent: null + }; + } + } finally { + ref.destroy(); + } } return data; } From 88438a926f5ac627beaafdc861b2e6c022850511 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:25:36 -0700 Subject: [PATCH 021/121] test(kube): give the import spec a real kube user The fixture used authType 'token', which parseAuth() never produces. Use a client certificate user and the kube-cert-auth data parseAuth() builds for it. --- .../kube-config-import.component.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts index e7e403edef..58a1c19bf1 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -31,7 +31,12 @@ function makeCluster(name: string): KubeConfigFileCluster { server: `https://${name}.example.com`, }, _user: 'admin', - _users: [{ name: 'admin', user: { token: 't' }, _authData: { authType: 'token', subType: '', values: {} } as any }], + // A cert user, with the auth data parseAuth() gives it + _users: [{ + name: 'admin', + user: { 'client-certificate-data': 'cert', 'client-key-data': 'key' }, + _authData: { authType: 'kube-cert-auth', subType: '', values: { cert: 'cert', certKey: 'key' } }, + }], _selected: true, _invalid: false, _state: undefined as any, @@ -45,7 +50,7 @@ function makeCluster(name: string): KubeConfigFileCluster { // Row wrappers are typed as Subject-or-signal; these are signal wrappers. const read = (wrapper: unknown) => (wrapper as () => unknown)(); -const AUTH_DATA = { authType: 'token', authVal: { token: 't' }, systemShared: false, bodyContent: '' }; +const AUTH_DATA = { authType: 'kube-cert-auth', authVal: { cert: 'cert', certKey: 'key' }, systemShared: false, bodyContent: '' }; // Taken before any test spies on it, so a spy can still defer to it. const realGetAuthData = KubeConfigAuthHelper.prototype.getAuthDataForConnect; From cfe2335536bf3719a65d8ed6172ee709912811fd Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 06:25:36 -0700 Subject: [PATCH 022/121] fix(kube): say why a kube config connect failed A connect that failed without a message, resolved or rejected, left the import row with an empty error. Fall back to "Failed to connect endpoint". --- .../kube-config-import.component.spec.ts | 21 +++++++++++++++++++ .../kube-config-import.component.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts index 58a1c19bf1..9d1fa3e8f4 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -136,6 +136,27 @@ describe('KubeConfigImportComponent', () => { expect(connect).toHaveBeenCalledTimes(2); }); + it('shows a fallback when a connect fails without a message', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); + connect.mockResolvedValueOnce({ busy: false, error: true, message: '' }); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(read(actions[1].state)).toEqual({ message: 'Failed to connect endpoint', error: true }); + expect(connect).toHaveBeenCalledTimes(2); + }); + + it('shows a fallback when a connect rejects without a message', async () => { + vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect').mockReturnValue(AUTH_DATA as any); + connect.mockRejectedValueOnce(new Error('')); + + const actions = await runImport([makeCluster('a'), makeCluster('b')]); + + expect(read(actions[1].actionState)).toMatchObject({ error: true, completed: true, message: 'Failed to connect endpoint' }); + expect(read(actions[1].state)).toEqual({ message: 'Failed to connect endpoint', error: true }); + expect(connect).toHaveBeenCalledTimes(2); + }); + it('moves on when a cluster has no usable auth data', async () => { vi.spyOn(KubeConfigAuthHelper.prototype, 'getAuthDataForConnect') .mockReturnValueOnce(null) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts index a261b95892..a170bab61e 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -256,7 +256,7 @@ export class KubeConfigImportComponent implements OnDestroy { busy: false, error: true, completed: true, - message: err?.message ?? 'Failed to connect endpoint', + message: err?.message || 'Failed to connect endpoint', }), })); @@ -265,7 +265,7 @@ export class KubeConfigImportComponent implements OnDestroy { take(1) ).subscribe((status: IActionMonitorComponentState) => { if (status.error) { - connect.state.next({ message: status.message, error: true }); + connect.state.next({ message: status.message || 'Failed to connect endpoint', error: true }); } this.processAction(next); })); From eaaffdcfe264642538bf7492eeb3884ecce6e31e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:14:04 -0700 Subject: [PATCH 023/121] fix(cf): stop the role picker loading forever The Add User and Manage Roles picker showed "Loading organizations..." until a reload in three cases: - No orgs: the per-org edit check combined an empty list with combineLatest(), which completes without a value. - A failed fetch: catchError() turned it into that same empty list. - Every later open: publishReplay() on the root service cached the dead stream, so no new request was sent. The component cleared its spinner only on next. Combine with combineLatestOrEmpty(), let a failed fetch error and cache it with shareReplay(), which resets on error, and clear the spinner on next, error and complete. A failure shows "Could not load organizations." with a Retry button. The component spec now awaits whenStable(). --- .../manage-users/cf-roles.service.spec.ts | 40 ++++ .../cf/users/manage-users/cf-roles.service.ts | 16 +- .../role-assignment.component.html | 12 ++ .../role-assignment.component.spec.ts | 173 +++++++++--------- .../role-assignment.component.ts | 33 +++- 5 files changed, 174 insertions(+), 100 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts index b5ce6bb0c6..be26f22a87 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts @@ -107,6 +107,46 @@ describe('CfRolesService', () => { req.flush({ resources: [], totalResults: 0 }); }); + it('fetchOrgs emits an empty list when the endpoint has no orgs', async () => { + const service = TestBed.inject(CfRolesService); + const result = firstValueFrom(service.fetchOrgs('cf-empty')); + + httpMock.expectOne('/pp/v1/cf/orgs/cf-empty?per_page=500') + .flush({ resources: [], totalResults: 0 }); + + // combineLatest([]) completes without a value; firstValueFrom would reject. + expect(await result).toEqual([]); + }); + + it('fetchOrgs reports a server error to the caller', async () => { + const service = TestBed.inject(CfRolesService); + const result = firstValueFrom(service.fetchOrgs('cf-5xx')); + + httpMock.expectOne('/pp/v1/cf/orgs/cf-5xx?per_page=500') + .flush('boom', { status: 502, statusText: 'Bad Gateway' }); + + await expect(result).rejects.toMatchObject({ status: 502 }); + }); + + it('fetchOrgs fetches again after a failure instead of replaying it', async () => { + const service = TestBed.inject(CfRolesService); + const failed = firstValueFrom(service.fetchOrgs('cf-retry')).catch(() => 'failed'); + httpMock.expectOne('/pp/v1/cf/orgs/cf-retry?per_page=500') + .flush('boom', { status: 500, statusText: 'Server Error' }); + await failed; + + const retried = firstValueFrom(service.fetchOrgs('cf-retry')); + httpMock.expectOne('/pp/v1/cf/orgs/cf-retry?per_page=500') + .flush({ resources: [], totalResults: 0 }); + expect(await retried).toEqual([]); + + // A success stays cached: the next caller gets it without a request. + const cached = firstValueFrom(service.fetchOrgs('cf-retry')); + httpMock.expectNone('/pp/v1/cf/orgs/cf-retry?per_page=500'); + expect(await cached).toEqual([]); + }); + + it('fetchOrg degrades to error envelope on HTTP failure', async () => { const service = TestBed.inject(CfRolesService); const emissions = firstValueFrom( 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 e643d8f78b..800d5fa0ef 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 @@ -19,6 +19,7 @@ import { take, } from 'rxjs/operators'; import { naturalCompare } from '@stratosui/core'; +import { combineLatestOrEmpty } from '@stratosui/store'; import { CurrentUserPermissionsService } from '../../../../../../core/src/core/permissions/current-user-permissions.service'; import { APIResource, EntityInfo } from '../../../../../../store/src/types/api.types'; @@ -77,11 +78,12 @@ export class CfRolesService { cfGuid: string, ): Observable[]> { return orgOrSpaces$.pipe( - // Create an observable containing the original list of organisations and a corresponding list of whether an org can be edited + // 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( observableOf(orgsOrSpaces), - combineLatest(orgsOrSpaces.map(orgOrSpace => CfRolesService.canEditOrgOrSpace( + combineLatestOrEmpty(orgsOrSpaces.map(orgOrSpace => CfRolesService.canEditOrgOrSpace( userPerms, orgOrSpace.metadata.guid, // entity.cfGuid is typed optional on IOrganization/ISpace; the cnsi @@ -309,18 +311,22 @@ export class CfRolesService { ); } + /** + * Orgs the connected user can edit roles in, sorted by name. A failed fetch + * errors (so the caller can say why there are no orgs) and is not cached: + * shareReplay resets on error, so the next subscriber fetches again. A + * successful result is cached for this service instance. + */ fetchOrgs(cfGuid: string): Observable[]> { if (!this.cfOrgs[cfGuid]) { const orgs$ = this.http.get<{ resources: StOrg[]; totalResults: number }>( `/pp/v1/cf/orgs/${cfGuid}?per_page=500`, ).pipe( map(resp => (resp?.resources ?? []).map(adaptOrgToApiResource)), - catchError(() => of([] as APIResource[])), ); this.cfOrgs[cfGuid] = CfRolesService.filterEditableOrgOrSpace(this.userPerms, true, orgs$, cfGuid).pipe( map(orgs => orgs.sort((a, b) => naturalCompare(a.entity.name, b.entity.name))), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: false }), ); } return this.cfOrgs[cfGuid]; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html index 42933fbaaf..6c8a1bd191 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html @@ -13,6 +13,18 @@ Loading organizations…
+ } @else if (orgsError()) { + } @else {
    @for (org of filteredAllowedOrgs(); track org.metadata.guid) { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts index bac250c900..7f893a216c 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts @@ -1,8 +1,11 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { By } from '@angular/platform-browser'; import { describe, it, expect, beforeEach } from 'vitest'; -import { of } from 'rxjs'; +import { EMPTY, Observable, of, throwError } from 'rxjs'; + +import { CfRolesService } from '../../../features/cf/users/manage-users/cf-roles.service'; import { CfCurrentUserPermissions } from '../../../user-permissions/cf-user-permissions-checkers'; import { OrgUserRoleNames, SpaceUserRoleNames } from '../../../store/types/cf-user.types'; @@ -60,10 +63,28 @@ describe('RoleAssignmentComponent', () => { }).compileComponents(); } - function createFixture(): ComponentFixture { + /** Default harness, but with fetchOrgs driven by the test. */ + async function setupFetchOrgs(fetchOrgs: () => Observable): Promise { + await TestBed.configureTestingModule({ + imports: [RoleAssignmentComponent], + providers: [ + provideZonelessChangeDetection(), + ...provideRoleAssignmentTestDeps(defaultCfg), + { provide: CfRolesService, useValue: { fetchOrgs, fetchSpacesForOrg: () => of([]) } }, + ], + }).compileComponents(); + } + + function picker(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('[data-testid="org-picker"]'); + } + + const serverError = () => throwError(() => new HttpErrorResponse({ status: 503, statusText: 'Service Unavailable' })); + + async function createFixture(): Promise> { const fixture = TestBed.createComponent(RoleAssignmentComponent); fixture.componentRef.setInput('cfGuid', cfGuid); - fixture.detectChanges(); + await fixture.whenStable(); return fixture; } @@ -73,9 +94,7 @@ describe('RoleAssignmentComponent', () => { // exclude space-manager-only users (the dead-end bug). // Default cfg: ORGANIZATION_CHANGE_ROLES true only for o1, but fetchOrgs returns both. await setup(); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const component = fixture.componentInstance; // Both o1 and o2 must appear in the picker (fetchOrgs result is the source of truth). @@ -105,9 +124,7 @@ describe('RoleAssignmentComponent', () => { const fixture = TestBed.createComponent(RoleAssignmentComponent); fixture.componentRef.setInput('cfGuid', cfGuid); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -116,9 +133,7 @@ describe('RoleAssignmentComponent', () => { // Pick the org to trigger space loading + per-space permission resolution component.pickOrg(orgX); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); // Space-level permission resolved per-space expect(component.canChangeSpaceRoles('orgX', 'spaceA')).toBe(true); @@ -127,17 +142,13 @@ describe('RoleAssignmentComponent', () => { it('materializes a picked org as an accordion section with org + space roles', async () => { await setup(); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const component = fixture.componentInstance; // Pick o1 component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); // Should have o1 in picked orgs expect(component.pickedOrgs()).toEqual([o1]); @@ -159,9 +170,7 @@ describe('RoleAssignmentComponent', () => { const fixture = TestBed.createComponent(RoleAssignmentComponent); fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('lockedOrg', { guid: 'o1', name: 'Org One' }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -181,7 +190,6 @@ describe('RoleAssignmentComponent', () => { fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('users', [user]); fixture.componentRef.setInput('baseline', {}); - fixture.detectChanges(); await fixture.whenStable(); const component = fixture.componentInstance; @@ -191,10 +199,10 @@ describe('RoleAssignmentComponent', () => { // Pick o1 then toggle Manager component.pickOrg(o1); - fixture.detectChanges(); + await fixture.whenStable(); component.onToggleOrgRole(o1, OrgUserRoleNames.MANAGER, true); - fixture.detectChanges(); + await fixture.whenStable(); expect(emitted.length).toBeGreaterThan(0); const lastEmit = emitted[emitted.length - 1]; @@ -213,21 +221,19 @@ describe('RoleAssignmentComponent', () => { fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('users', [user]); fixture.componentRef.setInput('baseline', {}); - fixture.detectChanges(); await fixture.whenStable(); const component = fixture.componentInstance; // Pick o1 (also expands it) and toggle Manager component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); const emitted: CfRoleChange[][] = []; component.changeSet.subscribe((v: CfRoleChange[]) => emitted.push(v)); component.onToggleOrgRole(o1, OrgUserRoleNames.MANAGER, true); - fixture.detectChanges(); + await fixture.whenStable(); // Capture the changeSet after toggle const changeSetBeforeCollapse = emitted[emitted.length - 1]; @@ -237,12 +243,12 @@ describe('RoleAssignmentComponent', () => { // Collapse the org section (same path the template button uses) expect(component.isExpanded('o1')).toBe(true); component.toggleExpanded('o1'); - fixture.detectChanges(); + await fixture.whenStable(); expect(component.isExpanded('o1')).toBe(false); // Re-expand component.toggleExpanded('o1'); - fixture.detectChanges(); + await fixture.whenStable(); expect(component.isExpanded('o1')).toBe(true); // (a) selection() still contains the toggled Manager role @@ -266,17 +272,13 @@ describe('RoleAssignmentComponent', () => { fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('users', [user]); fixture.componentRef.setInput('baseline', {}); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; // Pick o1 so spaces are loaded (space s1 is available via cfg) component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); // Initially no roles in baseline → org-user should NOT be disabled expect(component.isOrgUserDisabled('o1')).toBe(false); @@ -303,7 +305,7 @@ describe('RoleAssignmentComponent', () => { }, }; fixture.componentRef.setInput('baseline', baselineWithSpaceRole); - fixture.detectChanges(); + await fixture.whenStable(); // The computed orgUserDisabledMap must have re-run; org-user is now disabled expect(component.isOrgUserDisabled('o1')).toBe(true); @@ -311,7 +313,7 @@ describe('RoleAssignmentComponent', () => { it('exposes role defs derived from the registry in the established order', async () => { await setup(); - const fixture = createFixture(); + const fixture = await createFixture(); const component = fixture.componentInstance; expect(component.orgRoleDefs.map(d => d.label)).toEqual(['Manager', 'Auditor', 'Billing Manager', 'User']); @@ -337,16 +339,13 @@ describe('RoleAssignmentComponent', () => { fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('users', [user]); fixture.componentRef.setInput('baseline', {}); - fixture.detectChanges(); await fixture.whenStable(); const component = fixture.componentInstance; // Pick o1 so spaces are loaded (space s1 from the cfg) component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); // Initially no roles set expect(component.roleCountForOrg('o1')).toBe(0); @@ -354,7 +353,7 @@ describe('RoleAssignmentComponent', () => { // Toggle one org role (Manager) and one space role (Developer in s1) component.onToggleOrgRole(o1, OrgUserRoleNames.MANAGER, true); component.onToggleSpaceRole(o1, { guid: 's1', name: 'Space One' }, SpaceUserRoleNames.DEVELOPER, true); - fixture.detectChanges(); + await fixture.whenStable(); expect(component.roleCountForOrg('o1')).toBe(2); }); @@ -366,18 +365,15 @@ describe('RoleAssignmentComponent', () => { fixture.componentRef.setInput('cfGuid', cfGuid); fixture.componentRef.setInput('users', [user]); fixture.componentRef.setInput('baseline', {}); - fixture.detectChanges(); await fixture.whenStable(); const component = fixture.componentInstance; component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); component.onToggleOrgRole(o1, OrgUserRoleNames.AUDITOR, true); - fixture.detectChanges(); + await fixture.whenStable(); expect(component.roleCountForOrg('o1')).toBe(1); }); @@ -396,19 +392,15 @@ describe('RoleAssignmentComponent', () => { o2: [], }, }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const component = fixture.componentInstance; component.pickOrg(o1); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); // Apply filter component.setSpaceFilter('o1', 'Alpha'); - fixture.detectChanges(); + await fixture.whenStable(); const filtered = component.filteredSpacesFor('o1'); expect(filtered.length).toBe(1); @@ -436,9 +428,7 @@ describe('RoleAssignmentComponent', () => { o2: { name: 'o2', orgGuid: 'o2', permissions: { managers: false, billing_managers: false, auditors: false, users: true } }, }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; const guids = component.pickedOrgs().map(o => o.metadata.guid); @@ -468,9 +458,7 @@ describe('RoleAssignmentComponent', () => { o1: { name: 'o1', orgGuid: 'o1', permissions: { managers: false, billing_managers: false, auditors: false, users: true } }, }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; // lockedOrg path seeds o1; the baseline-seeding effect must not run @@ -510,9 +498,7 @@ describe('RoleAssignmentComponent', () => { }, }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -541,9 +527,7 @@ describe('RoleAssignmentComponent', () => { u1: { o1: { name: 'Org One', orgGuid: 'o1', permissions: { managers: true, billing_managers: false, auditors: false, users: true } } }, u2: { o1: { name: 'Org One', orgGuid: 'o1', permissions: { managers: false, billing_managers: false, auditors: false, users: true } } }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -569,9 +553,7 @@ describe('RoleAssignmentComponent', () => { u1: { o1: { name: 'Org One', orgGuid: 'o1', permissions: { managers: true, billing_managers: false, auditors: false, users: true } } }, u2: { o1: { name: 'Org One', orgGuid: 'o1', permissions: { managers: false, billing_managers: false, auditors: false, users: true } } }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const summary = fixture.debugElement.query(By.css('[data-testid="org-role-summary"]')); expect(summary).toBeTruthy(); @@ -583,9 +565,7 @@ describe('RoleAssignmentComponent', () => { orgs: [{ guid: 'o1', name: 'Org One' }, { guid: 'o2', name: 'Org Two' }], spacesByOrg: { o1: [], o2: [] }, }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const c = fixture.componentInstance; c.pickOrg(o1); @@ -602,9 +582,7 @@ describe('RoleAssignmentComponent', () => { orgs: [{ guid: 'o1', name: 'Org One' }, { guid: 'o2', name: 'Org Two' }], spacesByOrg: { o1: [], o2: [] }, }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const c = fixture.componentInstance; c.pickOrg(o1); @@ -620,19 +598,16 @@ describe('RoleAssignmentComponent', () => { it('H: view mode initializes from the persisted localStorage choice', async () => { localStorage.setItem(ROLE_ASSIGNMENT_VIEW_MODE_KEY, 'split'); await setup({ orgs: [{ guid: 'o1', name: 'Org One' }], spacesByOrg: { o1: [] } }); - const fixture = createFixture(); - await fixture.whenStable(); + const fixture = await createFixture(); expect(fixture.componentInstance.viewMode()).toBe('split'); }); it('H2: setViewMode persists the choice to localStorage', async () => { await setup({ orgs: [{ guid: 'o1', name: 'Org One' }], spacesByOrg: { o1: [] } }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); fixture.componentInstance.pickOrg(o1); fixture.componentInstance.setViewMode('split'); - fixture.detectChanges(); + await fixture.whenStable(); expect(localStorage.getItem(ROLE_ASSIGNMENT_VIEW_MODE_KEY)).toBe('split'); }); @@ -641,17 +616,13 @@ describe('RoleAssignmentComponent', () => { orgs: [{ guid: 'o1', name: 'Org One' }, { guid: 'o2', name: 'Org Two' }], spacesByOrg: { o1: [], o2: [] }, }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const c = fixture.componentInstance; c.pickOrg(o1); c.pickOrg(o2); // pickedOrgs [o2, o1] c.setViewMode('split'); // active = o2 - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const list = fixture.debugElement.query(By.css('[data-testid="role-pane-list"]')); const detail = fixture.debugElement.query(By.css('[data-testid="role-pane-detail"]')); @@ -663,20 +634,18 @@ describe('RoleAssignmentComponent', () => { it('I2: the view-toggle button flips accordion ↔ split', async () => { await setup({ orgs: [{ guid: 'o1', name: 'Org One' }], spacesByOrg: { o1: [] } }); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const c = fixture.componentInstance; c.pickOrg(o1); - fixture.detectChanges(); + await fixture.whenStable(); const toggle = fixture.debugElement.query(By.css('[data-testid="view-toggle"]')); expect(toggle).toBeTruthy(); expect(c.viewMode()).toBe('accordion'); toggle.nativeElement.click(); - fixture.detectChanges(); + await fixture.whenStable(); expect(c.viewMode()).toBe('split'); }); @@ -691,16 +660,14 @@ describe('RoleAssignmentComponent', () => { }); const o3 = makeOrg('o3', 'Org Three'); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const component = fixture.componentInstance; // Pick o1 first, then o3 component.pickOrg(o1); component.pickOrg(o3); - fixture.detectChanges(); + await fixture.whenStable(); expect(component.pickedOrgs()[0].metadata.guid).toBe('o3'); }); @@ -741,9 +708,7 @@ describe('RoleAssignmentComponent', () => { o1: { name: 'Org One', orgGuid: 'o1', permissions: { managers: false, billing_managers: false, auditors: false, users: true } }, }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -753,9 +718,7 @@ describe('RoleAssignmentComponent', () => { // Expand o1 — should trigger lazy space load component.toggleExpanded('o1'); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); expect(component.isExpanded('o1')).toBe(true); expect(fetchSpacesCalled).toBe(true); @@ -766,11 +729,45 @@ describe('RoleAssignmentComponent', () => { expect(component.spacesLoadingFor('o1')).toBe(false); }); + it('clears the org spinner when the org fetch completes without a value', async () => { + await setupFetchOrgs(() => EMPTY); + const fixture = await createFixture(); + + expect(fixture.componentInstance.orgsLoading()).toBe(false); + expect(picker(fixture).textContent).not.toContain('Loading organizations'); + expect(picker(fixture).textContent).toContain('No organizations'); + }); + + it('shows an error and clears the spinner when the org fetch fails', async () => { + await setupFetchOrgs(serverError); + const fixture = await createFixture(); + + expect(fixture.componentInstance.orgsLoading()).toBe(false); + expect(picker(fixture).textContent).not.toContain('Loading organizations'); + const alert = picker(fixture).querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Could not load organizations'); + }); + + it('retry fetches the orgs again after a failure', async () => { + const results = [serverError(), of([o1])]; + let calls = 0; + await setupFetchOrgs(() => results[calls++]); + const fixture = await createFixture(); + + const retry = picker(fixture).querySelector('[data-testid="org-retry"]'); + expect(retry).toBeTruthy(); + retry!.click(); + await fixture.whenStable(); + + expect(calls).toBe(2); + expect(picker(fixture).querySelector('[role="alert"]')).toBeNull(); + const names = Array.from(picker(fixture).querySelectorAll('.role-assignment__org-btn')).map(b => b.textContent?.trim()); + expect(names).toEqual(['Org One']); + }); + it('E: orgsLoading is false after ngOnInit resolves', async () => { await setup(); - const fixture = createFixture(); - await fixture.whenStable(); - fixture.detectChanges(); + const fixture = await createFixture(); const component = fixture.componentInstance; // After the synchronous of() from the harness resolves orgsLoading must be false diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts index 53fd096d30..c748511164 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts @@ -113,6 +113,9 @@ export class RoleAssignmentComponent implements OnInit, OnDestroy { /** True until the org list fetch resolves (drives the picker spinner). */ readonly orgsLoading = signal(true); + /** Set when the org list fetch fails; the picker shows it with a retry. */ + readonly orgsError = signal(null); + /** Orgs whose per-org space fetch is in flight (drives per-section spinner). */ private readonly spacesLoading = signal>(new Set()); spacesLoadingFor(orgGuid: string): boolean { @@ -228,13 +231,7 @@ export class RoleAssignmentComponent implements OnInit, OnDestroy { // --- Lifecycle --- ngOnInit(): void { - // Fetch all orgs, then resolve permissions per org - const orgsSub = this.cfRolesService.fetchOrgs(this.cfGuid).subscribe(orgs => { - this.allOrgs.set(orgs); - this.resolvePermissions(orgs); - this.orgsLoading.set(false); - }); - this.subs.add(orgsSub); + this.loadOrgs(); // lockedOrg: seed pickedOrgs from it once orgs load (or immediately if lockedOrg is set) if (this.lockedOrg) { @@ -248,6 +245,28 @@ export class RoleAssignmentComponent implements OnInit, OnDestroy { this.subs.unsubscribe(); } + /** + * Fetch all orgs, then resolve permissions per org. The spinner clears on + * every outcome: a value, an error, or completion without a value. + */ + loadOrgs(): void { + this.orgsLoading.set(true); + this.orgsError.set(null); + const orgsSub = this.cfRolesService.fetchOrgs(this.cfGuid).subscribe({ + next: orgs => { + this.allOrgs.set(orgs); + this.resolvePermissions(orgs); + this.orgsLoading.set(false); + }, + error: () => { + this.orgsError.set('Could not load organizations.'); + this.orgsLoading.set(false); + }, + complete: () => this.orgsLoading.set(false), + }); + this.subs.add(orgsSub); + } + // --- Permission resolution --- private resolvePermissions(orgs: APIResource[]): void { From b74bd84eba87b0d19658ceb035b4c8d0dea16149 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:14:04 -0700 Subject: [PATCH 024/121] fix(cf): fetch org names again after a failure The org-name lookup for existing roles caught a failed request before publishReplay(), so "no names" was cached on the root service for the rest of the session. Cache with shareReplay(), which resets on error, and recover outside the cache: the call still resolves without names and the next one fetches again. --- .../users/manage-users/cf-roles.service.spec.ts | 15 +++++++++++++++ .../cf/users/manage-users/cf-roles.service.ts | 10 ++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts index be26f22a87..e4b3b86636 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts @@ -146,6 +146,21 @@ describe('CfRolesService', () => { expect(await cached).toEqual([]); }); + it('populateRoles fetches org names again after a failure instead of caching it', async () => { + const service = TestBed.inject(CfRolesService); + const user = stUser({ guid: 'user-1', orgRoles: [{ orgGuid: 'org-1', roles: ['manager'] }] }); + + // The failure still lets the roles resolve (without the org name). + const first = firstValueFrom(service.populateRoles('cf-names', [user]).pipe(take(1))); + httpMock.expectOne('/pp/v1/cf/orgs/cf-names?per_page=500&page=1') + .flush('boom', { status: 500, statusText: 'Server Error' }); + expect((await first)['user-1']['org-1'].permissions.managers).toBe(true); + + const second = firstValueFrom(service.populateRoles('cf-names', [user]).pipe(take(1))); + httpMock.expectOne('/pp/v1/cf/orgs/cf-names?per_page=500&page=1') + .flush({ resources: [{ guid: 'org-1', name: 'My Org', cnsiGuid: 'cf-names' }], totalResults: 1 }); + expect((await second)['user-1']['org-1'].name).toBe('My Org'); + }); it('fetchOrg degrades to error envelope on HTTP failure', async () => { const service = TestBed.inject(CfRolesService); 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 800d5fa0ef..234ff557ff 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 @@ -342,12 +342,14 @@ export class CfRolesService { private fetchOrgNames(cfGuid: string): Observable { if (!this.cfOrgNames[cfGuid]) { this.cfOrgNames[cfGuid] = this.drainCfList(`/pp/v1/cf/orgs/${cfGuid}`).pipe( - catchError(() => of([] as StOrg[])), - publishReplay(1), - refCount(), + shareReplay({ bufferSize: 1, refCount: false }), ); } - return this.cfOrgNames[cfGuid]; + // Recover outside the cache: this call still resolves (without names), + // and the next call fetches again instead of replaying the failure. + return this.cfOrgNames[cfGuid].pipe( + catchError(() => of([] as StOrg[])), + ); } // Drains EVERY page of a native /pp/v1/cf list endpoint into one array. The From effc623074bde3287cb36f6dd69cf260de08cd9b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:22:26 -0700 Subject: [PATCH 025/121] fix(cf): start each roles dialog without old roles The roles service is a root singleton. Its existing and new roles streams used publishReplay(1), whose subject outlives the refCount, and toObservable() of the selected users, which holds the last value it pushed. A new Manage Roles dialog therefore got the previous dialog's roles first, and the picker could pre-pick the previous users' orgs. Watch the users with observeSignal() and share with shareReplay({ refCount: true }) so each dialog starts clean. --- .../manage-users/cf-roles.service.spec.ts | 35 +++++++++++++++++++ .../cf/users/manage-users/cf-roles.service.ts | 25 ++++++------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts index e4b3b86636..a831e2e107 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.spec.ts @@ -17,7 +17,9 @@ import { import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateTestCfEndpointServiceProvider } from '@test-framework/cloud-foundry-endpoint-service.helper'; +import { CfUsersRolesDataService } from '../../../../services/domain-data/cf-users-roles-data.service'; import { StUser } from '../../../../services/endpoint-data/stratos-types'; +import { CfUserRolesSelected } from '../../../../store/types/users-roles.types'; import { generateCFEntities } from '../../../../cf-entity-generator'; import { CfRolesService } from './cf-roles.service'; @@ -162,6 +164,39 @@ describe('CfRolesService', () => { expect((await second)['user-1']['org-1'].name).toBe('My Org'); }); + it('existingRoles$ does not replay the previous dialog\'s roles to the next one', () => { + const service = TestBed.inject(CfRolesService); + const rolesData = TestBed.inject(CfUsersRolesDataService); + const alice = stUser({ guid: 'alice', orgRoles: [{ orgGuid: 'org-1', roles: ['manager'] }] }); + const bob = stUser({ guid: 'bob', username: 'bob' }); + + // First dialog: alice's roles resolve. + rolesData.setUsers('cf-1', [alice]); + const first: CfUserRolesSelected[] = []; + const sub1 = service.existingRoles$.subscribe(r => first.push(r)); + TestBed.tick(); + httpMock.expectOne('/pp/v1/cf/orgs/cf-1?per_page=500&page=1') + .flush({ resources: [{ guid: 'org-1', name: 'My Org', cnsiGuid: 'cf-1' }], totalResults: 1 }); + expect(first.at(-1)).toHaveProperty('alice'); + sub1.unsubscribe(); + + // Next dialog, for bob: nothing about alice may reach it, and loading + // must not clear before bob's roles are known. + rolesData.setUsers('cf-1', [bob]); + const second: CfUserRolesSelected[] = []; + const loading: boolean[] = []; + const sub2 = service.existingRoles$.subscribe(r => second.push(r)); + const sub3 = service.loading$.subscribe(l => loading.push(l)); + expect(loading).toEqual([true]); + TestBed.tick(); + + expect(second.length).toBeGreaterThan(0); + expect(second.some(r => 'alice' in r)).toBe(false); + expect(second.at(-1)).toHaveProperty('bob'); + sub2.unsubscribe(); + sub3.unsubscribe(); + }); + it('fetchOrg degrades to error envelope on HTTP failure', async () => { const service = TestBed.inject(CfRolesService); const emissions = firstValueFrom( 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 234ff557ff..ecf1188eac 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 @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Injectable, Injector, inject } from '@angular/core'; import { combineLatest, EMPTY, from, Observable, of as observableOf, of } from 'rxjs'; import { take, catchError, @@ -10,16 +9,14 @@ import { take, filter, map, mergeMap, - publishReplay, reduce, - refCount, shareReplay, startWith, switchMap, } from 'rxjs/operators'; import { naturalCompare } from '@stratosui/core'; -import { combineLatestOrEmpty } from '@stratosui/store'; +import { combineLatestOrEmpty, observeSignal } from '@stratosui/store'; import { CurrentUserPermissionsService } from '../../../../../../core/src/core/permissions/current-user-permissions.service'; import { APIResource, EntityInfo } from '../../../../../../store/src/types/api.types'; @@ -56,6 +53,7 @@ export class CfRolesService { private userPerms = inject(CurrentUserPermissionsService); private rolesData = inject(CfUsersRolesDataService); private http = inject(HttpClient); + private injector = inject(Injector); existingRoles$: Observable; @@ -117,19 +115,22 @@ export class CfRolesService { } constructor() { - this.users$ = toObservable(this.rolesData.users); + // This service is a root singleton shared by every dialog. observeSignal + // reads the signal fresh on each subscribe, and shareReplay with refCount + // drops the cached value once the last subscriber leaves, so a new dialog + // never sees the previous dialog's users or roles. (toObservable() would + // 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(toObservable(this.rolesData.cfGuid)), + combineLatestOperators(observeSignal(this.rolesData.cfGuid, this.injector)), filter(([_users, cfGuid]) => !!cfGuid), switchMap(([users, cfGuid]) => this.populateRoles(cfGuid, users)), distinctUntilChanged(), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }), ); - this.newRoles$ = toObservable(this.rolesData.newRoles).pipe( + this.newRoles$ = observeSignal(this.rolesData.newRoles, this.injector).pipe( distinctUntilChanged(), - publishReplay(1), - refCount() + shareReplay({ bufferSize: 1, refCount: true }), ); this.loading$ = this.existingRoles$.pipe( From 32fcc8823aa4b4c33bc5398bd4e931b58ddf769e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:22:44 -0700 Subject: [PATCH 026/121] fix(cf): say so when there are no orgs to manage With no organizations to manage and nothing searched, the role picker said "No organizations match." Say "You have no organizations where you can manage roles." instead; a search with no hits keeps the old text. --- .../role-assignment.component.html | 6 +++++- .../role-assignment.component.spec.ts | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html index 6c8a1bd191..8ff0cc2a9f 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html @@ -40,7 +40,11 @@ } @if (filteredAllowedOrgs().length === 0) { -
  • No organizations match.
  • + @if (orgSearchText()) { +
  • No organizations match.
  • + } @else { +
  • You have no organizations where you can manage roles.
  • + } }
} diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts index 7f893a216c..4069ad490f 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.spec.ts @@ -735,7 +735,7 @@ describe('RoleAssignmentComponent', () => { expect(fixture.componentInstance.orgsLoading()).toBe(false); expect(picker(fixture).textContent).not.toContain('Loading organizations'); - expect(picker(fixture).textContent).toContain('No organizations'); + expect(picker(fixture).textContent).toContain('You have no organizations where you can manage roles.'); }); it('shows an error and clears the spinner when the org fetch fails', async () => { @@ -772,5 +772,23 @@ describe('RoleAssignmentComponent', () => { const component = fixture.componentInstance; // After the synchronous of() from the harness resolves orgsLoading must be false expect(component.orgsLoading()).toBe(false); + it('says there are no orgs to manage when the list is empty and nothing is searched', async () => { + await setupFetchOrgs(() => of([])); + const fixture = await createFixture(); + + expect(picker(fixture).textContent).toContain('You have no organizations where you can manage roles.'); + expect(picker(fixture).textContent).not.toContain('No organizations match.'); + }); + + it('says no orgs match when a search has no hits', async () => { + await setup(); + const fixture = await createFixture(); + + fixture.componentInstance.orgSearchText.set('zzz'); + await fixture.whenStable(); + + expect(picker(fixture).textContent).toContain('No organizations match.'); + expect(picker(fixture).textContent).not.toContain('You have no organizations'); + }); }); }); From b31600a85c358747063c623062a3080f5742d5bb Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:22:44 -0700 Subject: [PATCH 027/121] fix(cf): stop the role picker space spinner hanging Expanding an org whose space fetch failed left "Loading spaces..." up for good and raised an unhandled error. Clear the spinner on next, error and complete, and show "Could not load spaces." with a Retry for that org instead of "No spaces in this organization." --- .../role-assignment.component.html | 12 ++++ .../role-assignment.component.spec.ts | 61 ++++++++++++++++--- .../role-assignment.component.ts | 27 ++++++-- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html index 8ff0cc2a9f..7fdc3c9cc4 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.html @@ -105,6 +105,18 @@

Loading spaces… + } @else if (spacesFailedFor(org.metadata.guid)) { + } @else { { }).compileComponents(); } - /** Default harness, but with fetchOrgs driven by the test. */ - async function setupFetchOrgs(fetchOrgs: () => Observable): Promise { + /** Default harness, but with fetchOrgs (and optionally fetchSpacesForOrg) driven by the test. */ + async function setupFetchOrgs( + fetchOrgs: () => Observable, + fetchSpacesForOrg: () => Observable = () => of([]), + ): Promise { await TestBed.configureTestingModule({ imports: [RoleAssignmentComponent], providers: [ provideZonelessChangeDetection(), ...provideRoleAssignmentTestDeps(defaultCfg), - { provide: CfRolesService, useValue: { fetchOrgs, fetchSpacesForOrg: () => of([]) } }, + { provide: CfRolesService, useValue: { fetchOrgs, fetchSpacesForOrg } }, ], }).compileComponents(); } + function orgSection(fixture: ComponentFixture, orgGuid: string): HTMLElement { + return fixture.nativeElement.querySelector(`[data-org="${orgGuid}"]`); + } + function picker(fixture: ComponentFixture): HTMLElement { return fixture.nativeElement.querySelector('[data-testid="org-picker"]'); } @@ -765,13 +772,6 @@ describe('RoleAssignmentComponent', () => { expect(names).toEqual(['Org One']); }); - it('E: orgsLoading is false after ngOnInit resolves', async () => { - await setup(); - const fixture = await createFixture(); - - const component = fixture.componentInstance; - // After the synchronous of() from the harness resolves orgsLoading must be false - expect(component.orgsLoading()).toBe(false); it('says there are no orgs to manage when the list is empty and nothing is searched', async () => { await setupFetchOrgs(() => of([])); const fixture = await createFixture(); @@ -790,5 +790,46 @@ describe('RoleAssignmentComponent', () => { expect(picker(fixture).textContent).toContain('No organizations match.'); expect(picker(fixture).textContent).not.toContain('You have no organizations'); }); + + it('shows an error and clears the space spinner when the space fetch fails', async () => { + await setupFetchOrgs(() => of([o1]), serverError); + const fixture = await createFixture(); + + fixture.componentInstance.pickOrg(o1); + await fixture.whenStable(); + + expect(fixture.componentInstance.spacesLoadingFor('o1')).toBe(false); + const section = orgSection(fixture, 'o1'); + expect(section.textContent).not.toContain('Loading spaces'); + expect(section.textContent).not.toContain('No spaces in this organization.'); + expect(section.querySelector('[role="alert"]')?.textContent).toContain('Could not load spaces.'); + }); + + it('space retry fetches the spaces again after a failure', async () => { + const results = [serverError(), of([space1])]; + let calls = 0; + await setupFetchOrgs(() => of([o1]), () => results[calls++]); + const fixture = await createFixture(); + fixture.componentInstance.pickOrg(o1); + await fixture.whenStable(); + + const retry = orgSection(fixture, 'o1').querySelector('[data-testid="space-retry"]'); + expect(retry).toBeTruthy(); + retry!.click(); + await fixture.whenStable(); + + expect(calls).toBe(2); + expect(orgSection(fixture, 'o1').querySelector('[role="alert"]')).toBeNull(); + expect(fixture.componentInstance.spacesFor('o1')).toEqual([space1]); + expect(orgSection(fixture, 'o1').querySelector('[data-space="s1"]')).toBeTruthy(); + }); + + it('E: orgsLoading is false after ngOnInit resolves', async () => { + await setup(); + const fixture = await createFixture(); + + const component = fixture.componentInstance; + // After the synchronous of() from the harness resolves orgsLoading must be false + expect(component.orgsLoading()).toBe(false); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts index c748511164..2d3501f144 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/role-assignment/role-assignment.component.ts @@ -122,6 +122,12 @@ export class RoleAssignmentComponent implements OnInit, OnDestroy { return this.spacesLoading().has(orgGuid); } + /** Orgs whose last space fetch failed (the section shows an error with a retry). */ + private readonly spacesFailed = signal>(new Set()); + spacesFailedFor(orgGuid: string): boolean { + return this.spacesFailed().has(orgGuid); + } + /** Permission map: orgGuid → can edit */ private readonly canEditByOrg = signal>({}); @@ -395,15 +401,26 @@ export class RoleAssignmentComponent implements OnInit, OnDestroy { // --- Space loading --- - private loadSpacesFor(orgGuid: string): void { + /** Also the section's Retry: a failed fetch leaves spacesByOrg unset, so this fetches again. */ + protected loadSpacesFor(orgGuid: string): void { if (this.spacesByOrg()[orgGuid]) { return; // already loaded } this.spacesLoading.update(s => { const next = new Set(s); next.add(orgGuid); return next; }); - const spaceSub = this.cfRolesService.fetchSpacesForOrg(this.cfGuid, orgGuid).subscribe(spaces => { - this.spacesByOrg.update(m => ({ ...m, [orgGuid]: spaces })); - this.spacesLoading.update(s => { const next = new Set(s); next.delete(orgGuid); return next; }); - this.resolveSpacePermissions(orgGuid, spaces); + this.spacesFailed.update(s => { const next = new Set(s); next.delete(orgGuid); return next; }); + // The spinner clears on every outcome: a value, an error, or completion without a value. + const done = () => this.spacesLoading.update(s => { const next = new Set(s); next.delete(orgGuid); return next; }); + const spaceSub = this.cfRolesService.fetchSpacesForOrg(this.cfGuid, orgGuid).subscribe({ + next: spaces => { + this.spacesByOrg.update(m => ({ ...m, [orgGuid]: spaces })); + done(); + this.resolveSpacePermissions(orgGuid, spaces); + }, + error: () => { + this.spacesFailed.update(s => { const next = new Set(s); next.add(orgGuid); return next; }); + done(); + }, + complete: done, }); this.subs.add(spaceSub); } From ce54f4c2d5817bef1341f7151a42044bfb6bf423 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:34:23 -0700 Subject: [PATCH 028/121] refactor(cf): remove the orphaned remove-user page The users/remove page lost its last in-app link when the old users list config was deleted in 44cacd1a7d. The users tabs now remove users with inline bulk actions (Remove from Space, Remove from Spaces, Remove from Org and Spaces), so the page was only reachable by typing its URL, and it hung on an empty selection and fetched each user four times. Remove its three routes, the component, its URL helper and its e2e page object, and reword the comments that pointed at it. --- e2e/pages/cloud-foundry/remove-users.page.ts | 41 --- .../src/features/cf/cf.helpers.ts | 17 - .../cf/cloud-foundry-section.routes.ts | 16 - .../cf-users/cloud-foundry-users.component.ts | 10 +- .../manage-users/manage-users.component.ts | 2 +- .../remove-user/remove-user.component.html | 10 - .../remove-user/remove-user.component.spec.ts | 131 -------- .../remove-user/remove-user.component.ts | 318 ------------------ .../user/cf-users-bulk-remove.ts | 2 +- .../components/stepper/step/step.component.ts | 2 +- .../steppers-destructive-flow.spec.ts | 2 +- 11 files changed, 8 insertions(+), 543 deletions(-) delete mode 100644 e2e/pages/cloud-foundry/remove-users.page.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.html delete mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.spec.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.ts diff --git a/e2e/pages/cloud-foundry/remove-users.page.ts b/e2e/pages/cloud-foundry/remove-users.page.ts deleted file mode 100644 index 6201d57222..0000000000 --- a/e2e/pages/cloud-foundry/remove-users.page.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Page, Locator } from '@playwright/test'; -import { BasePage } from '../base.page'; -import { ManageUsersConfirmStep } from './manage-users.page'; - -/** - * Remove Users Page - * Multi-step user removal wizard - */ -export class RemoveUsersPage extends BasePage { - private readonly container: Locator; - public stepper: Locator; - public confirmStep: ManageUsersConfirmStep; - - static determineUrl(cfGuid: string, orgGuid?: string, spaceGuid?: string, userGuid?: string): string { - let url = `/cloud-foundry/${cfGuid}`; - if (orgGuid) { - url += `/organizations/${orgGuid}`; - } - if (spaceGuid) { - url += `/spaces/${spaceGuid}`; - } - url += '/users/remove'; - if (userGuid) { - url += `?user=${userGuid}`; - } - return url; - } - - constructor(page: Page, cfGuid: string, orgGuid?: string, spaceGuid?: string, userGuid?: string) { - super(page); - - this.container = page.locator('app-remove-users'); - this.stepper = page.locator('app-stepper, mat-stepper'); - this.confirmStep = new ManageUsersConfirmStep(page); - } - - async navigateTo(cfGuid: string, orgGuid?: string, spaceGuid?: string, userGuid?: string): Promise { - const url = RemoveUsersPage.determineUrl(cfGuid, orgGuid, spaceGuid, userGuid); - await this.page.goto(url); - } -} 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 b0b8be40d9..79eef89c8d 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 @@ -365,23 +365,6 @@ export function createCfOrgSpaceSteppersUrl( return route; } -export function createCfOrgSpaceUserRemovalUrl( - cfGuid: string, - orgGuid?: string, - spaceGuid?: string, -): string { - let route = `/cloud-foundry/${cfGuid}`; - if (orgGuid) { - route += `/organizations/${orgGuid}`; - if (spaceGuid) { - route += `/spaces/${spaceGuid}`; - } - } - route += '/users/remove'; - - return route; -} - export function isServiceInstance(obj: any): IServiceInstance | null { return !!obj && !!obj.service_plan_url ? obj as IServiceInstance : null; } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-section.routes.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-section.routes.ts index 04ff652230..abe38bd388 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-section.routes.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-section.routes.ts @@ -96,7 +96,6 @@ import { CloudFoundrySummaryTabComponent } from './tabs/cf-summary-tab/cloud-fou import { CloudFoundryUsersComponent } from './tabs/cf-users/cloud-foundry-users.component'; import { InviteUsersComponent } from './users/invite-users/invite-users.component'; import { UsersRolesComponent } from './users/manage-users/manage-users.component'; -import { RemoveUserComponent } from './users/remove-user/remove-user.component'; const usersRoles: Routes = [ { @@ -104,21 +103,11 @@ const usersRoles: Routes = [ component: UsersRolesComponent, pathMatch: 'full' }, - { - path: 'users/remove', - component: RemoveUserComponent, - pathMatch: 'full' - }, { path: 'organizations/:orgId/users/manage', component: UsersRolesComponent, pathMatch: 'full' }, - { - path: 'organizations/:orgId/users/remove', - component: RemoveUserComponent, - pathMatch: 'full' - }, { path: 'organizations/:orgId/users/invite', component: InviteUsersComponent, @@ -129,11 +118,6 @@ const usersRoles: Routes = [ component: UsersRolesComponent, pathMatch: 'full' }, - { - path: 'organizations/:orgId/spaces/:spaceId/users/remove', - component: RemoveUserComponent, - pathMatch: 'full' - }, { path: 'organizations/:orgId/spaces/:spaceId/users/invite', component: InviteUsersComponent, diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.ts index 81037e45e4..27fa5d2b90 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.ts @@ -37,12 +37,10 @@ import { AddUserDialogComponent } from '../../users/add-user/add-user-dialog.com // /cloud-foundry/:cnsi/users. CNSI-wide — shows every user the CF returns, // joined with their org and space role grants by the backend handler. // -// Manage Roles + Remove User wizards are still legacy ngrx (separate -// route entries under /cloud-foundry/:cnsi/users/manage|remove). Manage -// Roles is selection-driven via the bulk action (forwards the selected -// users as ?users=g1,g2,…); the per-row kebab now carries only the two -// Remove entries, which navigate with ?user={guid} to pre-fill the -// wizard for that row. The Org Roles + Space Roles columns resolve +// The Manage Roles wizard is a separate route +// (/cloud-foundry/:cnsi/users/manage), selection-driven via the bulk +// action (forwards the selected users as ?users=g1,g2,…). Removal is the +// inline bulk remove. The Org Roles + Space Roles columns resolve // org/space names via EndpointDataService signals so cells never render // raw GUIDs (no_raw_guids feedback rule). @Component({ diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts index 2477e42339..aaf9610472 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts @@ -127,7 +127,7 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { finishButtonText: computed(() => this.applyStartedSignal() ? 'Close' : 'Apply'), onEnter: () => this.confirm?.onEnter(), submit: async () => { - // Two-click apply semantic — see remove-user for the long form. + // Two-click apply semantic. // First click dispatches the changes and returns ignoreSuccess so // the per-row monitor stays visible; second click navigates back. if (this.applyStartedSignal()) { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.html b/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.html deleted file mode 100644 index 8f891da2a9..0000000000 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.html +++ /dev/null @@ -1,10 +0,0 @@ - - @if (title$ | async; as title) { - {{ title }} - } - - - - - - diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.spec.ts deleted file mode 100644 index cd539cfbfe..0000000000 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { provideZonelessChangeDetection } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter, ActivatedRoute } from '@angular/router'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { BehaviorSubject } from 'rxjs'; - -import { - TabNavService -} from '@stratosui/core'; -import { cfCurrentUserPermissionsService } from '@stratosui/cloud-foundry'; -import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; - -import { CloudFoundryTestingModule } from '../../../../cloud-foundry-test.module'; -import { CfUsersPagedDataService } from '../../../../shared/data-services/cf-users-paged-data.service'; -import { ActiveRouteCfOrgSpace } from '../../cf-page.types'; -import { CfRolesService } from '../manage-users/cf-roles.service'; -import { RemoveUserComponent } from './remove-user.component'; -describe('RemoveUserComponent', () => { - let component: RemoveUserComponent; - let fixture: ComponentFixture; - - const mockActiveRoute = { - cfGuid: 'cf-guid', - orgGuid: 'org-guid', - spaceGuid: 'space-guid' - }; - - const mockActivatedRoute = { - snapshot: { - params: { - endpointId: 'cf-guid', - orgId: 'org-guid', - spaceId: 'space-guid' - }, - queryParams: { - user: 'test-user-guid' - } - } - }; - - const mockUser = { - guid: 'test-user-guid', - username: 'test-user', - cnsiGuid: 'cf-guid', - orgRoles: [], - spaceRoles: [] - }; - - const mockExistingRoles = { - 'test-user-guid': { - orgGuid: 'org-guid', - name: 'test-org', - permissions: { - isManager: true, // User has at least one org role - isBillingManager: false, - isAuditor: false - }, - spaces: { - 'space-guid': { - orgGuid: 'org-guid', - orgName: 'test-org', - name: 'test-space', - permissions: { - isManager: false, - isDeveloper: true, // User has at least one space role - isAuditor: false - } - } - } - } - }; - - //Create mock services with BehaviorSubjects to ensure immediate emission - const mockUserSubject = new BehaviorSubject(mockUser); - const mockExistingRolesSubject = new BehaviorSubject(mockExistingRoles); - - const mockUsersData = { - getUser: vi.fn(() => mockUserSubject.asObservable()) - }; - - const mockCfRolesService = { - existingRoles$: mockExistingRolesSubject.asObservable() - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - RemoveUserComponent, - CloudFoundryTestingModule, - NoopAnimationsModule, - ], - providers: [ - provideZonelessChangeDetection(), - provideRouter([]), - provideHttpClient(), - provideHttpClientTesting(), - ...STORE_TEST_PROVIDERS, - { provide: ActiveRouteCfOrgSpace, useValue: mockActiveRoute }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: CfRolesService, useValue: mockCfRolesService }, - { provide: CfUsersPagedDataService, useValue: mockUsersData }, - TabNavService, - ...cfCurrentUserPermissionsService, - ], - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RemoveUserComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - // Clean up subscriptions to prevent unhandled errors - fixture.destroy(); - }); - - // FIXME: This test has async subscription issues in the component constructor - // The component subscribes to observables with .take(1) which can throw EmptyError - // asynchronously after the test completes. The root cause is that selectCfUsersRoles - // store selector completes without emitting when the store state isn't fully initialized. - // Skipping for now to prevent false positives in the test suite. - it.skip('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.ts deleted file mode 100644 index 9c010f2b1f..0000000000 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/remove-user/remove-user.component.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - computed, - inject, - OnDestroy, - signal, - ViewChild, -} from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { combineLatest as obsCombineLatest, Observable, Subscription } from 'rxjs'; -import { take, combineLatest, filter, map, startWith } from 'rxjs/operators'; - -import { CurrentUserPermissionsService } from '../../../../../../core/src/core/permissions/current-user-permissions.service'; -import { PageHeaderComponent } from '../../../../../../core/src/shared/components/page-header/page-header.component'; -import { - SignalStepHandle, - StepComponent, -} from '../../../../../../core/src/shared/components/stepper/step/step.component'; -import { SteppersComponent } from '../../../../../../core/src/shared/components/stepper/steppers/steppers.component'; -import { CfUsersRolesDataService } from '../../../../services/domain-data/cf-users-roles-data.service'; -import { StUser } from '../../../../services/endpoint-data/stratos-types'; -import { CfUsersPagedDataService } from '../../../../shared/data-services/cf-users-paged-data.service'; -import { IUserPermissionInOrg, IUserPermissionInSpace, OrgUserRoleNames, SpaceUserRoleNames } from '../../../../store/types/cf-user.types'; -import { CfRoleChange } from '../../../../store/types/users-roles.types'; -import { CfCurrentUserPermissions } from '../../../../user-permissions/cf-user-permissions-checkers'; -import { ActiveRouteCfOrgSpace } from '../../cf-page.types'; -import { getActiveRouteCfOrgSpaceProvider } from '../../cf.helpers'; -import { CfRolesService } from '../manage-users/cf-roles.service'; -import { UsersRolesConfirmComponent } from '../manage-users/manage-users-confirm/manage-users-confirm.component'; - -@Component({ -selector: 'app-remove-user', - templateUrl: './remove-user.component.html', - providers: [ - getActiveRouteCfOrgSpaceProvider, - CfRolesService - ], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - CommonModule, - PageHeaderComponent, - SteppersComponent, - StepComponent, - UsersRolesConfirmComponent - ] -}) -export class RemoveUserComponent implements AfterViewInit, OnDestroy { - private activeRouteCfOrgSpace = inject(ActiveRouteCfOrgSpace); - private usersData = inject(CfUsersPagedDataService); - private cfRolesService = inject(CfRolesService); - private route = inject(ActivatedRoute); - private userPerms = inject(CurrentUserPermissionsService); - private router = inject(Router); - private cdr = inject(ChangeDetectorRef); - private rolesData = inject(CfUsersRolesDataService); - private state$ = toObservable(this.rolesData.state); - - users$!: Observable; - singleUser$!: Observable; - title$!: Observable; - defaultCancelUrl!: string; - cfGuid!: string; - orgGuid!: string; - spaceGuid!: string; - // FWT-959 Part 2: applyStarted promoted to a signal so the confirm step's - // canClose / disablePrevious / destructiveStep / finishButtonText handle - // fields can be `computed()` over it. The legacy boolean read/write API - // is kept via a getter/setter pair so the existing imperative call-sites - // (e.g. inside `submit`) don't need to learn signal syntax. - applyStartedSignal = signal(false); - get applyStarted(): boolean { return this.applyStartedSignal(); } - set applyStarted(v: boolean) { this.applyStartedSignal.set(v); } - onlySpaces = false; - isBlocked$!: Observable; - - // FWT-959 Part 2: SignalStepHandle for the single Confirm step. - // - // Two-click apply semantic preserved via submit(): - // 1st click (applyStarted == false) — run rolesData.executeChanges(), - // flip applyStarted, return { ignoreSuccess: true } so the stepper - // does NOT auto-advance / does NOT pop a success snackbar. - // 2nd click (applyStarted == true) — return void after explicit - // Router.navigate to the cancel URL (replaces legacy redirect: true). - // - // canClose / destructiveStep / finishButtonText are computed() over the - // applyStartedSignal so the bottom bar flips reactively as soon as the - // first click lands. - @ViewChild('confirm', { static: false }) confirm!: UsersRolesConfirmComponent; - - private isBlockedSig = signal(true); - private isBlockedSub?: Subscription; - - confirmStepHandle: SignalStepHandle = { - valid: signal(true).asReadonly(), - blocked: this.isBlockedSig.asReadonly(), - canClose: computed(() => !this.applyStartedSignal()), - disablePrevious: computed(() => this.applyStartedSignal()), - destructiveStep: computed(() => !this.applyStartedSignal()), - finishButtonText: computed(() => this.applyStartedSignal() ? 'Close' : 'Apply'), - onEnter: () => this.confirm?.onEnter(), - submit: async () => { - if (this.applyStartedSignal()) { - // Second click — close the wizard. Replaces legacy { redirect: true } - // (no payload), which the stepper handled by navigating to its - // cancel URL. We do that explicitly here. - await this.router.navigateByUrl(this.defaultCancelUrl); - return; - } - this.applyStartedSignal.set(true); - await this.rolesData.executeChanges(); - // First click — apply has started; suppress auto-advance and the - // success snackbar so the user can see the per-row monitor while - // the changes settle. - return { ignoreSuccess: true }; - }, - }; - - constructor() { - const activeRouteCfOrgSpace = this.activeRouteCfOrgSpace; - - this.defaultCancelUrl = this.createReturnUrl(activeRouteCfOrgSpace); - this.cfGuid = this.activeRouteCfOrgSpace.cfGuid; - this.orgGuid = this.activeRouteCfOrgSpace.orgGuid; - this.spaceGuid = this.activeRouteCfOrgSpace.spaceGuid; - this.onlySpaces = this.route.snapshot.queryParams.spaces === 'true'; - - // `?users=g1,g2,g3` removes multiple users at once (the bulk path); - // `?user=g1` is the single-user shorthand. - const usersQParam = this.route.snapshot.queryParams.users; - const userQParam = this.route.snapshot.queryParams.user; - if (usersQParam) { - const guids = (usersQParam as string).split(',').map(g => g.trim()).filter(Boolean); - this.users$ = obsCombineLatest( - guids.map(guid => this.usersData.getUser(activeRouteCfOrgSpace.cfGuid, guid)) - ).pipe( - map(users => users.filter((u): u is StUser => !!u)), - take(1) - ); - } else if (userQParam) { - this.users$ = this.usersData.getUser(activeRouteCfOrgSpace.cfGuid, userQParam) - .pipe( - map(user => user ? [user] : []), - take(1) - ); - } else { - console.error('User param not defined'); - return; - } - - this.singleUser$ = this.users$.pipe(map(users => users.length === 1 ? users[0] : null)); - this.title$ = this.users$.pipe( - map(users => users.length === 1 ? `Remove User: ${users[0].username}` : `Remove ${users.length} Users`) - ); - - const cfGuid$ = this.state$.pipe( - combineLatest(this.users$), - take(1) - ); - // Ensure that when we arrive here directly the store is set up with all it needs - cfGuid$.subscribe(([usersRoles, users]) => { - if (!usersRoles?.cfGuid || !users) { - this.rolesData.setUsers(activeRouteCfOrgSpace.cfGuid, users); - } - }); - - this.isBlocked$ = cfGuid$.pipe( - filter(res => !!res), - map(() => false), - startWith(true), - ); - - this.cfRolesService.existingRoles$.pipe( - combineLatest(this.users$), - take(1), - ).subscribe(([existingRoles, users]) => { - const changes: CfRoleChange[] = []; - for (const user of users) { - const orgs = existingRoles[user.guid]; - if (orgs) { - changes.push(...this.getRolesChanges(user, orgs)); - } - } - - if (!changes.length) { - this.rolesData.setChanges([]); - return; - } - - obsCombineLatest(...this.getChangesObservables(changes)).pipe( - map(([...canChanges]) => canChanges), - take(1) - ).subscribe((canChanges) => { - const allowedChanges = canChanges.filter((c) => c.can).map(c => c.change); - this.rolesData.setChanges(allowedChanges); - }); - }); - } - - ngAfterViewInit(): void { - // Bridge isBlocked$ into a signal so the handle's `blocked` field - // re-evaluates reactively once the store data is ready. OnPush parent - // → signal read inside the handle gives us the same gating behaviour - // the legacy `[blocked]="isBlocked$ | async"` template binding had. - this.isBlockedSub = this.isBlocked$.subscribe(v => { - this.isBlockedSig.set(!!v); - this.cdr.markForCheck(); - }); - } - - ngOnDestroy(): void { - this.isBlockedSub?.unsubscribe(); - this.rolesData.clear(); - } - - getChangesObservables(changes: CfRoleChange[]) { - return changes.map((c) => { - const isOrgRole = !c.spaceGuid; - - if (isOrgRole) { - return this.userPerms.can(CfCurrentUserPermissions.ORGANIZATION_CHANGE_ROLES, this.cfGuid, c.orgGuid).pipe( - map((can) => ({ can, change: c })) - ); - } - - return this.userPerms.can(CfCurrentUserPermissions.SPACE_CHANGE_ROLES, this.cfGuid, c.orgGuid, c.spaceGuid).pipe( - map((can) => ({ can, change: c })) - ); - }); - } - - getRolesChanges(user: StUser, orgs: any) { - const changes = []; - const orgGuids = this.orgGuid ? [this.orgGuid] : Object.keys(orgs); - - for (const orgGuid of orgGuids) { - const org: IUserPermissionInOrg = orgs[orgGuid]; - - changes.push(...this.getOrgRolesChanges(user, org)); - changes.push(...this.getSpacesRolesChanges(user, org.spaces)); - } - - return changes; - } - - getOrgRolesChanges(user: StUser, org: IUserPermissionInOrg): CfRoleChange[] { - const changes: CfRoleChange[] = []; - - if (!this.spaceGuid && !this.onlySpaces) { - const roles = org.permissions; - - for (const role of Object.keys(roles)) { - const assigned = roles[role]; - - if (assigned) { - changes.push({ - userGuid: user.guid, - orgGuid: org.orgGuid, - orgName: org.name, - add: false, - role: role as OrgUserRoleNames }); - } - } - } - - return changes; - } - - getSpacesRolesChanges(user: StUser, spaces?: { [spaceGuid: string]: IUserPermissionInSpace }): CfRoleChange[] { - const changes: CfRoleChange[] = []; - if (!spaces) { - return changes; - } - const spaceGuids = this.spaceGuid ? [this.spaceGuid] : Object.keys(spaces); - - for (const spaceGuid of spaceGuids) { - const space: IUserPermissionInSpace = spaces[spaceGuid]; - const roles = space.permissions; - - for (const role of Object.keys(roles)) { - const assigned = roles[role]; - - if (assigned) { - changes.push({ - userGuid: user.guid, - orgGuid: space.orgGuid, - orgName: space.orgName, - spaceGuid, - spaceName: space.name, - add: false, - role: role as SpaceUserRoleNames }); - } - } - } - - return changes; - } - - /** - * Determine where the return url should be. This will only apply when user visits modal directly (otherwise stepper uses previous state) - */ - createReturnUrl(activeRouteCfOrgSpace: ActiveRouteCfOrgSpace): string { - let route = `/cloud-foundry/${activeRouteCfOrgSpace.cfGuid}`; - if (this.activeRouteCfOrgSpace.orgGuid) { - route += `/organizations/${activeRouteCfOrgSpace.orgGuid}`; - if (this.activeRouteCfOrgSpace.spaceGuid) { - route += `/spaces/${activeRouteCfOrgSpace.spaceGuid}`; - } - } - route += `/users`; - return route; - } -} diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-bulk-remove.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-bulk-remove.ts index 3b6fb22ce4..cc7a43af1e 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-bulk-remove.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-bulk-remove.ts @@ -107,7 +107,7 @@ export async function bulkRemoveUsers(deps: BulkRemoveDeps, req: BulkRemoveReque return; } - // Permission-filter per change (mirrors RemoveUserComponent). + // Permission-filter per change. const checks = candidates.map(c => { const can$ = c.spaceGuid ? deps.userPerms.can(CfCurrentUserPermissions.SPACE_CHANGE_ROLES, deps.cfGuid, c.orgGuid, c.spaceGuid) diff --git a/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts b/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts index 10078a84a7..287a6ecbd6 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts @@ -42,7 +42,7 @@ export interface SignalStepHandle { * the existing stepper plumbing. Resolving with `{ ignoreSuccess: true }` * preserves the legacy `StepOnNextResult.ignoreSuccess` semantic — the * step reports success without auto-advancing or emitting the success - * snackbar (used by `manage-users` / `remove-user` two-click apply). + * snackbar (used by the `manage-users` two-click apply). * When omitted the step auto-succeeds — useful for confirmation screens. */ readonly submit?: () => Promise>; diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers-destructive-flow.spec.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers-destructive-flow.spec.ts index df0cce8e1c..34cd93c97f 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers-destructive-flow.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers-destructive-flow.spec.ts @@ -11,7 +11,7 @@ import { TabNavService } from '../../../../tab-nav.service'; import { SignalStepHandle, StepComponent } from '../step/step.component'; import { SteppersComponent } from './steppers.component'; -// Reproduces the manage-users / remove-user Shape-3 wizard: two +// Reproduces the manage-users Shape-3 wizard: two // signal-handle steps where the second is a destructive two-click // confirm. Pins the full Next → confirm-entry → Apply-enabled lifecycle — // the destructive-step busy delay must release the Apply button. From a3fb9e88b772784375b6e1cefc5708861d2c643a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:34:23 -0700 Subject: [PATCH 029/121] fix(cf): load manage-users past a bad user guid The page read its users from the users= / user= query parameter: - One lookup per guid in combineLatest() with no error handler, so a single deleted or mistyped guid failed the whole stream and the roles step spun forever. - The cold stream had two subscribers (the title and the seeding), so each user was fetched twice. - users=, gave no guids and combineLatest([]) never emitted; users= fell through to empty wizard state. Parse the guids once, drop empty ones, and load each user once with its own error handling. The page shows the users that loaded with an error naming each guid that did not, and "No users selected" (or "None of the selected users could be loaded") with a link back when none load. The spec now awaits whenStable(). --- .../manage-users/manage-users.component.html | 40 +++-- .../manage-users.component.spec.ts | 162 ++++++++++++++---- .../manage-users/manage-users.component.ts | 131 +++++++------- 3 files changed, 227 insertions(+), 106 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.html b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.html index d2d1582ce1..71e1614b4c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.html @@ -1,16 +1,28 @@ - - {{ title$ | async}} + + {{ title() }} - - @if (setUsernames) { - - - +@if (noUsers()) { + + +} @else { + @if (failedLookups().length) { + } - - - - - - - + + @if (setUsernames) { + + + + } + + + + + + + +} diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.spec.ts index f5f3d12a21..21156a5f7c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.spec.ts @@ -1,36 +1,39 @@ import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection, importProvidersFrom, NO_ERRORS_SCHEMA } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { importProvidersFrom, NO_ERRORS_SCHEMA } from '@angular/core'; +import { describe, it, expect, afterEach } from 'vitest'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { ActivatedRoute } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; import { TEST_CATALOGUE_ENTITIES, generateStratosEntities, EntityCatalogTestModule, EntityCatalogHelper, EntityCatalogHelpers } from '@stratosui/store'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; -import { AppTestModule } from '@test-framework'; +import { AppTestModule, configureZonelessTestBed } from '@test-framework'; import { CloudFoundryTestingModule, generateCFEntities } from '@test-framework/cf'; -import { CfUserServiceTestProvider } from "@test-framework/user-service-helper"; import { TabNavService } from '@stratosui/core'; -import { ActiveRouteCfOrgSpace } from '../../cf-page.types'; +import { CfUsersRolesDataService } from '../../../../services/domain-data/cf-users-roles-data.service'; +import { StUser } from '../../../../services/endpoint-data/stratos-types'; import { CfRolesService } from './cf-roles.service'; import { UsersRolesComponent } from './manage-users.component'; +const CF = 'cf1'; +const userUrl = (guid: string) => `/pp/v1/cf/users/${CF}/${guid}`; +const stUser = (guid: string, username: string) => ({ guid, username, orgRoles: [], spaceRoles: [] }) as unknown as StUser; + describe('UsersRolesComponent', () => { let component: UsersRolesComponent; let fixture: ComponentFixture; + let http: HttpTestingController; - beforeEach(() => { - TestBed.configureTestingModule({ + async function setup(queryParams: Record = { breadcrumbs: 'key' }) { + await configureZonelessTestBed({ imports: [ UsersRolesComponent, ], schemas: [NO_ERRORS_SCHEMA], providers: [ - provideZonelessChangeDetection(), provideRouter([]), provideHttpClient(), provideHttpClientTesting(), @@ -49,57 +52,148 @@ describe('UsersRolesComponent', () => { ] }, EntityCatalogHelper, - CfUserServiceTestProvider, CfRolesService, TabNavService, { provide: ActivatedRoute, useValue: { snapshot: { - queryParams: { breadcrumbs: 'key' }, - params: {} + queryParams, + params: { endpointId: CF } } } }, - { - provide: ActiveRouteCfOrgSpace, - useValue: { - cfGuid: 'cfGuid', - orgGuid: 'orgGuid', - spaceGuid: 'spaceGuid' - } - }, ], - }) - .compileComponents(); + }).compileComponents(); // Initialize EntityCatalogHelper for Angular 20 compatibility const helper = TestBed.inject(EntityCatalogHelper); EntityCatalogHelpers.SetEntityCatalogHelper(helper); - }); + http = TestBed.inject(HttpTestingController); - beforeEach(() => { fixture = TestBed.createComponent(UsersRolesComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + } + + const text = () => (fixture.nativeElement as HTMLElement).textContent ?? ''; + const q = (sel: string) => (fixture.nativeElement as HTMLElement).querySelector(sel); + + afterEach(() => { + TestBed.resetTestingModule(); }); - it('should create', () => { + it('should create', async () => { + await setup(); expect(component).toBeTruthy(); }); - it('F: breadcrumbs$ emits a single Users breadcrumb pointing at defaultCancelUrl', async () => { - // breadcrumbs$ must emit [{ breadcrumbs: [{ value: 'Users', routerLink: defaultCancelUrl }] }]. - // The harness uses a real route (cfGuid resolves to null from the ActivatedRoute snapshot), - // so we validate structure and coherence rather than a hardcoded URL string. - const breadcrumbs = await firstValueFrom(component.breadcrumbs$); + it('F: breadcrumbs emit a single Users breadcrumb pointing at defaultCancelUrl', async () => { + await setup(); + const breadcrumbs = component.breadcrumbs(); expect(breadcrumbs.length).toBe(1); const crumbs = breadcrumbs[0].breadcrumbs; expect(crumbs.length).toBe(1); expect(crumbs[0].value).toBe('Users'); // The routerLink must match the component's computed defaultCancelUrl. expect(crumbs[0].routerLink).toBe(component.defaultCancelUrl); - // defaultCancelUrl is always a /cloud-foundry path ending in /users. - expect(component.defaultCancelUrl).toMatch(/^\/cloud-foundry\/.+\/users$/); + expect(component.defaultCancelUrl).toBe(`/cloud-foundry/${CF}/users`); + }); + + describe('?users= deep link', () => { + it('loads the good users and names the guid that failed', async () => { + await setup({ users: 'good1,bad1,good2' }); + + http.expectOne(userUrl('good1')).flush(stUser('good1', 'alice')); + http.expectOne(userUrl('bad1')).flush({ errors: [{ detail: 'User not found' }] }, { status: 404, statusText: 'Not Found' }); + http.expectOne(userUrl('good2')).flush(stUser('good2', 'bob')); + await fixture.whenStable(); + + const rolesData = TestBed.inject(CfUsersRolesDataService); + expect(rolesData.users().map(u => u.guid)).toEqual(['good1', 'good2']); + const err = q('[data-testid="manage-users-lookup-error"]'); + expect(err).toBeTruthy(); + expect(err!.textContent).toContain('bad1'); + expect(err!.textContent).toContain('User not found'); + expect(q('app-steppers')).toBeTruthy(); + expect(q('[data-testid="manage-users-no-users"]')).toBeNull(); + }); + + it('shows a message and a way back when no user resolves', async () => { + await setup({ users: 'bad1,bad2' }); + + http.expectOne(userUrl('bad1')).flush({}, { status: 404, statusText: 'Not Found' }); + http.expectOne(userUrl('bad2')).flush({}, { status: 500, statusText: 'Server Error' }); + await fixture.whenStable(); + + const none = q('[data-testid="manage-users-no-users"]'); + expect(none).toBeTruthy(); + expect(text()).toContain('bad1'); + expect(text()).toContain('bad2'); + const back = none!.querySelector('a'); + expect(back?.getAttribute('href')).toBe(`/cloud-foundry/${CF}/users`); + expect(q('app-steppers')).toBeNull(); + }); + + it('shows the same message for an empty list (?users=,), with no request', async () => { + await setup({ users: ',' }); + await fixture.whenStable(); + + http.expectNone(r => r.url.startsWith(`/pp/v1/cf/users/${CF}/`)); + expect(q('[data-testid="manage-users-no-users"]')).toBeTruthy(); + expect(q('app-steppers')).toBeNull(); + }); + + it('shows the same message for an empty param (?users=)', async () => { + await setup({ users: '' }); + await fixture.whenStable(); + + http.expectNone(r => r.url.startsWith(`/pp/v1/cf/users/${CF}/`)); + expect(q('[data-testid="manage-users-no-users"]')).toBeTruthy(); + }); + + it('fetches each user exactly once', async () => { + await setup({ users: 'g1,g2' }); + await fixture.whenStable(); + + const g1 = http.match(userUrl('g1')); + const g2 = http.match(userUrl('g2')); + expect(g1.length).toBe(1); + expect(g2.length).toBe(1); + g1.forEach(r => r.flush(stUser('g1', 'alice'))); + g2.forEach(r => r.flush(stUser('g2', 'bob'))); + await fixture.whenStable(); + + expect(http.match(r => r.url.startsWith(`/pp/v1/cf/users/${CF}/`)).length).toBe(0); + expect(TestBed.inject(CfUsersRolesDataService).users().map(u => u.guid)).toEqual(['g1', 'g2']); + // The header renders through a portal outside the fixture, so read + // the signal it binds. + expect(component.title()).toBe('Manage User Roles'); + }); + }); + + describe('?user= deep link', () => { + it('fetches the user once and titles the page with the username', async () => { + await setup({ user: 'g1' }); + await fixture.whenStable(); + + const reqs = http.match(userUrl('g1')); + expect(reqs.length).toBe(1); + reqs.forEach(r => r.flush(stUser('g1', 'alice'))); + await fixture.whenStable(); + + expect(component.title()).toBe('Manage Roles: alice'); + }); + + it('shows the message when the single user fails', async () => { + await setup({ user: 'bad1' }); + await fixture.whenStable(); + + http.match(userUrl('bad1')).forEach(r => r.flush({}, { status: 404, statusText: 'Not Found' })); + await fixture.whenStable(); + + expect(q('[data-testid="manage-users-no-users"]')).toBeTruthy(); + expect(text()).toContain('bad1'); + }); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts index aaf9610472..a6fad47428 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users.component.ts @@ -11,11 +11,19 @@ import { ViewChild, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { combineLatest as observableCombineLatest, firstValueFrom, Observable, of, Subscription } from 'rxjs'; -import { take, combineLatest, filter, map } from 'rxjs/operators'; +import { firstValueFrom, forkJoin, of, Subscription } from 'rxjs'; +import { catchError, map, take } from 'rxjs/operators'; -import { IHeaderBreadcrumb, PageHeaderComponent, SignalStepHandle, StepComponent, SteppersComponent } from '@stratosui/core'; +import { + DialogErrorComponent, + IHeaderBreadcrumb, + NoContentMessageComponent, + PageHeaderComponent, + SignalStepHandle, + StepComponent, + SteppersComponent, +} from '@stratosui/core'; +import { extractHttpErrorMessage } from '@stratosui/store'; import { CfUsersRolesDataService } from '../../../../services/domain-data/cf-users-roles-data.service'; import { CfUsersPagedDataService } from '../../../../shared/data-services/cf-users-paged-data.service'; import { StUser } from '../../../../services/endpoint-data/stratos-types'; @@ -34,6 +42,8 @@ import { ManageUsersSetUsernamesComponent } from './manage-users-set-usernames/m changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, + DialogErrorComponent, + NoContentMessageComponent, PageHeaderComponent, SteppersComponent, StepComponent, @@ -54,8 +64,26 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { private cdr = inject(ChangeDetectorRef); private rolesData = inject(CfUsersRolesDataService); - initialUsers$!: Observable; - singleUser$!: Observable; + /** Users the wizard was opened for; null while the deep-link lookup runs. */ + readonly initialUsers = signal(null); + /** Deep-link guids that did not resolve, with the reason. */ + readonly failedLookups = signal<{ guid: string; reason: string }[]>([]); + /** Nothing to manage: the deep link named no users, or none resolved. */ + readonly noUsers = computed(() => this.initialUsers()?.length === 0); + readonly lookupErrorMessage = computed(() => { + const failed = this.failedLookups(); + const total = failed.length + (this.initialUsers()?.length ?? 0); + return `Could not load ${failed.length} of ${total} selected users: ` + + failed.map(f => `${f.guid} (${f.reason})`).join(', '); + }); + readonly noUsersLines = computed(() => [ + ...this.failedLookups().map(f => ({ text: `${f.guid}: ${f.reason}` })), + { link: this.defaultCancelUrl, linkText: 'Back to Users', text: '' }, + ]); + readonly title = computed(() => { + const users = this.initialUsers(); + return users?.length === 1 ? `Manage Roles: ${users[0].username}` : `Manage User Roles`; + }); defaultCancelUrl!: string; // FWT-959 Part 2: applyStarted promoted to a signal so the confirm step's // canClose / disablePrevious / destructiveStep / finishButtonText handle @@ -66,9 +94,8 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { get applyStarted(): boolean { return this.applyStartedSignal(); } set applyStarted(v: boolean) { this.applyStartedSignal.set(v); } setUsernames = false; - title$!: Observable; /** Trail back to the Users list (the page the wizard was launched from). */ - breadcrumbs$!: Observable; + readonly breadcrumbs = signal([]); // FWT-959 Part 2: SignalStepHandle wiring for the 3-step manage-users // flow. The first step (`Usernames`) is rendered conditionally via the @@ -91,6 +118,7 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { private modifyValid = signal(false); private modifyBlocked = signal(true); private bridgeSubs: Subscription[] = []; + private lookupSub?: Subscription; setUsernamesStepHandle: SignalStepHandle = { valid: this.setUsersValid.asReadonly(), @@ -141,63 +169,49 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { }; constructor() { - const activeRouteCfOrgSpace = this.activeRouteCfOrgSpace; - const route = this.route; - + const cfGuid = this.activeRouteCfOrgSpace.cfGuid; + const queryParams = this.route.snapshot.queryParams; - this.defaultCancelUrl = this.createReturnUrl(activeRouteCfOrgSpace); - this.breadcrumbs$ = of([{ breadcrumbs: [{ value: 'Users', routerLink: this.defaultCancelUrl }] }]); + this.defaultCancelUrl = this.createReturnUrl(this.activeRouteCfOrgSpace); + this.breadcrumbs.set([{ breadcrumbs: [{ value: 'Users', routerLink: this.defaultCancelUrl }] }]); // Setup the stepper with the users that will have their roles managed - this.setUsernames = route.snapshot.queryParams.setByUsername; + this.setUsernames = queryParams.setByUsername; if (this.setUsernames) { // User has yet to supply users to manage. This will be handled by the first step - this.singleUser$ = of(null); - } else { - // `?users=g1,g2,g3` selects multiple users (the bulk Manage Roles - // path); `?user=g1` is the single-user shorthand. Falls back to the - // already-picked users in the wizard state. - const usersQParam = this.route.snapshot.queryParams.users; - const userQParam = this.route.snapshot.queryParams.user; - if (usersQParam) { - const guids = (usersQParam as string).split(',').map(g => g.trim()).filter(Boolean); - this.initialUsers$ = observableCombineLatest( - guids.map(guid => this.usersData.getUser(activeRouteCfOrgSpace.cfGuid, guid)) - ).pipe( - // getUser emits StUser | undefined; drop any guid that didn't resolve - // so the wizard is never seeded with a hole in the picked-users array. - map(users => users.filter((user): user is StUser => !!user)), - take(1) - ); - } else if (userQParam) { - this.initialUsers$ = this.usersData.getUser(activeRouteCfOrgSpace.cfGuid, userQParam).pipe( - map(user => user ? [user] : []), - take(1) - ); - } else { - this.initialUsers$ = toObservable(this.rolesData.users).pipe(take(1)); - } - - this.singleUser$ = this.initialUsers$.pipe( - take(1), - filter(users => users && users.length > 0), - map(users => users.length === 1 ? users[0] : null), - ); - - // Ensure that when we arrive here directly the store is set up with all it needs - toObservable(this.rolesData.state).pipe( - combineLatest(this.initialUsers$), - take(1) - ).subscribe(([usersRoles, users]) => { - if (!usersRoles?.cfGuid || !users) { - this.rolesData.setUsers(activeRouteCfOrgSpace.cfGuid, users); - } - }); + return; + } + // `?users=g1,g2,g3` selects multiple users (the bulk Manage Roles + // path); `?user=g1` is the single-user shorthand. Falls back to the + // already-picked users in the wizard state. + const guidsParam: string | undefined = queryParams.users ?? queryParams.user; + if (guidsParam === undefined) { + this.seedUsers(cfGuid, this.rolesData.users()); + return; + } + const guids = String(guidsParam).split(',').map(g => g.trim()).filter(Boolean); + if (guids.length === 0) { + this.seedUsers(cfGuid, []); + return; } + // One subscription, so one request per guid. A failed lookup is kept + // with its reason instead of failing the whole page. + this.lookupSub = forkJoin(guids.map(guid => this.usersData.getUser(cfGuid, guid).pipe( + take(1), + map(user => ({ guid, user, reason: 'user not found' })), + catchError(err => of({ guid, user: undefined, reason: extractHttpErrorMessage(err) })), + ))).subscribe(results => { + this.failedLookups.set(results.filter(r => !r.user).map(({ guid, reason }) => ({ guid, reason }))); + this.seedUsers(cfGuid, results.flatMap(r => r.user ? [r.user] : [])); + }); + } - this.title$ = this.singleUser$.pipe( - map(singleUser => singleUser ? `Manage Roles: ${singleUser.username}` : `Manage User Roles`) - ); + private seedUsers(cfGuid: string, users: StUser[]): void { + this.initialUsers.set(users); + // Ensure that when we arrive here directly the store is set up with all it needs + if (users.length > 0 && !this.rolesData.cfGuid()) { + this.rolesData.setUsers(cfGuid, users); + } } ngAfterViewInit(): void { @@ -234,6 +248,7 @@ export class UsersRolesComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.lookupSub?.unsubscribe(); this.bridgeSubs.forEach(s => s.unsubscribe()); this.rolesData.clear(); } From d6d920e26c608ca2f964935382669b6e8d884cb4 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 11:52:39 -0700 Subject: [PATCH 030/121] test(cf): give the user service test provider a backend CfUserServiceTestProvider bound HttpBackend to HttpTestingController, which is not a backend, so every HttpClient request in a spec using it threw "this.backend.handle is not a function". Use provideHttpClientTesting(), which puts the testing backend behind both HttpBackend and HttpTestingController. None of the three specs passed only because requests threw; invite-users-create's afterEach now really flushes its requests. --- .../cf-user-service-test-provider.spec.ts | 37 +++++++++++++++++++ .../test-framework/user-service-helper.ts | 13 +++---- 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/users/cf-user-service-test-provider.spec.ts diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/cf-user-service-test-provider.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/cf-user-service-test-provider.spec.ts new file mode 100644 index 0000000000..803410a073 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/cf-user-service-test-provider.spec.ts @@ -0,0 +1,37 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { CfUserServiceTestProvider } from '@test-framework/cf'; + +// The shared provider bundle must leave requests on the testing backend, so a +// spec that uses it can expect and flush them instead of seeing them throw. +describe('CfUserServiceTestProvider', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [...CfUserServiceTestProvider], + }); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('routes a request to HttpTestingController', () => { + let body: unknown; + let error: unknown; + TestBed.inject(HttpClient).get('/pp/v1/probe').subscribe({ + next: value => { body = value; }, + error: err => { error = err; }, + }); + + expect(error).toBeUndefined(); + httpMock.expectOne('/pp/v1/probe').flush({ ok: true }); + + expect(body).toEqual({ ok: true }); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/test-framework/user-service-helper.ts b/src/frontend/packages/cloud-foundry/test-framework/user-service-helper.ts index a7ea3fd35f..ea7a262ba5 100644 --- a/src/frontend/packages/cloud-foundry/test-framework/user-service-helper.ts +++ b/src/frontend/packages/cloud-foundry/test-framework/user-service-helper.ts @@ -1,14 +1,13 @@ -import { HttpBackend } from '@angular/common/http'; -import { HttpTestingController } from '@angular/common/http/testing'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { cfCurrentUserPermissionsService } from '../src/user-permissions/cf-user-permissions-checkers'; -// Legacy-named bundle: provides the test HttpBackend + CF permissions (the CfUserService it once provided was deleted). +// Legacy-named bundle: provides the testing HttpBackend + CF permissions (the CfUserService it once provided was deleted). +// provideHttpClientTesting() puts HttpClientTestingBackend behind both HttpBackend and HttpTestingController, +// so requests are held for the spec to expect and flush. HttpClient itself comes from the workspace platform +// (or the spec's own provideHttpClient(), which must sit before this bundle). export const CfUserServiceTestProvider = [ - { - provide: HttpBackend, - useClass: HttpTestingController - }, + ...provideHttpClientTesting(), ...cfCurrentUserPermissionsService ]; From 0bce4acb4a2b891b16badb3ac1dd0cae39c1bc6a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:00:56 -0700 Subject: [PATCH 031/121] test(cf): stop the endpoint test provider breaking HTTP generateTestCfEndpointServiceProvider() and the deps behind generateTestCfEndpointService() listed bare HttpClient and HttpHandler class providers. Angular then built the abstract HttpHandler, so every request in the 26 specs using them threw "this.handler.handle is not a function". Drop both so HttpClient comes from the testing setup. All 26 specs pass before and after; none passed only because requests threw, and card-cf-info's afterEach now really flushes its requests. --- ...dry-endpoint-service-test-provider.spec.ts | 38 +++++++++++++++++++ .../cloud-foundry-endpoint-service.helper.ts | 6 --- 2 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-endpoint-service-test-provider.spec.ts diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-endpoint-service-test-provider.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-endpoint-service-test-provider.spec.ts new file mode 100644 index 0000000000..75e7567d8c --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry-endpoint-service-test-provider.spec.ts @@ -0,0 +1,38 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { generateTestCfEndpointServiceProvider } from '@test-framework/cf'; + +// The shared endpoint-service provider bundle must leave HttpClient wired to the +// testing backend, so a spec that uses it can expect and flush its requests +// instead of seeing them throw. +describe('generateTestCfEndpointServiceProvider', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [...generateTestCfEndpointServiceProvider()], + }); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('routes a request to HttpTestingController', () => { + let body: unknown; + let error: unknown; + TestBed.inject(HttpClient).get('/pp/v1/probe').subscribe({ + next: value => { body = value; }, + error: err => { error = err; }, + }); + + expect(error).toBeUndefined(); + httpMock.expectOne('/pp/v1/probe').flush({ ok: true }); + + expect(body).toEqual({ ok: true }); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/test-framework/cloud-foundry-endpoint-service.helper.ts b/src/frontend/packages/cloud-foundry/test-framework/cloud-foundry-endpoint-service.helper.ts index f1764eb522..b7541ecfd8 100644 --- a/src/frontend/packages/cloud-foundry/test-framework/cloud-foundry-endpoint-service.helper.ts +++ b/src/frontend/packages/cloud-foundry/test-framework/cloud-foundry-endpoint-service.helper.ts @@ -1,8 +1,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, - HttpClient, - HttpHandler, withXhr, } from "@angular/common/http"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; @@ -32,8 +30,6 @@ import { CfUserServiceTestProvider } from "./user-service-helper"; export const cfEndpointServiceProviderDeps = [ CfOrgSpaceDataService, UserInviteService, - HttpClient, - HttpHandler, CloudFoundryEndpointService, ]; export class BaseCfOrgSpaceRouteMock { @@ -80,8 +76,6 @@ export function generateTestCfEndpointServiceProvider( CloudFoundryEndpointService, UserInviteService, UserInviteConfigureService, - HttpClient, - HttpHandler, ]; } From d22b55cd6fba65ccb1d6edf3c8f9a2859a3a4981 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:45:02 -0700 Subject: [PATCH 032/121] fix(git): key git lookups by host as well GitDataService cached repositories, branches and commits by scmType--project[--sha] only, so the same owner/repo on two git endpoints (github.com and an Enterprise host, or two GitLab hosts) shared one entry and showed the other host's data. Prefix the key with the endpoint guid, or the API URL when a private lookup has no endpoint. --- .../git/src/shared/git-data.service.spec.ts | 64 ++++++++++++++++++- .../git/src/shared/git-data.service.ts | 19 +++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/frontend/packages/git/src/shared/git-data.service.spec.ts b/src/frontend/packages/git/src/shared/git-data.service.spec.ts index e16e64ee3a..a416f485cc 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.spec.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.spec.ts @@ -1,8 +1,8 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { firstValueFrom, Observable, of, throwError } from 'rxjs'; import { filter, take } from 'rxjs/operators'; @@ -37,6 +37,26 @@ function makeScm(overrides: Partial = {}): GitSCM { } as GitSCM; } +/** + * A GitSCM whose lookups go through the HttpClient the service passes in, so + * HttpTestingController holds each request until the test flushes it. + * `host` stands in for the API base the real SCMs derive from the endpoint + * or the public API URL. + */ +function makeHttpScm(endpointGuid: string, host = 'https://api.github.com'): GitSCM { + return makeScm({ + endpointGuid, + getPublicApi: () => host, + getRepository: (http: HttpClient, p: string) => http.get(`${host}/${endpointGuid}/repos/${p}`), + getBranch: (http: HttpClient, p: string, b: string) => + http.get(`${host}/${endpointGuid}/repos/${p}/branches/${b}`), + getCommit: (http: HttpClient, p: string, sha: string) => + http.get(`${host}/${endpointGuid}/repos/${p}/commits/${sha}`), + }); +} + +const notFound = { status: 404, statusText: 'Not Found' }; + describe('GitDataService', () => { let service: GitDataService; @@ -133,4 +153,42 @@ describe('GitDataService', () => { expect(commit.guid).toBe('github--org/repo--abc'); }); }); + + describe('with requests held by the HTTP testing backend', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + // github.com and a GitHub Enterprise host (or two GitLab hosts) can both + // hold `org/repo`; their entries must not be shared. + it('keeps the same owner/repo on two endpoints apart', async () => { + const a = service.getRepository(makeHttpScm('ep-a'), 'org/repo'); + const b = service.getRepository(makeHttpScm('ep-b'), 'org/repo'); + + expect(b).not.toBe(a); + httpMock.expectOne('https://api.github.com/ep-a/repos/org/repo').flush({ full_name: 'org/repo', html_url: 'a' }); + httpMock.expectOne('https://api.github.com/ep-b/repos/org/repo').flush({ full_name: 'org/repo', html_url: 'b' }); + expect((await firstValueFrom(a.waitForValue$)).html_url).toBe('a'); + expect((await firstValueFrom(b.waitForValue$)).html_url).toBe('b'); + }); + + it('keeps the same owner/repo on two hosts with no endpoint apart', () => { + const ghe = 'https://github.example.com/api/v3'; + service.getBranch(makeHttpScm('', 'https://api.github.com'), 'org/repo', 'main'); + service.getBranch(makeHttpScm('', ghe), 'org/repo', 'main'); + service.getCommit(makeHttpScm('', 'https://api.github.com'), 'org/repo', 'abc'); + service.getCommit(makeHttpScm('', ghe), 'org/repo', 'abc'); + + httpMock.expectOne('https://api.github.com//repos/org/repo/branches/main').flush({ name: 'main' }); + httpMock.expectOne(`${ghe}//repos/org/repo/branches/main`).flush({ name: 'main' }); + httpMock.expectOne('https://api.github.com//repos/org/repo/commits/abc').flush({ sha: 'abc' }); + httpMock.expectOne(`${ghe}//repos/org/repo/commits/abc`).flush({ sha: 'abc' }); + }); + }); }); diff --git a/src/frontend/packages/git/src/shared/git-data.service.ts b/src/frontend/packages/git/src/shared/git-data.service.ts index ee7b05d0a8..4a1b687c60 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.ts @@ -43,6 +43,10 @@ export interface GitResource { * `scmType` / `projectName` / `guid` / `endpointGuid` onto each entity), and * holds the result in per-resource signals cached by guid so repeated reads of * the same resource share a single fetch (mirroring the entity-store dedupe). + * + * The cache is keyed by the host as well as the guid: the guid only names the + * SCM type and project, and two hosts (github.com and a GitHub Enterprise + * server, or two GitLab servers) can each hold the same `owner/repo`. */ @Injectable({ providedIn: 'root' }) export class GitDataService { @@ -97,7 +101,8 @@ export class GitDataService { scm: GitSCM, fetch: () => Observable ): GitResource { - const existing = cache.get(guid); + const key = `${GitDataService.hostKey(scm)}::${guid}`; + const existing = cache.get(key); if (existing) { return existing; } @@ -121,7 +126,7 @@ export class GitDataService { map(s => s.value as T) ), }; - cache.set(guid, resource); + cache.set(key, resource); fetch().subscribe({ next: value => state.set({ value, fetching: false, error: false, errorMessage: '' }), @@ -136,6 +141,16 @@ export class GitDataService { return resource; } + /** + * The host an SCM's requests go to. A registered endpoint is proxied by the + * backend, so its guid names the host; without one the SCM calls its public + * API URL directly (the default host, or a base URL typed into the deploy + * wizard). + */ + private static hostKey(scm: GitSCM): string { + return scm.endpointGuid || scm.getPublicApi(); + } + private stampRepo(repo: GitRepo, scm: GitSCM, projectName: string, guid: string): GitRepo { repo.scmType = scm.getType(); repo.projectName = projectName; From 7b7be415e477a7fb3ed2527538be0c8cac940ab6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:45:02 -0700 Subject: [PATCH 033/121] fix(git): retry a git lookup that failed A failed repository, branch or commit lookup stayed in the cache, so every later read showed the failure until a reload. Drop the entry on error so the next read retries. Two callers would then refetch too often. The git tab looked the repository up a second time for its error snackbar; it now reads it once. The build tab looked the repository and commit up again on every app refresh; it now does so only when the git source changes. The two tab specs now await whenStable(). --- .../build-tab/build-tab.component.spec.ts | 115 +++++++++++---- .../tabs/build-tab/build-tab.component.ts | 16 ++- .../gitscm-tab/gitscm-tab.component.spec.ts | 132 ++++++++++++------ .../tabs/gitscm-tab/gitscm-tab.component.ts | 11 +- .../git/src/shared/git-data.service.spec.ts | 26 ++++ .../git/src/shared/git-data.service.ts | 21 ++- 6 files changed, 242 insertions(+), 79 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts index dacc900749..809bb99e9b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts @@ -1,8 +1,11 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { provideZonelessChangeDetection, signal, WritableSignal } from '@angular/core'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; +import { CurrentUserPermissionsService } from '@stratosui/core'; +import { getGitHubAPIURL, GITHUB_API_URL } from '@stratosui/git'; +import { of } from 'rxjs'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { generateTestApplicationServiceProvider, generateCfStoreModules, ApplicationStateService, ApplicationEnvVarsHelper } from '@test-framework/cf'; @@ -13,7 +16,18 @@ import { AppDetailDataService } from '../../../../app-detail-data.service'; import { BuildTabComponent } from './build-tab.component'; import { ViewBuildpackComponent } from "./view-buildpack/view-buildpack.component"; -function makeDataServiceStub() { +// Signals a test drives; anything left out keeps the default below. +interface DataSignals { + stratosProject?: WritableSignal; + app?: WritableSignal; + space?: WritableSignal; +} + +function makeDataServiceStub({ + stratosProject = signal(null), + app = signal({ entity: { instances: 1 } }), + space = signal(undefined), +}: DataSignals = {}) { return { // Minimal app-detail so the Summary template's `@if (data.appDetail())` // block renders (the scale card + accordion both live inside it). @@ -24,18 +38,18 @@ function makeDataServiceStub() { droplet: null, build: null, }).asReadonly(), - app: signal({ entity: { instances: 1 } }).asReadonly(), + app: app.asReadonly(), summary: signal(undefined).asReadonly(), stats: signal([]).asReadonly(), envVars: signal(undefined).asReadonly(), - space: signal(undefined).asReadonly(), + space: space.asReadonly(), org: signal(undefined).asReadonly(), domains: signal([]).asReadonly(), loading: signal({ app: false, stats: false, envVars: false, space: false, org: false, domains: false }).asReadonly(), errors: signal({ app: null, stats: null, envVars: null, space: null, org: null, domains: null }).asReadonly(), running: signal(false).asReadonly(), url: signal(null).asReadonly(), - stratosProject: signal(null).asReadonly(), + stratosProject: stratosProject.asReadonly(), state: signal({ status: 'UNKNOWN' }).asReadonly(), fetching: signal(false).asReadonly(), lastPolledAt: signal(null).asReadonly(), @@ -60,8 +74,7 @@ describe('BuildTabComponent', () => { const appId = '4e4858c4-24ab-4caf-87a8-7703d1da58a0'; const cfId = testSCFEndpointGuid; - beforeEach(async () => { - await TestBed.configureTestingModule({ + const configure = (signals: DataSignals = {}) => TestBed.configureTestingModule({ imports: [ BuildTabComponent, ViewBuildpackComponent, @@ -80,7 +93,8 @@ describe('BuildTabComponent', () => { provide: AppApplicationActionsService, useValue: { inFlight: signal(false).asReadonly() }, }, - { provide: AppDetailDataService, useFactory: makeDataServiceStub }, + { provide: AppDetailDataService, useFactory: () => makeDataServiceStub(signals) }, + { provide: GITHUB_API_URL, useFactory: getGitHubAPIURL }, { provide: ActivatedRoute, useValue: { @@ -94,28 +108,79 @@ describe('BuildTabComponent', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(BuildTabComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - afterEach(() => { fixture?.destroy(); }); - it('should be created', () => { - expect(component).toBeTruthy(); - }); + describe('without a deployed project', () => { + beforeEach(async () => { + await configure(); + fixture = TestBed.createComponent(BuildTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); - it('renders the instances accordion', () => { - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('app-instances-accordion')).toBeTruthy(); + it('renders the instances accordion', () => { + expect(fixture.nativeElement.querySelector('app-instances-accordion')).toBeTruthy(); + }); + + it('renders the scale card with actions enabled', () => { + const card = fixture.debugElement.query(By.directive(CardAppInstancesComponent)); + expect(card).toBeTruthy(); + expect(card.componentInstance.showActions).toBe(true); + }); }); - it('renders the scale card with actions enabled', () => { - fixture.detectChanges(); - const card = fixture.debugElement.query(By.directive(CardAppInstancesComponent)); - expect(card).toBeTruthy(); - expect(card.componentInstance.showActions).toBe(true); + // App and env var refreshes hand the tab an equal deploy source again. The + // git lookups depend only on that source, so a failed lookup is not + // retried on every refresh. + describe('when the app refreshes after a failed git lookup', () => { + const source = { type: 'gitscm', scm: 'github', project: 'org/missing', branch: 'main', commit: 'abc', endpointGuid: '' }; + const repoUrl = 'https://api.github.com/repos/org/missing'; + const commitUrl = 'https://api.github.com/repos/org/missing/commits/abc'; + const notFound = { status: 404, statusText: 'Not Found' }; + let stratosProject: WritableSignal; + let app: WritableSignal; + + beforeEach(async () => { + stratosProject = signal({ deploySource: { ...source } }); + app = signal({ entity: { instances: 1 } }); + // The deploy source shows once the space allows env var reads. + await configure({ stratosProject, app, space: signal({ guid: 'space-1' }) }); + vi.spyOn(TestBed.inject(CurrentUserPermissionsService), 'can').mockReturnValue(of(true)); + fixture = TestBed.createComponent(BuildTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('looks the repository and the commit up once while the source stays the same', async () => { + const httpMock = TestBed.inject(HttpTestingController); + const subs = [ + component.gitRepo$.subscribe({ error: () => undefined }), + component.deploySource$.subscribe(), + ]; + await fixture.whenStable(); + const repoRequests = httpMock.match(repoUrl); + const commitRequests = httpMock.match(commitUrl); + expect(repoRequests.length).toBe(1); + expect(commitRequests.length).toBe(1); + repoRequests.forEach(r => r.flush('nope', notFound)); + commitRequests.forEach(r => r.flush('nope', notFound)); + await fixture.whenStable(); + + for (let i = 0; i < 3; i++) { + stratosProject.set({ deploySource: { ...source } }); + app.set({ entity: { instances: 1 } }); + await fixture.whenStable(); + } + + expect(httpMock.match(repoUrl).length).toBe(0); + expect(httpMock.match(commitUrl).length).toBe(0); + subs.forEach(sub => sub.unsubscribe()); + }); }); }); 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 fb7990fed9..71743f4ad3 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, Observable, of } from 'rxjs'; -import { combineLatest, distinct, map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest, distinct, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; import { CurrentUserPermissionsService, @@ -30,6 +30,12 @@ import { EnvVarStratosProjectSource } from './application-env-vars.service'; const isDockerHubRegEx = /^([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+):([a-zA-Z0-9_.-]+)/g; +// What a git lookup is made from: the host (SCM type and endpoint), the +// project and the commit. App and env var refreshes hand over equal sources +// again; comparing on this keeps a failed lookup from being retried on each. +const gitSourceKey = (source: EnvVarStratosProjectSource): string => + [source.scm || source.type, source.endpointGuid, source.project, source.commit].join('|'); + interface CustomEnvVarStratosProjectSource extends EnvVarStratosProjectSource { label?: string; icon?: SCMIcon; @@ -139,6 +145,7 @@ export class BuildTabComponent implements OnInit { ); this.gitRepo$ = this.applicationService.applicationStratProject$.pipe( + distinctUntilChanged((a, b) => gitSourceKey(a.deploySource) === gitSourceKey(b.deploySource)), map(project => { const scmType = project.deploySource.scm || project.deploySource.type; const scm = this.scmService.getSCM(scmType as GitSCMType, project.deploySource.endpointGuid); @@ -183,7 +190,12 @@ export class BuildTabComponent implements OnInit { return null; } }), - switchMap((deploySource: DeploySourceView | null) => { + // Each app refresh rebuilds an equal source; only a changed one is shown + // and looked up again. The steps below fill in a copy, so the source + // kept for this comparison stays as built. + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), + switchMap((source: DeploySourceView | null) => { + const deploySource = source && { ...source }; let commit$: Observable | null> = of(null); if (deploySource && deploySource.type === 'gitscm') { // Add gitscm info... add async info in next section diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts index d7c0aa1752..eaa4aac4cf 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts @@ -1,79 +1,129 @@ import { DatePipe } from '@angular/common'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection, NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CommonModule } from '@angular/common'; import { getGitHubAPIURL, GITHUB_API_URL, GitSCMService } from '@stratosui/git'; import { TailwindSnackBarService } from '@stratosui/core'; -import { EMPTY } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; +import { TruncatePipe } from '../../../../../../../../core/src/core/truncate.pipe'; import { AppDetailDataService } from '../../../../../../features/applications/app-detail-data.service'; import { CfDeployAppDataService } from '../../../../../../services/domain-data/cf-deploy-app-data.service'; import { ApplicationService } from '../../../../application.service'; import { GitSCMTabComponent } from './gitscm-tab.component'; +const project = { + deploySource: { + type: 'gitscm', + scm: 'github', + project: 'org/repo', + branch: 'main', + commit: 'abc', + endpointGuid: '', + timestamp: 0, + }, +}; + /** * Minimal AppDetailDataService stub. Used only for `data.stratosProject()` * reads from the template; ngOnInit branches on ApplicationService instead. */ -const makeDataStub = () => ({ - stratosProject: signal(null).asReadonly(), +const makeDataStub = (stratosProject: unknown) => ({ + stratosProject: signal(stratosProject).asReadonly(), }); /** * Minimal ApplicationService stub. EMPTY suppresses the inner subscribe - * body so ngOnInit doesn't reach into the git entity catalog — same intent - * as the AppDetailDataService stub above for the legacy code path. + * body so ngOnInit doesn't reach into the git data service. */ -const makeAppServiceStub = () => ({ - applicationStratProject$: EMPTY, +const makeAppServiceStub = (applicationStratProject$: Observable) => ({ + applicationStratProject$, waitForAppEntity$: EMPTY, appSpace$: EMPTY, }); +function configure(applicationStratProject$: Observable, stratosProject: unknown) { + return TestBed.configureTestingModule({ + imports: [ + GitSCMTabComponent, + ], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + provideNoopAnimations(), + TailwindSnackBarService, + { provide: GITHUB_API_URL, useFactory: getGitHubAPIURL }, + DatePipe, + GitSCMService, + { provide: AppDetailDataService, useFactory: () => makeDataStub(stratosProject) }, + { provide: ApplicationService, useFactory: () => makeAppServiceStub(applicationStratProject$) }, + { provide: CfDeployAppDataService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + // Keep CommonModule (async pipe) and the template's pipe but strip deep + // child components that pull in the full ngrx entity store. + .overrideComponent(GitSCMTabComponent, { + set: { imports: [CommonModule, TruncatePipe] }, + }) + .compileComponents(); +} + describe('GitSCMTabComponent', () => { let component: GitSCMTabComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - GitSCMTabComponent, - ], - providers: [ - provideZonelessChangeDetection(), - provideHttpClient(), - provideHttpClientTesting(), - provideRouter([]), - provideNoopAnimations(), - TailwindSnackBarService, - { provide: GITHUB_API_URL, useFactory: getGitHubAPIURL }, - DatePipe, - GitSCMService, - { provide: AppDetailDataService, useFactory: makeDataStub }, - { provide: ApplicationService, useFactory: makeAppServiceStub }, - { provide: CfDeployAppDataService, useValue: {} }, - ], - schemas: [NO_ERRORS_SCHEMA] - }) - // Keep CommonModule (async pipe) but strip deep child components that pull - // in the full ngrx entity store. Logic under test is injection + ngOnInit. - .overrideComponent(GitSCMTabComponent, { - set: { imports: [CommonModule] }, - }) - .compileComponents(); + describe('without a deployed project', () => { + beforeEach(async () => { + await configure(EMPTY, null); + fixture = TestBed.createComponent(GitSCMTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); - fixture = TestBed.createComponent(GitSCMTabComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + it('should create', () => { + expect(component).toBeTruthy(); + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + // A deployed project's git lookups, held by the HTTP testing backend. + describe('git lookups', () => { + const repoUrl = 'https://api.github.com/repos/org/repo'; + const notFound = { status: 404, statusText: 'Not Found' }; + let httpMock: HttpTestingController; + let error: ReturnType; + + beforeEach(async () => { + await configure(of(project), project); + error = vi.spyOn(TestBed.inject(TailwindSnackBarService), 'error'); + httpMock = TestBed.inject(HttpTestingController); + + fixture = TestBed.createComponent(GitSCMTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + afterEach(() => { + // The commit list loads its own pages once the repo resolves. + httpMock.match(() => true); + }); + + it('reports a failed repository lookup once', async () => { + httpMock.expectOne(repoUrl).flush('nope', notFound); + await fixture.whenStable(); + + // The error report reads the lookup it already holds, not a new one. + httpMock.expectNone(repoUrl); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('Unable to fetch GitHub project: Git request failed(404)'); + }); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts index b577e848c4..fd4ec31026 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts @@ -186,7 +186,11 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { this.deployedCommitSha = stProject.deploySource.commit!.trim(); this.icon$ = of(baseGitMeta.scm.getIcon()); - this.hasRepo$ = this.gitData.getRepository(baseGitMeta.scm, baseGitMeta.projectName).state$.pipe( + // Read once and held: a failed lookup leaves the cache, so a second + // read here would start a new request instead of seeing this one. + const repo = this.gitData.getRepository(baseGitMeta.scm, baseGitMeta.projectName); + + this.hasRepo$ = repo.state$.pipe( map(state => state.value ? true : state.error ? false : undefined), startWith(undefined), publishReplay(1), @@ -205,7 +209,7 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { ); this.gitSCMRepo$ = blockedOnRepo$.pipe( - switchMap(([, meta]) => this.gitData.getRepository(meta.scm, meta.projectName).waitForValue$) + switchMap(() => repo.waitForValue$) ); // Once the repo resolves, capture its URL (for Compare links) and kick @@ -219,8 +223,7 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { this.gitSCMRepoErrorSub = this.hasRepo$.pipe( filter(hasRepo => hasRepo === false), - switchMap(() => coreInfo$), - switchMap(([, meta]) => this.gitData.getRepository(meta.scm, meta.projectName).state$), + switchMap(() => repo.state$), map(state => state.errorMessage), distinctUntilChanged(), withLatestFrom(coreInfo$) diff --git a/src/frontend/packages/git/src/shared/git-data.service.spec.ts b/src/frontend/packages/git/src/shared/git-data.service.spec.ts index a416f485cc..084fca603f 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.spec.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.spec.ts @@ -165,6 +165,32 @@ describe('GitDataService', () => { httpMock.verify(); }); + // A failed lookup must not stay cached: the next read retries instead of + // showing the failure until the page reloads. + it('retries a repository lookup that failed when it is read again', async () => { + const scm = makeHttpScm('ep-1'); + const first = service.getRepository(scm, 'org/repo'); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo').flush('nope', notFound); + await firstValueFrom(first.state$.pipe(filter(s => s.error))); + + const second = service.getRepository(scm, 'org/repo'); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo') + .flush({ full_name: 'org/repo', clone_url: 'url', default_branch: 'main' }); + + expect((await firstValueFrom(second.waitForValue$)).full_name).toBe('org/repo'); + expect(second.error()).toBe(false); + }); + + it('retries a commit lookup that failed when it is read again', async () => { + const scm = makeHttpScm('ep-1'); + const first = service.getCommit(scm, 'org/repo', 'abc'); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo/commits/abc').flush('nope', notFound); + await firstValueFrom(first.state$.pipe(filter(s => s.error))); + + service.getCommit(scm, 'org/repo', 'abc'); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo/commits/abc').flush({ sha: 'abc' }); + }); + // github.com and a GitHub Enterprise host (or two GitLab hosts) can both // hold `org/repo`; their entries must not be shared. it('keeps the same owner/repo on two endpoints apart', async () => { diff --git a/src/frontend/packages/git/src/shared/git-data.service.ts b/src/frontend/packages/git/src/shared/git-data.service.ts index 4a1b687c60..3d49a1c1d0 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.ts @@ -46,7 +46,8 @@ export interface GitResource { * * The cache is keyed by the host as well as the guid: the guid only names the * SCM type and project, and two hosts (github.com and a GitHub Enterprise - * server, or two GitLab servers) can each hold the same `owner/repo`. + * server, or two GitLab servers) can each hold the same `owner/repo`. A failed + * lookup is dropped from the cache so the next read retries it. */ @Injectable({ providedIn: 'root' }) export class GitDataService { @@ -130,12 +131,18 @@ export class GitDataService { fetch().subscribe({ next: value => state.set({ value, fetching: false, error: false, errorMessage: '' }), - error: err => state.set({ - value: null, - fetching: false, - error: true, - errorMessage: scm.parseErrorAsString(err), - }), + error: err => { + // Readers holding this resource keep the error; the next read retries. + if (cache.get(key) === resource) { + cache.delete(key); + } + state.set({ + value: null, + fetching: false, + error: true, + errorMessage: scm.parseErrorAsString(err), + }); + }, }); return resource; From 3eaa3b30a06cdcac763f919499aeae9081e2e829 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:45:02 -0700 Subject: [PATCH 034/121] fix(git): read a branch head afresh on open A cached branch was never refetched, so after a push the git tab compared the deployed commit with a stale head for the rest of the session. Add a refresh read option, used by the git tab each time it opens; a request still in flight is shared. Watch each entry with observeSignal() instead of a root toObservable() that never ended. --- .../gitscm-tab/gitscm-tab.component.spec.ts | 21 +++++++++++++ .../tabs/gitscm-tab/gitscm-tab.component.ts | 4 ++- .../git/src/shared/git-data.service.spec.ts | 30 +++++++++++++++++++ .../git/src/shared/git-data.service.ts | 27 ++++++++++++----- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts index eaa4aac4cf..1f768ecdcc 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts @@ -97,6 +97,8 @@ describe('GitSCMTabComponent', () => { // A deployed project's git lookups, held by the HTTP testing backend. describe('git lookups', () => { const repoUrl = 'https://api.github.com/repos/org/repo'; + const commitUrl = 'https://api.github.com/repos/org/repo/commits/abc'; + const branchUrl = 'https://api.github.com/repos/org/repo/branches/main'; const notFound = { status: 404, statusText: 'Not Found' }; let httpMock: HttpTestingController; let error: ReturnType; @@ -125,5 +127,24 @@ describe('GitSCMTabComponent', () => { expect(error).toHaveBeenCalledTimes(1); expect(error).toHaveBeenCalledWith('Unable to fetch GitHub project: Git request failed(404)'); }); + + // The branch head moves with every push, so opening the tab reads it + // afresh instead of reusing what an earlier visit cached. + it('reads the branch head afresh each time the tab opens', async () => { + httpMock.expectOne(repoUrl).flush({ full_name: 'org/repo', html_url: 'h', owner: {} }); + await fixture.whenStable(); + httpMock.expectOne(commitUrl).flush({ sha: 'abc', commit: { author: { date: '2026-01-01' } } }); + httpMock.expectOne(branchUrl).flush({ name: 'main', commit: { sha: 'abc' } }); + await fixture.whenStable(); + fixture.destroy(); + + fixture = TestBed.createComponent(GitSCMTabComponent); + await fixture.whenStable(); + httpMock.expectNone(repoUrl); + httpMock.expectOne(branchUrl).flush({ name: 'main', commit: { sha: 'def' } }); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('branch has been updated since this app was deployed'); + }); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts index fd4ec31026..88be7624fc 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts @@ -247,10 +247,12 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { // strict: getCommit resolves the full commit detail before emitting. this.deployedTime = getUnixTime(new Date(deployedCommit.commit!.author.date)); }); + // The branch head moves with every push, so it is read afresh each + // time the tab opens rather than taken from an earlier visit's cache. this.isHead$ = blockedOnRepo$.pipe( // strict: git-deploy deploySource always carries branch. switchMap(([project, meta]) => - this.gitData.getBranch(meta.scm, meta.projectName, project.deploySource.branch!).waitForValue$ + this.gitData.getBranch(meta.scm, meta.projectName, project.deploySource.branch!, { refresh: true }).waitForValue$ ), withLatestFrom(blockedOnRepo$), // strict: a resolved branch carries its head commit; deploySource.commit diff --git a/src/frontend/packages/git/src/shared/git-data.service.spec.ts b/src/frontend/packages/git/src/shared/git-data.service.spec.ts index 084fca603f..46722a4a9d 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.spec.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.spec.ts @@ -191,6 +191,36 @@ describe('GitDataService', () => { httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo/commits/abc').flush({ sha: 'abc' }); }); + // A branch's head moves on every push. A plain read shares the cached + // entry; a refresh read (the gitscm tab opening) fetches the head again. + it('refetches a branch on a refresh read and exposes the new head', async () => { + const scm = makeHttpScm('ep-1'); + const url = 'https://api.github.com/ep-1/repos/org/repo/branches/main'; + const first = service.getBranch(scm, 'org/repo', 'main'); + httpMock.expectOne(url).flush({ name: 'main', commit: { sha: 'abc' } }); + expect((await firstValueFrom(first.waitForValue$)).commit!.sha).toBe('abc'); + + service.getBranch(scm, 'org/repo', 'main'); + httpMock.expectNone(url); + + const refreshed = service.getBranch(scm, 'org/repo', 'main', { refresh: true }); + httpMock.expectOne(url).flush({ name: 'main', commit: { sha: 'def' } }); + + expect((await firstValueFrom(refreshed.waitForValue$)).commit!.sha).toBe('def'); + // Later plain reads see the refreshed entry. + expect(service.getBranch(scm, 'org/repo', 'main').value()!.commit!.sha).toBe('def'); + }); + + it('shares a branch request still in flight with a refresh read', () => { + const scm = makeHttpScm('ep-1'); + const url = 'https://api.github.com/ep-1/repos/org/repo/branches/main'; + const first = service.getBranch(scm, 'org/repo', 'main'); + const refreshed = service.getBranch(scm, 'org/repo', 'main', { refresh: true }); + + expect(refreshed).toBe(first); + httpMock.expectOne(url).flush({ name: 'main', commit: { sha: 'abc' } }); + }); + // github.com and a GitHub Enterprise host (or two GitLab hosts) can both // hold `org/repo`; their entries must not be shared. it('keeps the same owner/repo on two endpoints apart', async () => { diff --git a/src/frontend/packages/git/src/shared/git-data.service.ts b/src/frontend/packages/git/src/shared/git-data.service.ts index 3d49a1c1d0..ebbdbbb54f 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, Signal, computed, inject, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { observeSignal } from '@stratosui/store'; import { Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -32,6 +32,14 @@ export interface GitResource { readonly waitForValue$: Observable; } +export interface GitReadOptions { + /** + * Fetch again even when a value is cached. A request still in flight is + * shared instead, since it is already fresh. + */ + refresh?: boolean; +} + /** * Signal-native replacement for the git ngrx entity pipeline (GitEffects + * git actions + the `gitEntityCatalog.*.store` action-orchestrator surface). @@ -60,16 +68,20 @@ export class GitDataService { getRepository(scm: GitSCM, projectName: string): GitResource { const guid = getRepositoryGuid(scm.getType(), projectName); - return this.resource(this.repos, guid, scm, () => + return this.resource(this.repos, guid, scm, {}, () => scm.getRepository(this.http, projectName).pipe( map(repo => this.stampRepo(repo, scm, projectName, guid)) ) ); } - getBranch(scm: GitSCM, projectName: string, branchName: string): GitResource { + /** + * A branch's head commit moves with every push, so a view that compares + * against it passes `{ refresh: true }` when it opens. + */ + getBranch(scm: GitSCM, projectName: string, branchName: string, options: GitReadOptions = {}): GitResource { const guid = getCommitGuid(scm.getType(), projectName, branchName); - return this.resource(this.branches, guid, scm, () => + return this.resource(this.branches, guid, scm, options, () => scm.getBranch(this.http, projectName, branchName).pipe( map(branch => this.stampBranch(branch, scm, projectName)) ) @@ -78,7 +90,7 @@ export class GitDataService { getCommit(scm: GitSCM, projectName: string, commitSha: string): GitResource { const guid = getCommitGuid(scm.getType(), projectName, commitSha); - return this.resource(this.commits, guid, scm, () => + return this.resource(this.commits, guid, scm, {}, () => scm.getCommit(this.http, projectName, commitSha).pipe( map(commit => this.stampCommit(commit, scm, projectName)) ) @@ -100,11 +112,12 @@ export class GitDataService { cache: Map>, guid: string, scm: GitSCM, + options: GitReadOptions, fetch: () => Observable ): GitResource { const key = `${GitDataService.hostKey(scm)}::${guid}`; const existing = cache.get(key); - if (existing) { + if (existing && (!options.refresh || existing.fetching())) { return existing; } @@ -114,7 +127,7 @@ export class GitDataService { error: false, errorMessage: '', }); - const state$ = toObservable(state, { injector: this.injector }); + const state$ = observeSignal(state, this.injector); const resource: GitResource = { value: computed(() => state().value), From 1eebb37ef35a3292140715b47ccb0d79ef2e3c20 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:45:02 -0700 Subject: [PATCH 035/121] fix(git): end git waits when the lookup fails waitForValue$() waited for a value and never errored or completed, so a failed lookup left its callers hanging: - Deploy step 2 reported success without saving the source details. It now reports the lookup failure. It also reports a missing branch (a redeploy whose branch is gone) instead of succeeding. - The git tab's take(1) subscriptions never ended on a failed repo or commit. It now leaves the details out. - The build tab's repository links now show as plain text. waitForValue$() now errors on failure and completes after its value. --- .../build-tab/build-tab.component.spec.ts | 32 ++++++++ .../tabs/build-tab/build-tab.component.ts | 8 +- .../gitscm-tab/gitscm-tab.component.spec.ts | 45 ++++++++++- .../tabs/gitscm-tab/gitscm-tab.component.ts | 13 ++- ...deploy-application-step2.component.spec.ts | 81 +++++++++++++++++-- .../deploy-application-step2.component.ts | 60 +++++++++----- .../git/src/shared/git-data.service.spec.ts | 37 +++++++++ .../git/src/shared/git-data.service.ts | 14 ++-- 8 files changed, 251 insertions(+), 39 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts index 809bb99e9b..63a355c7a4 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts @@ -135,6 +135,38 @@ describe('BuildTabComponent', () => { }); }); + // The repository links bind gitRepo$ with the async pipe, so a failed + // lookup must end the stream rather than throw into the template. + describe('with a git deployed project', () => { + const project = { + deploySource: { type: 'gitscm', scm: 'github', project: 'org/missing', branch: 'main', commit: 'abc', endpointGuid: '' }, + }; + + beforeEach(async () => { + await configure({ stratosProject: signal(project) }); + fixture = TestBed.createComponent(BuildTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('ends gitRepo$ without an error when the repository lookup fails', async () => { + const events: string[] = []; + component.gitRepo$.subscribe({ + next: () => events.push('next'), + error: () => events.push('error'), + complete: () => events.push('complete'), + }); + await fixture.whenStable(); + TestBed.inject(HttpTestingController) + .expectOne('https://api.github.com/repos/org/missing') + .flush('nope', { status: 404, statusText: 'Not Found' }); + await fixture.whenStable(); + + expect(events).not.toContain('error'); + expect(events).not.toContain('next'); + }); + }); + // App and env var refreshes hand the tab an equal deploy source again. The // git lookups depend only on that source, so a failed lookup is not // retried on every refresh. 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 71743f4ad3..356e7bc3c7 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 @@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, ChangeDetectionStrategy, inject } from '@angular/core'; import { RouterModule } from '@angular/router'; import { GitCommit, GitDataService, GitResourceState, GitRepo, GitSCMService, GitSCMType, SCMIcon } from '@stratosui/git'; -import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; -import { combineLatest, distinct, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, EMPTY, Observable, of } from 'rxjs'; +import { catchError, combineLatest, distinct, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; import { CurrentUserPermissionsService, @@ -154,7 +154,9 @@ export class BuildTabComponent implements OnInit { // type because docker/other deploy kinds omit it). return this.gitData.getRepository(scm, project.deploySource.project!); }), - switchMap(repoResource => repoResource.waitForValue$) + // A failed lookup leaves the repository links out instead of throwing + // into the template's async pipes. + switchMap(repoResource => repoResource.waitForValue$.pipe(catchError(() => EMPTY))) ); const deploySource$ = observableCombineLatest( diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts index 1f768ecdcc..7b3affc684 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts @@ -8,9 +8,10 @@ import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CommonModule } from '@angular/common'; -import { getGitHubAPIURL, GITHUB_API_URL, GitSCMService } from '@stratosui/git'; +import { getGitHubAPIURL, GITHUB_API_URL, GitDataService, GitResource, GitSCMService } from '@stratosui/git'; import { TailwindSnackBarService } from '@stratosui/core'; -import { EMPTY, Observable, of } from 'rxjs'; +import { defer, EMPTY, Observable, of } from 'rxjs'; +import { finalize } from 'rxjs/operators'; import { TruncatePipe } from '../../../../../../../../core/src/core/truncate.pipe'; import { AppDetailDataService } from '../../../../../../features/applications/app-detail-data.service'; @@ -103,8 +104,30 @@ describe('GitSCMTabComponent', () => { let httpMock: HttpTestingController; let error: ReturnType; + // Every git lookup subscription the tab opens must end: on the lookup's + // value, on its error, or when the tab is destroyed. Each resource the + // tab reads is wrapped to count the subscriptions still open on it. + let open: Record<'repo' | 'commit' | 'branch', number>; + const counted = (kind: keyof typeof open, source: Observable) => defer(() => { + open[kind]++; + return source.pipe(finalize(() => open[kind]--)); + }); + const track = (kind: keyof typeof open, resource: GitResource): GitResource => ({ + ...resource, + state$: counted(kind, resource.state$), + waitForValue$: counted(kind, resource.waitForValue$), + }); + beforeEach(async () => { await configure(of(project), project); + open = { repo: 0, commit: 0, branch: 0 }; + const gitData = TestBed.inject(GitDataService); + const getRepository = gitData.getRepository.bind(gitData); + const getCommit = gitData.getCommit.bind(gitData); + const getBranch = gitData.getBranch.bind(gitData); + vi.spyOn(gitData, 'getRepository').mockImplementation((...a) => track('repo', getRepository(...a))); + vi.spyOn(gitData, 'getCommit').mockImplementation((...a) => track('commit', getCommit(...a))); + vi.spyOn(gitData, 'getBranch').mockImplementation((...a) => track('branch', getBranch(...a))); error = vi.spyOn(TestBed.inject(TailwindSnackBarService), 'error'); httpMock = TestBed.inject(HttpTestingController); @@ -118,7 +141,20 @@ describe('GitSCMTabComponent', () => { httpMock.match(() => true); }); - it('reports a failed repository lookup once', async () => { + it('ends the commit subscriptions when the commit lookup fails', async () => { + httpMock.expectOne(repoUrl).flush({ full_name: 'org/repo', html_url: 'h', owner: {} }); + await fixture.whenStable(); + httpMock.expectOne(commitUrl).flush('nope', notFound); + httpMock.expectOne(branchUrl).flush({ name: 'main', commit: { sha: 'abc' } }); + await fixture.whenStable(); + + expect(open.commit).toBe(0); + + fixture.destroy(); + expect(open).toEqual({ repo: 0, commit: 0, branch: 0 }); + }); + + it('reports a failed repository lookup once and ends its subscriptions', async () => { httpMock.expectOne(repoUrl).flush('nope', notFound); await fixture.whenStable(); @@ -126,6 +162,9 @@ describe('GitSCMTabComponent', () => { httpMock.expectNone(repoUrl); expect(error).toHaveBeenCalledTimes(1); expect(error).toHaveBeenCalledWith('Unable to fetch GitHub project: Git request failed(404)'); + + fixture.destroy(); + expect(open).toEqual({ repo: 0, commit: 0, branch: 0 }); }); // The branch head moves with every push, so opening the tab reads it diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts index 88be7624fc..d70aaeeb19 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts @@ -15,8 +15,9 @@ import { GithubCommitAuthorComponent, } from '@stratosui/git'; import { getUnixTime } from 'date-fns'; -import { Observable, Subscription, combineLatest, of } from 'rxjs'; +import { EMPTY, Observable, Subscription, combineLatest, of } from 'rxjs'; import { + catchError, distinctUntilChanged, filter, map, @@ -203,7 +204,11 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { startWith(true) ); + // Completes once the repo lookup settles, without emitting when it + // failed, so the one-shot subscriptions below end either way. const blockedOnRepo$: Observable<[EnvVarStratosProject, GitMeta]> = this.hasRepo$.pipe( + filter(hasRepo => hasRepo !== undefined), + take(1), filter(hasRepo => !!hasRepo), switchMap(() => coreInfo$) ); @@ -234,11 +239,14 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { this.snackBarRef = this.snackBar.error(`Unable to fetch ${meta.scm.getLabel()} project: ${errorMessage}`); }); + // A failed commit or branch lookup ends these streams without a value: + // the template leaves the details out, as it did while they hung. this.commit$ = blockedOnRepo$.pipe( // strict: git-deploy deploySource always carries commit/branch. switchMap(([project, meta]) => this.gitData.getCommit(meta.scm, meta.projectName, project.deploySource.commit!.trim()).waitForValue$ - ) + ), + catchError(() => EMPTY) ); // Capture the deployed commit's timestamp — the Compare action is only // offered for commits newer than the deployed one (see the legacy @@ -258,6 +266,7 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { // strict: a resolved branch carries its head commit; deploySource.commit // is present for git-deployed apps. map(([branch, [project]]) => branch.commit!.sha === project.deploySource.commit!.trim()), + catchError(() => EMPTY) ); }); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts index 903ff913bd..39ae80fe23 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts @@ -1,10 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ActivatedRoute } from '@angular/router'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Subject, of } from 'rxjs'; +import { Subject, firstValueFrom, of } from 'rxjs'; import { getGitHubAPIURL, GITHUB_API_URL, GitDataService, GitSCMService, GitHubSCM } from '@stratosui/git'; import { @@ -326,26 +326,95 @@ describe('DeployApplicationStep2Component', () => { component.repositoryBranch = { name: 'main' } as any; }); - it('saves the deploy details in Public mode with no endpoint registered', () => { + it('saves the deploy details in Public mode with no endpoint registered', async () => { const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); component.sourceType = { id: 'github', group: 'gitscm', name: 'GitHub' } as any; component.gitMode = 'public'; - component.onNext(); + const result = await firstValueFrom(component.onNext(0, null as any)); + expect(result.success).toBe(true); expect(save).toHaveBeenCalledWith( expect.objectContaining({ projectName: 'o/r', url: 'https://github.com/o/r.git', endpointGuid: '' }), null, ); }); - it('saves the registered endpoint in Public mode when one is selected', () => { + it('saves the registered endpoint in Public mode when one is selected', async () => { const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); component.sourceType = { id: 'github', group: 'gitscm', name: 'GitHub', endpointGuid: 'ep-1' } as any; component.gitMode = 'public'; - component.onNext(); + await firstValueFrom(component.onNext(0, null as any)); expect(save).toHaveBeenCalledWith(expect.objectContaining({ endpointGuid: 'ep-1' }), null); }); }); + // On a redeploy the form is disabled and counts as valid, so Next is open + // even when the deployed branch is no longer in the repository. onNext is + // then the only check left. + describe('onNext without a branch', () => { + beforeEach(() => { + fixture = TestBed.createComponent(DeployApplicationStep2Component); + component = fixture.componentInstance; + component.scm = TestBed.inject(GitSCMService).getSCM('github', ''); + component.sourceType = { id: 'github', group: 'gitscm', name: 'GitHub' } as any; + component.repository = 'o/r'; + component.repositoryBranch = undefined; + }); + + it('reports failure and saves nothing', async () => { + const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setSourceType(component.sourceType); + deployData.setDeployBranch('gone'); + component.isRedeploy = true; + const results: { success: boolean; message?: string }[] = []; + component.onNext(0, null as any).subscribe(r => results.push(r)); + + TestBed.inject(HttpTestingController).match(() => true).forEach(r => r.flush({ clone_url: 'u' })); + await fixture.whenStable(); + + expect(results).toEqual([{ success: false, message: 'Branch gone was not found in o/r.' }]); + expect(save).not.toHaveBeenCalled(); + }); + + it('asks for a branch outside a redeploy', async () => { + const results: { success: boolean; message?: string }[] = []; + component.onNext(0, null as any).subscribe(r => results.push(r)); + + TestBed.inject(HttpTestingController).match(() => true).forEach(r => r.flush({ clone_url: 'u' })); + await fixture.whenStable(); + + expect(results).toEqual([{ success: false, message: 'Select a branch to deploy from o/r.' }]); + }); + }); + + // The repository lookup runs through the real data service; the testing + // backend holds the request until it is flushed with an error. + describe('onNext when the repository lookup fails', () => { + beforeEach(() => { + fixture = TestBed.createComponent(DeployApplicationStep2Component); + component = fixture.componentInstance; + component.scm = TestBed.inject(GitSCMService).getSCM('github', ''); + component.sourceType = { id: 'github', group: 'gitscm', name: 'GitHub' } as any; + component.gitMode = 'public'; + component.repository = 'o/missing'; + component.repositoryBranch = { name: 'main' } as any; + }); + + it('reports failure with the message and saves nothing', async () => { + const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); + const results: { success: boolean; message?: string }[] = []; + component.onNext(0, null as any).subscribe(r => results.push(r)); + + TestBed.inject(HttpTestingController) + .expectOne('https://api.github.com/repos/o/missing') + .flush('nope', { status: 404, statusText: 'Not Found' }); + await fixture.whenStable(); + + expect(results).toEqual([{ success: false, message: expect.stringContaining('Git request failed(404)') }]); + expect(save).not.toHaveBeenCalled(); + }); + }); + describe('applyGithubEnterpriseAndToken — form wiring', () => { let scmSpy: { setPublicApi: ReturnType; 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 7ed3b2711a..9d04c50ba9 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 @@ -25,7 +25,6 @@ import { } from 'rxjs'; import { catchError, - defaultIfEmpty, distinctUntilChanged, filter, map, @@ -41,7 +40,10 @@ import { import { CfDeployAppDataService } from '../../../../services/domain-data/cf-deploy-app-data.service'; import { TruncatePipe } from '../../../../../../core/src/core/truncate.pipe'; -import { StepOnNextFunction } from '../../../../../../core/src/shared/components/stepper/step/step.component'; +import { + StepOnNextFunction, + StepOnNextResult, +} from '../../../../../../core/src/shared/components/stepper/step/step.component'; import { DeployApplicationState, ProjectExists, SourceType } from '../../../../store/types/deploy-application.types'; import { ApplicationDeploySourceTypes, DEPLOY_TYPES_IDS } from '../deploy-application-steps.types'; import { GitSuggestedRepo } from './../../../../../../git/src/store/git.public-types'; @@ -209,20 +211,37 @@ export class DeployApplicationStep2Component if (this.sourceType.group === 'gitscm') { const branch = this.repositoryBranch; const endpointGuid = this.activeEndpointGuid; - this.gitData.getRepository(this.scm, this.repository) - .waitForValue$.pipe(take(1), defaultIfEmpty(null)).subscribe(repo => { - // A gitscm save needs a resolved repo and branch. The endpoint is - // optional: without one the backend clones the repo URL directly. - if (!repo || !branch) { return; } - this.deployData.saveAppDetails({ - projectName: this.repository, - branch, - url: repo.clone_url, - accessToken: this.accessToken, - commit: this.isRedeploy ? this.commitInfo?.sha : undefined, - endpointGuid, - }, null); - }); + const data = this.sourceSelectionForm.form.value.fsLocalSource; + // A gitscm save needs a resolved repo and branch. The endpoint is + // optional: without one the backend clones the repo URL directly. + // A redeploy's form is disabled and counts as valid, so a deployed + // branch that is gone from the repository is only caught here. + if (!branch) { + const branchName = this.deployData.deployBranchName(); + return observableOf({ + success: false, + message: this.isRedeploy && branchName ? + `Branch ${branchName} was not found in ${this.repository}.` : + `Select a branch to deploy from ${this.repository}.`, + }); + } + return this.gitData.getRepository(this.scm, this.repository).waitForValue$.pipe( + map((repo): StepOnNextResult => { + this.deployData.saveAppDetails({ + projectName: this.repository, + branch, + url: repo.clone_url, + accessToken: this.accessToken, + commit: this.isRedeploy ? this.commitInfo?.sha : undefined, + endpointGuid, + }, null); + return { success: true, data }; + }), + catchError((err: Error) => observableOf({ + success: false, + message: `Unable to fetch ${this.scm.getLabel()} repository ${this.repository}: ${err.message}`, + })) + ); } else if (this.sourceType.id === DEPLOY_TYPES_IDS.GIT_URL) { this.deployData.saveAppDetails({ projectName: this.gitUrl, @@ -371,11 +390,12 @@ export class DeployApplicationStep2Component this.commitSubscription.unsubscribe(); } if (!commitSha) { return; } + // A failed lookup leaves commitInfo unset, so onNext saves no commit. this.commitSubscription = this.gitData.getCommit(this.scm, projectInfo.full_name, commitSha) - .waitForValue$.pipe( - take(1), - tap(p => this.commitInfo = p), - ).subscribe(); + .waitForValue$.subscribe({ + next: p => this.commitInfo = p, + error: () => this.commitInfo = undefined, + }); } } }) diff --git a/src/frontend/packages/git/src/shared/git-data.service.spec.ts b/src/frontend/packages/git/src/shared/git-data.service.spec.ts index 46722a4a9d..b89815d23d 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.spec.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.spec.ts @@ -221,6 +221,43 @@ describe('GitDataService', () => { httpMock.expectOne(url).flush({ name: 'main', commit: { sha: 'abc' } }); }); + // waitForValue$ is what the deploy wizard and the gitscm tab wait on. It + // must end: error when the lookup fails, complete after the value. + it('waitForValue$ errors with the message when the lookup fails', async () => { + const scm = makeHttpScm('ep-1'); + const res = service.getCommit(scm, 'org/repo', 'abc'); + const outcome = firstValueFrom(res.waitForValue$).then( + () => 'value', + (err: Error) => `error: ${err.message}`, + ); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo/commits/abc').flush('nope', notFound); + + const result = await Promise.race([ + outcome, + new Promise(resolve => setTimeout(() => resolve('still waiting'), 50)), + ]); + expect(result).toMatch(/^error: /); + }); + + it('waitForValue$ completes after the value', async () => { + const scm = makeHttpScm('ep-1'); + const res = service.getRepository(scm, 'org/repo'); + const events: string[] = []; + const done = new Promise(resolve => { + res.waitForValue$.subscribe({ + next: () => events.push('next'), + complete: () => { + events.push('complete'); + resolve(); + }, + }); + }); + httpMock.expectOne('https://api.github.com/ep-1/repos/org/repo').flush({ full_name: 'org/repo' }); + + await Promise.race([done, new Promise(resolve => setTimeout(resolve, 50))]); + expect(events).toEqual(['next', 'complete']); + }); + // github.com and a GitHub Enterprise host (or two GitLab hosts) can both // hold `org/repo`; their entries must not be shared. it('keeps the same owner/repo on two endpoints apart', async () => { diff --git a/src/frontend/packages/git/src/shared/git-data.service.ts b/src/frontend/packages/git/src/shared/git-data.service.ts index ebbdbbb54f..69f246c8cc 100644 --- a/src/frontend/packages/git/src/shared/git-data.service.ts +++ b/src/frontend/packages/git/src/shared/git-data.service.ts @@ -1,8 +1,8 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, Signal, computed, inject, signal } from '@angular/core'; import { observeSignal } from '@stratosui/store'; -import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { Observable, of, throwError } from 'rxjs'; +import { filter, map, mergeMap, take } from 'rxjs/operators'; import { getCommitGuid, getRepositoryGuid } from '../store/git-entity-factory'; import { GitBranch, GitCommit, GitRepo } from '../store/git.public-types'; @@ -28,7 +28,10 @@ export interface GitResource { readonly errorMessage: Signal; /** Continuous state stream — equivalent to the old `entityObs$`. */ readonly state$: Observable>; - /** Emits once the value is available — equivalent to the old `waitForEntity$`. */ + /** + * Emits the value once it is available, then completes. Errors with the + * lookup's message when the lookup fails, so callers never wait forever. + */ readonly waitForValue$: Observable; } @@ -136,8 +139,9 @@ export class GitDataService { errorMessage: computed(() => state().errorMessage), state$, waitForValue$: state$.pipe( - filter(s => s.value !== null), - map(s => s.value as T) + filter(s => s.value !== null || s.error), + take(1), + mergeMap(s => s.error ? throwError(() => new Error(s.errorMessage)) : of(s.value as T)) ), }; cache.set(key, resource); From 44ecac4a740d244f5d6a8aba82db009388d1b68d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:45:02 -0700 Subject: [PATCH 036/121] test(cf): request the commit list once per open Guard the git tab against loading its commit list more than once per open. --- .../tabs/gitscm-tab/gitscm-tab.component.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts index 7b3affc684..6e58580a70 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.spec.ts @@ -167,6 +167,16 @@ describe('GitSCMTabComponent', () => { expect(open).toEqual({ repo: 0, commit: 0, branch: 0 }); }); + it('requests the commit list once per tab open', async () => { + httpMock.expectOne(repoUrl).flush({ full_name: 'org/repo', html_url: 'h', owner: {} }); + await fixture.whenStable(); + httpMock.expectOne(commitUrl).flush({ sha: 'abc', commit: { author: { date: '2026-01-01' } } }); + httpMock.expectOne(branchUrl).flush({ name: 'main', commit: { sha: 'abc' } }); + await fixture.whenStable(); + + expect(httpMock.match(r => r.url.endsWith('/repos/org/repo/commits?sha=main')).length).toBe(1); + }); + // The branch head moves with every push, so opening the tab reads it // afresh instead of reusing what an earlier visit cached. it('reads the branch head afresh each time the tab opens', async () => { From 75d50fee6d39c033f915aff87f6454be75c049b6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:59:15 -0700 Subject: [PATCH 037/121] fix(core): end the routing bar on a failed navigation The routing indicator ended only on NavigationEnd or NavigationCancel. A NavigationError, such as a lazy chunk that 404s after a redeploy, left the bar at 95 % for good, and because it still counted as started it ignored every later NavigationStart. Treat NavigationError and NavigationSkipped as ends too. --- .../routing-indicator.component.spec.ts | 118 ++++++++++++++---- .../routing-indicator.component.ts | 10 +- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.spec.ts b/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.spec.ts index f5d024508c..0f6ed65e59 100644 --- a/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.spec.ts @@ -1,42 +1,110 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { By } from '@angular/platform-browser'; +import { + Event, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + Router, +} from '@angular/router'; +import { Subject } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configureZonelessTestBed } from '@test-framework/zoneless-testbed.helper'; +import { AppProgressBarComponent } from '../progress-bar/app-progress-bar.component'; import { RoutingIndicatorComponent } from './routing-indicator.component'; -import { CoreModule } from '../../../core/core.module'; -import { ProgressBarComponent } from '../progress-bar/progress-bar.component'; -import { RouterTestingModule } from '@angular/router/testing'; describe('RoutingIndicatorComponent', () => { let component: RoutingIndicatorComponent; let fixture: ComponentFixture; + let events: Subject; + let navId = 0; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideHttpClient(), - provideHttpClientTesting(), - ], - imports: [ - RoutingIndicatorComponent, - RouterTestingModule, - CoreModule, - ProgressBarComponent, - ] - }); - TestBed.compileComponents(); - }); + const start = () => events.next(new NavigationStart(++navId, '/next')); + const end = () => events.next(new NavigationEnd(navId, '/next', '/next')); + // A lazy route whose chunk 404s (e.g. after a redeploy) ends in this event. + const error = () => events.next(new NavigationError(navId, '/next', new Error('chunk load failed'))); + const skipped = () => events.next( + new NavigationSkipped(navId, '/next', 'same url', NavigationSkippedCode.IgnoredSameUrlNavigation) + ); + + const advance = async (ms: number) => { + await vi.advanceTimersByTimeAsync(ms); + await fixture.whenStable(); + }; + const bar = () => fixture.debugElement.query(By.directive(AppProgressBarComponent)); - beforeEach(() => { + beforeEach(async () => { + // RxJS delay/interval run on the faked timers; requestAnimationFrame stays + // real so the zoneless scheduler (and whenStable) still settles. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); + // Largest steps, so the bar reaches its 95 % ceiling in a few seconds. + vi.spyOn(Math, 'random').mockReturnValue(0.99); + events = new Subject(); + await configureZonelessTestBed({ + imports: [RoutingIndicatorComponent], + providers: [{ provide: Router, useValue: { events } }], + }).compileComponents(); fixture = TestBed.createComponent(RoutingIndicatorComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('shows the bar during a navigation and hides it on NavigationEnd', async () => { + start(); + await advance(1000); + expect(bar()).toBeTruthy(); + + end(); + await advance(300); + expect(bar()).toBeNull(); + }); + + it('hides the bar when the navigation ends in NavigationError', async () => { + start(); + await advance(10000); + expect(bar().componentInstance.value).toBe(95); + + error(); + await advance(300); + expect(bar()).toBeNull(); + }); + + it('runs the next navigation normally after a NavigationError', async () => { + start(); + await advance(10000); + error(); + await advance(300); + + start(); + await advance(1000); + // A fresh run starts low again instead of sitting at the 95 % ceiling. + expect(bar()).toBeTruthy(); + expect(bar().componentInstance.value).toBeLessThan(95); + + end(); + await advance(300); + expect(bar()).toBeNull(); + }); + + it('hides the bar when the navigation ends in NavigationSkipped', async () => { + start(); + await advance(1000); + expect(bar()).toBeTruthy(); + + skipped(); + await advance(300); + expect(bar()).toBeNull(); + }); }); diff --git a/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.ts b/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.ts index 684752e97c..bbd2f35f77 100644 --- a/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.ts +++ b/src/frontend/packages/core/src/shared/components/routing-indicator/routing-indicator.component.ts @@ -1,6 +1,6 @@ import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { NavigationCancel, NavigationEnd, NavigationError, NavigationSkipped, NavigationStart, Router } from '@angular/router'; import { interval, Observable, of as observableOf } from 'rxjs'; import { filter, map, startWith, switchMap, delay, tap } from 'rxjs/operators'; @@ -26,10 +26,16 @@ export class RoutingIndicatorComponent { constructor() { this.value$ = this.router.events.pipe( + // Every way a navigation can finish must end the bar. A missed one + // (e.g. NavigationError from a lazy chunk that 404s after a redeploy) + // left it at 95 % with `started` set, so the next NavigationStart was + // filtered out too. filter(event => { return (event instanceof NavigationStart && !this.started) || event instanceof NavigationCancel || - event instanceof NavigationEnd; + event instanceof NavigationEnd || + event instanceof NavigationError || + event instanceof NavigationSkipped; }), switchMap(event => { if (event instanceof NavigationStart) { From a4fff6dd4c09ba993f00e9b950fb244704f3eff6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 12:59:15 -0700 Subject: [PATCH 038/121] fix(core): drop the stepper's 5 ms first-step poll The stepper activated step 0 before its steps were filtered, so it fell back to a 5 ms setInterval waiting for the first step to unblock. On create app, deploy and add service instance the first step stays blocked while no CF is connected, so the poll ran until the page closed. It also entered allSteps[0] even when that step was hidden, and the first visible step never got its onEnter. Enter the first visible step once from an effect on the filtered steps and their blocked state, then stop. The legacy [blocked] input is now backed by a signal so the effect sees it clear. --- .../components/stepper/step/step.component.ts | 10 +- .../steppers/steppers.component.spec.ts | 141 +++++++++++++++++- .../stepper/steppers/steppers.component.ts | 78 +++++----- 3 files changed, 184 insertions(+), 45 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts b/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts index 287a6ecbd6..d471dbaab5 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/step/step.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, Signal, TemplateRef, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, Signal, TemplateRef, ViewChild, signal } from '@angular/core'; import { from, Observable, of as observableOf } from 'rxjs'; import { StepperRedirectPayload } from '../stepper.types'; @@ -217,11 +217,13 @@ export class StepComponent { } @Output() disablePreviousChange = new EventEmitter(); - private _blocked = false; + // A signal, like signalHandle.blocked, so the stepper's first-step entry + // effect re-runs when a legacy [blocked] binding clears. + private _blocked = signal(false); @Input() - set blocked(v: boolean) { this._blocked = v; } + set blocked(v: boolean) { this._blocked.set(v); } get blocked(): boolean { - return this.signalHandle?.blocked ? this.signalHandle.blocked() : this._blocked; + return this.signalHandle?.blocked ? this.signalHandle.blocked() : this._blocked(); } private _destructiveStep = false; diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts index f3afe53f35..b316f6c727 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts @@ -42,10 +42,10 @@ describe('SteppersComponent', () => { TestBed.compileComponents(); }); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(SteppersComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should be created', () => { @@ -177,7 +177,6 @@ describe('SteppersComponent — projected step content', () => { }); await TestBed.compileComponents(); hostFixture = TestBed.createComponent(ProjectionHostComponent); - hostFixture.detectChanges(); await hostFixture.whenStable(); }); @@ -210,3 +209,139 @@ describe('SteppersComponent — projected step content', () => { expect(hostFixture.componentInstance.entered).toEqual(['two']); }); }); + +// The first step's onEnter used to be delivered by a 5 ms setInterval that +// waited for step 0 to unblock. With no Cloud Foundry connected, the CF step +// of create-app / deploy / add-service-instance stays blocked, so the +// interval ran for the life of the page. The poll also targeted allSteps[0] +// even when that step was hidden, so the step actually shown never got its +// onEnter. +@Component({ + standalone: true, + imports: [SteppersComponent, StepComponent], + template: ` + + + + + `, +}) +class FirstStepHostComponent { + entered: string[] = []; + firstHidden = signal(false); + secondHidden = signal(false); + firstBlocked = signal(false); + firstLegacyBlocked = signal(false); + firstHandle: SignalStepHandle = { + valid: signal(true), + blocked: this.firstBlocked, + onEnter: () => { this.entered.push('one'); }, + }; + secondHandle: SignalStepHandle = { + valid: signal(true), + onEnter: () => { this.entered.push('two'); }, + }; +} + +@Component({ + standalone: true, + imports: [SteppersComponent], + template: ``, +}) +class EmptyStepperHostComponent { } + +describe('SteppersComponent — first step entry', () => { + let hostFixture: ComponentFixture; + let host: FirstStepHostComponent; + + // Long enough for any first-step poll to have ticked several times. + const settle = async () => { + await vi.advanceTimersByTimeAsync(50); + await hostFixture.whenStable(); + }; + + beforeEach(async () => { + // Only intervals are faked: setTimeout and requestAnimationFrame stay + // real so the zoneless scheduler and whenStable still settle. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + TestBed.configureTestingModule({ + imports: [FirstStepHostComponent, EmptyStepperHostComponent, RouterTestingModule], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + await TestBed.compileComponents(); + hostFixture = TestBed.createComponent(FirstStepHostComponent); + host = hostFixture.componentInstance; + }); + + afterEach(() => vi.useRealTimers()); + + it('fires the first step onEnter exactly once on create', async () => { + await settle(); + await settle(); + expect(host.entered).toEqual(['one']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('leaves no interval running while the first step stays blocked', async () => { + host.firstBlocked.set(true); + await settle(); + expect(host.entered).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('fires onEnter once when a blocked first step unblocks', async () => { + host.firstBlocked.set(true); + await settle(); + expect(host.entered).toEqual([]); + + host.firstBlocked.set(false); + await settle(); + await settle(); + expect(host.entered).toEqual(['one']); + }); + + it('fires onEnter once when a legacy [blocked] first step unblocks', async () => { + host.firstHandle = { valid: signal(true), onEnter: () => { host.entered.push('one'); } }; + host.firstLegacyBlocked.set(true); + await settle(); + expect(host.entered).toEqual([]); + + host.firstLegacyBlocked.set(false); + await settle(); + await settle(); + expect(host.entered).toEqual(['one']); + }); + + it('enters the first visible step when the first step is hidden', async () => { + host.firstHidden.set(true); + await settle(); + expect(host.entered).toEqual(['two']); + expect(vi.getTimerCount()).toBe(0); + }); + + it('fires onEnter once when the first visible step appears later', async () => { + host.firstHidden.set(true); + host.secondHidden.set(true); + await settle(); + expect(host.entered).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + + // e.g. the step list resolves after an async load + host.firstHidden.set(false); + host.secondHidden.set(false); + await settle(); + await settle(); + expect(host.entered).toEqual(['one']); + }); + + it('leaves no interval running for a stepper with no steps', async () => { + const emptyFixture = TestBed.createComponent(EmptyStepperHostComponent); + await vi.advanceTimersByTimeAsync(50); + await emptyFixture.whenStable(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts index 73230d9cbf..fe4ab5c35f 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, AfterContentInit, Component, ContentChildren, Injector, Input, OnDestroy, OnInit, QueryList, TemplateRef, ViewEncapsulation, afterNextRender, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, AfterContentInit, Component, ContentChildren, Injector, Input, OnDestroy, OnInit, QueryList, TemplateRef, ViewEncapsulation, afterNextRender, effect, inject, signal, untracked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RoutingHistoryService } from '@stratosui/store'; @@ -72,11 +72,40 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { private enterData: any; private snackBarRef!: TailwindSnackBarRef; - // First-step unblock poller (see setActive). Held as a field so destroy can - // clear it — a local timer kept ticking after teardown and then called - // afterNextRender against a destroyed injector (NG0205). - private firstStepTimer?: ReturnType; - private isDestroyed = false; + + // Mirrors `steps` so firstStepEntry re-runs whenever the steps re-filter. + private readonly visibleSteps = signal([]); + + // Delivers the first visible step's onEnter once that step exists and is + // unblocked. setActive(0) cannot do it: no step is visible until + // filterSteps runs, and a blocked step (e.g. the CF picker while no CF is + // connected) can unblock at any later time. `blocked` reads the step's + // signals, so this re-runs only when it can change, and it destroys itself + // after the one delivery. It replaces a 5 ms setInterval that kept polling + // for as long as the first step stayed blocked. + private readonly firstStepEntry = effect(() => { + const step = this.visibleSteps()[0]; + if (!step || step.blocked) { + return; + } + untracked(() => { + this.firstStepEntry.destroy(); + // A transition through setActive has already delivered its own onEnter. + if (this.currentIndex() !== 0) { + return; + } + step.active = true; + // Route through pOnEnter so signal-handle consumers receive the enter + // callback, after the render that shows the step (see setActive). + // markForCheck guarantees the render that afterNextRender waits on. + this.cdr.markForCheck(); + afterNextRender(() => { + if (step.active) { + step.pOnEnter(this.enterData); + } + }, { injector: this.injector }); + }); + }); // Signal (read directly in the template as currentIndex()) so the action // buttons' [disabled]/text bindings re-render under zoneless CD when the @@ -110,8 +139,6 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { ngOnInit() { } ngOnDestroy() { - this.isDestroyed = true; - clearInterval(this.firstStepTimer); this.hiddenSubs.forEach(sub => sub.unsubscribe()); this.unsubscribeNext(); if (this.snackBarRef) { @@ -121,7 +148,6 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { ngAfterContentInit() { this.allSteps = this.stepComponents.toArray(); - this.setActive(0); this.allSteps.forEach((step => { this.hiddenSubs.push(step.hiddenChange.subscribe((_hidden) => { @@ -155,6 +181,7 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { private filterSteps() { this.steps = this.allSteps.filter((step => !step.hidden)); + this.visibleSteps.set(this.steps); // Under zoneless CD, reassigning this.steps doesn't mark the view // dirty. Without markForCheck the template keeps rendering the step // list as it was at ngAfterContentInit, so steps whose [hidden] @@ -286,35 +313,8 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { if (this.basePreviousRedirect && index < 0) { this.navigate(this.basePreviousRedirect); } + // The first step's initial onEnter is delivered by firstStepEntry. if (!this.canGoto(index)) { - if (index === 0) { - if (this.allSteps && this.allSteps.length > 0) { - // Execute `onEnter` for the first step as soon as step is unblocked. - // Route through pOnEnter so signal-handle consumers (FWT-959 Shape 3 - // wizards) receive the enter callback — the legacy onEnter @Input - // defaults to a no-op so a raw step.onEnter call swallows - // signalHandle.onEnter. - clearInterval(this.firstStepTimer); - this.firstStepTimer = setInterval(() => { - if (this.isDestroyed) { - clearInterval(this.firstStepTimer); - return; - } - if (this.allSteps[index].blocked === false) { - const step = this.allSteps[index]; - step.active = true; - // Same deferred delivery as setActive's main path below: the - // first step's content may not have rendered yet when this - // interval fires, so a synchronous pOnEnter races the child - // instantiation. markForCheck guarantees the render that - // afterNextRender waits on. - this.cdr.markForCheck(); - afterNextRender(() => step.pOnEnter(this.enterData), { injector: this.injector }); - clearInterval(this.firstStepTimer); - } - }, 5); - } - } return; } @@ -335,7 +335,9 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { }); this.currentIndex.set(index); // Route through pOnEnter so signal-handle consumers (FWT-959 Shape 3 - // wizards) receive the enter callback — see comment above for rationale. + // wizards) receive the enter callback — the legacy onEnter @Input + // defaults to a no-op, so a raw step.onEnter call swallows + // signalHandle.onEnter. // // Delivery is deferred to after the NEXT render. Note: projected step // content is NOT lazily instantiated — Ivy constructs inside From 1417a14ddb89351db8395b433dbf06db85087b6a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 13:10:08 -0700 Subject: [PATCH 039/121] fix(core): refilter steps when a step handle hides The stepper refiltered its visible steps only on the legacy [hidden] input's hiddenChange event. Hiding or showing a step through its signal handle left the step list stale, and the first-step entry never saw the change. Watch the handles' hidden state and refilter when the visible list changes. --- .../steppers/steppers.component.spec.ts | 81 +++++++++++++++++++ .../stepper/steppers/steppers.component.ts | 15 ++++ 2 files changed, 96 insertions(+) diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts index b316f6c727..4a35370140 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts @@ -345,3 +345,84 @@ describe('SteppersComponent — first step entry', () => { expect(vi.getTimerCount()).toBe(0); }); }); + +// A step can be hidden through its signal handle instead of the legacy +// [hidden] input. Only the input emits hiddenChange, so the stepper must +// also follow the handle's hidden signal or its step list goes stale. +@Component({ + standalone: true, + imports: [SteppersComponent, StepComponent], + template: ` + + + + + `, +}) +class SignalHiddenHostComponent { + entered: string[] = []; + firstHidden = signal(false); + secondHidden = signal(false); + firstHandle: SignalStepHandle = { + valid: signal(true), + hidden: this.firstHidden, + onEnter: () => { this.entered.push('one'); }, + }; + secondHandle: SignalStepHandle = { + valid: signal(true), + hidden: this.secondHidden, + onEnter: () => { this.entered.push('two'); }, + }; +} + +describe('SteppersComponent — signal-handle hidden', () => { + let hostFixture: ComponentFixture; + let host: SignalHiddenHostComponent; + const steppers = () => hostFixture.debugElement + .query(By.directive(SteppersComponent)).componentInstance as SteppersComponent; + const titles = () => steppers().steps.map(step => step.title); + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [SignalHiddenHostComponent, RouterTestingModule], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + await TestBed.compileComponents(); + hostFixture = TestBed.createComponent(SignalHiddenHostComponent); + host = hostFixture.componentInstance; + }); + + it('re-filters the step list when a handle hidden signal flips', async () => { + host.firstHidden.set(true); + await hostFixture.whenStable(); + expect(titles()).toEqual(['Two']); + + host.firstHidden.set(false); + await hostFixture.whenStable(); + expect(titles()).toEqual(['One', 'Two']); + expect(hostFixture.nativeElement.textContent).toContain('One'); + + host.secondHidden.set(true); + await hostFixture.whenStable(); + expect(titles()).toEqual(['One']); + }); + + it('enters the first step once it is shown through its handle', async () => { + host.firstHidden.set(true); + host.secondHidden.set(true); + await hostFixture.whenStable(); + expect(titles()).toEqual([]); + expect(host.entered).toEqual([]); + + host.firstHidden.set(false); + host.secondHidden.set(false); + await hostFixture.whenStable(); + await hostFixture.whenStable(); + expect(titles()).toEqual(['One', 'Two']); + expect(host.entered).toEqual(['one']); + }); +}); diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts index fe4ab5c35f..40a5e62ff8 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts @@ -75,6 +75,20 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { // Mirrors `steps` so firstStepEntry re-runs whenever the steps re-filter. private readonly visibleSteps = signal([]); + // The projected steps, set once in ngAfterContentInit. + private readonly contentSteps = signal([]); + + // A legacy [hidden] change re-filters through hiddenChange, but a + // signalHandle.hidden change emits nothing. Reading `hidden` here tracks + // each handle's signal, so the step list re-filters when one flips. + private readonly signalHiddenSync = effect(() => { + const visible = this.contentSteps().filter(step => !step.hidden); + untracked(() => { + if (visible.length !== this.steps.length || visible.some((step, i) => step !== this.steps[i])) { + this.filterSteps(); + } + }); + }); // Delivers the first visible step's onEnter once that step exists and is // unblocked. setActive(0) cannot do it: no step is visible until @@ -148,6 +162,7 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { ngAfterContentInit() { this.allSteps = this.stepComponents.toArray(); + this.contentSteps.set(this.allSteps); this.allSteps.forEach((step => { this.hiddenSubs.push(step.hiddenChange.subscribe((_hidden) => { From 10606d56b1d3ee3cffb36028ccf7de199c073dee Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 13:10:08 -0700 Subject: [PATCH 040/121] docs(cf): correct the create app step content note The comment said only the active step's content is instantiated and that it is torn down on leaving. The stepper builds every step's projected content on first render and keeps it; the projection spec in steppers.component.spec.ts shows it. --- .../create-application.component.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts index 44a4c04e4a..1c44c53c92 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts @@ -53,15 +53,16 @@ export class CreateApplicationComponent implements OnInit, OnDestroy { // name) lives in CfOrgSpaceDataService + the signal-native // CreateAppStateService — children read/write it via that service. // - // The steppers component renders only the active step's content - // template at any given time (see steppers.component.html line 71: - // ``), so - // children for non-active steps are not instantiated. We use - // ViewChild *setters* so the bridge subscription is wired the moment - // the child instance becomes available — and torn down when the - // child reference flips back to undefined as the user navigates - // away. This sidesteps relying on signal-handle onEnter being routed - // through the steppers component for bridge wiring. + // The steppers component shows only the active step's content + // (`*ngTemplateOutlet="steps[currentIndex()].content"`), but Ivy + // builds every step's projected content with this view on first + // render, whichever step is active (pinned by the projected step + // content spec in steppers.component.spec.ts). So all three children + // exist up front, and navigating between steps does not create or + // destroy them. We use ViewChild *setters* so each bridge + // subscription is wired as soon as its child exists, without relying + // on signal-handle onEnter being routed through the steppers + // component. private _step1?: CreateApplicationStep1Component; private _step2?: CreateApplicationStep2Component; private _step3?: CreateApplicationStep3Component; From bb2e640bc6f0a8bc4619e464780ccab636630418 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 13:10:08 -0700 Subject: [PATCH 041/121] fix(core): keep the stepper on its step after refilter Refiltering rebuilt the step list without adjusting the current index. Showing a hidden step before the current one moved the index onto a different step while the old one stayed marked active, and hiding a step at or before the end pushed the index past the list and threw in the template. The index now follows the current step; if that step itself is hidden, the step shown in its place is entered. --- .../steppers/steppers.component.spec.ts | 72 +++++++++++++++++++ .../stepper/steppers/steppers.component.ts | 19 +++++ 2 files changed, 91 insertions(+) diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts index 4a35370140..ad51026e80 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts @@ -426,3 +426,75 @@ describe('SteppersComponent — signal-handle hidden', () => { expect(host.entered).toEqual(['one']); }); }); + +// Showing or hiding a step before the current one shifts the visible +// list. The current index must move with it, so the stepper stays on the +// step the user is on and that step is the one marked active. +describe('SteppersComponent — current step across re-filters', () => { + let hostFixture: ComponentFixture; + let host: FirstStepHostComponent; + const steppers = () => hostFixture.debugElement + .query(By.directive(SteppersComponent)).componentInstance as SteppersComponent; + const current = () => steppers().steps[steppers().currentIndex()]; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [FirstStepHostComponent, RouterTestingModule], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + await TestBed.compileComponents(); + hostFixture = TestBed.createComponent(FirstStepHostComponent); + host = hostFixture.componentInstance; + }); + + it('stays on the entered step when a hidden step before it is shown', async () => { + host.firstHidden.set(true); + await hostFixture.whenStable(); + await hostFixture.whenStable(); + expect(host.entered).toEqual(['two']); + expect(current().title).toBe('Two'); + + host.firstHidden.set(false); + await hostFixture.whenStable(); + expect(steppers().steps.map(step => step.title)).toEqual(['One', 'Two']); + expect(current().title).toBe('Two'); + expect(current().active).toBe(true); + expect(steppers().steps[0].active).toBe(false); + // Showing a step is not a transition: no extra onEnter. + expect(host.entered).toEqual(['two']); + }); + + it('stays on the current step when a step before it is hidden', async () => { + await hostFixture.whenStable(); + await hostFixture.whenStable(); + steppers().setActive(1); + await hostFixture.whenStable(); + expect(current().title).toBe('Two'); + + host.firstHidden.set(true); + await hostFixture.whenStable(); + expect(steppers().steps.map(step => step.title)).toEqual(['Two']); + expect(current().title).toBe('Two'); + expect(current().active).toBe(true); + }); + + it('enters the step shown in place of a current step that is hidden', async () => { + await hostFixture.whenStable(); + await hostFixture.whenStable(); + steppers().setActive(1); + await hostFixture.whenStable(); + expect(host.entered).toEqual(['one', 'two']); + + host.secondHidden.set(true); + await hostFixture.whenStable(); + await hostFixture.whenStable(); + expect(steppers().steps.map(step => step.title)).toEqual(['One']); + expect(current().title).toBe('One'); + expect(current().active).toBe(true); + expect(host.entered).toEqual(['one', 'two', 'one']); + }); +}); diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts index 40a5e62ff8..316b8a2772 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts @@ -195,7 +195,22 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { } private filterSteps() { + const current = this.steps[this.currentIndex()]; this.steps = this.allSteps.filter((step => !step.hidden)); + // Showing or hiding a step before the current one moves it in the + // list. Follow it, so the index keeps pointing at the step the user is + // on (the one marked active) instead of at its neighbour or past the end. + if (current?.active) { + const index = this.steps.indexOf(current); + if (index >= 0) { + this.currentIndex.set(index); + } else if (this.steps.length) { + // The current step itself was hidden: enter the step now shown in + // its place (clamped to the end of the list). + current.active = false; + this.enterStep(Math.min(this.currentIndex(), this.steps.length - 1)); + } + } this.visibleSteps.set(this.steps); // Under zoneless CD, reassigning this.steps doesn't mark the view // dirty. Without markForCheck the template keeps rendering the step @@ -344,6 +359,10 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { } // 3) Set stepper state WRT required step + this.enterStep(index); + } + + private enterStep(index: number): void { this.steps.forEach((s, i) => { s.complete = i < index; s.active = i === index; From 294131b1dba05991e921eea66c5a1401cbdccd85 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 13:16:01 -0700 Subject: [PATCH 042/121] fix(core): keep the stepper oriented when all steps hide Hiding every visible step while one was active left the old index in place, so when steps came back the index pointed at the wrong step or past the list and the template threw. Remember the active step while nothing is visible: if it comes back, return to it without a second onEnter; if not, enter the first visible step once. --- .../steppers/steppers.component.spec.ts | 68 +++++++++++++++++++ .../stepper/steppers/steppers.component.ts | 17 +++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts index ad51026e80..e41084bf58 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.spec.ts @@ -498,3 +498,71 @@ describe('SteppersComponent — current step across re-filters', () => { expect(host.entered).toEqual(['one', 'two', 'one']); }); }); + +// When every step is hidden at once (here, two handle signals flipping in +// one tick), the stepper shows nothing and there is no step for the index +// to follow. It must remember the step the user was on, so that when steps +// come back the index and the active marker agree again. +describe('SteppersComponent — all steps hidden, then shown', () => { + let hostFixture: ComponentFixture; + let host: SignalHiddenHostComponent; + const steppers = () => hostFixture.debugElement + .query(By.directive(SteppersComponent)).componentInstance as SteppersComponent; + const current = () => steppers().steps[steppers().currentIndex()]; + const settle = async () => { + await hostFixture.whenStable(); + await hostFixture.whenStable(); + }; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [SignalHiddenHostComponent, RouterTestingModule], + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + await TestBed.compileComponents(); + hostFixture = TestBed.createComponent(SignalHiddenHostComponent); + host = hostFixture.componentInstance; + await settle(); + // Move to the second step, then hide every step in one re-filter. + steppers().setActive(1); + await settle(); + expect(host.entered).toEqual(['one', 'two']); + host.firstHidden.set(true); + host.secondHidden.set(true); + await settle(); + expect(steppers().steps).toEqual([]); + }); + + it('returns to the step the user was on when it reappears, without a second onEnter', async () => { + host.secondHidden.set(false); + await settle(); + expect(steppers().steps.map(step => step.title)).toEqual(['Two']); + expect(current().title).toBe('Two'); + expect(current().active).toBe(true); + expect(host.entered).toEqual(['one', 'two']); + }); + + it('returns to the step the user was on when all steps reappear', async () => { + host.firstHidden.set(false); + host.secondHidden.set(false); + await settle(); + expect(current().title).toBe('Two'); + expect(current().active).toBe(true); + expect(steppers().steps[0].active).toBe(false); + expect(host.entered).toEqual(['one', 'two']); + }); + + it('enters the first visible step once when the step the user was on stays hidden', async () => { + host.firstHidden.set(false); + await settle(); + expect(steppers().steps.map(step => step.title)).toEqual(['One']); + expect(current().title).toBe('One'); + expect(current().active).toBe(true); + expect(steppers().allSteps[1].active).toBe(false); + expect(host.entered).toEqual(['one', 'two', 'one']); + }); +}); diff --git a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts index 316b8a2772..75a69b9787 100644 --- a/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts +++ b/src/frontend/packages/core/src/shared/components/stepper/steppers/steppers.component.ts @@ -77,6 +77,9 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { private readonly visibleSteps = signal([]); // The projected steps, set once in ngAfterContentInit. private readonly contentSteps = signal([]); + // The active step while every step is hidden, so filterSteps can return + // to it (or move on from it) once steps are shown again. + private parkedStep?: StepComponent; // A legacy [hidden] change re-filters through hiddenChange, but a // signalHandle.hidden change emits nothing. Reading `hidden` here tracks @@ -195,7 +198,9 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { } private filterSteps() { - const current = this.steps[this.currentIndex()]; + const parked = this.parkedStep; + const current = parked ?? this.steps[this.currentIndex()]; + this.parkedStep = undefined; this.steps = this.allSteps.filter((step => !step.hidden)); // Showing or hiding a step before the current one moves it in the // list. Follow it, so the index keeps pointing at the step the user is @@ -204,11 +209,15 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { const index = this.steps.indexOf(current); if (index >= 0) { this.currentIndex.set(index); - } else if (this.steps.length) { + } else if (!this.steps.length) { + // Every step is hidden: nothing to show or follow until one returns. + this.parkedStep = current; + } else { // The current step itself was hidden: enter the step now shown in - // its place (clamped to the end of the list). + // its place (clamped to the end of the list). After every step was + // hidden the old position means nothing, so start from the first. current.active = false; - this.enterStep(Math.min(this.currentIndex(), this.steps.length - 1)); + this.enterStep(parked ? 0 : Math.min(this.currentIndex(), this.steps.length - 1)); } } this.visibleSteps.set(this.steps); From 22c63bc10eb7871c4dc3c3e141b1e26490c50d0f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 15:57:14 -0700 Subject: [PATCH 043/121] fix(cf): keep a running file deploy open on leave The source upload step hands its deployer to the deploy step, but on destroy it always closed it. Leaving the page during a file or folder push dropped the socket before the deploy step could wait for the end, so no completion or failure notice showed. The upload step now closes the deployer only when no push is running, the same rule the deploy step uses; a deployer handed on but never started is still closed. --- ...ation-step-source-upload.component.spec.ts | 34 ++++++++++++++++++- ...pplication-step-source-upload.component.ts | 3 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 2acfdfa3cd..51a0669e15 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; @@ -10,6 +10,7 @@ import { CATALOGUE_ENTITIES, entityCatalog, generateStratosEntities, TestEntityC import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCFEntities } from '../../../../cf-entity-generator'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; +import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStepSourceUploadComponent } from "./deploy-application-step-source-upload.component"; describe('DeployApplicationStepSourceUploadComponent', () => { @@ -54,4 +55,35 @@ describe('DeployApplicationStepSourceUploadComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('deployer ownership on destroy', () => { + const fileInfo = {} as FileScannerInfo; + let closeSpy: ReturnType; + + beforeEach(() => { + vi.spyOn(component.deployer, 'open').mockImplementation(() => {}); + closeSpy = vi.spyOn(component.deployer, 'close'); + }); + + it('closes the deployer when no push is running', () => { + component.onEnter(fileInfo); + fixture.destroy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('closes a handed-on deployer the deploy step never started', () => { + component.onEnter(fileInfo); + component.onNext(0, null as any).subscribe(); + fixture.destroy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('leaves the deployer open while its push is running', () => { + component.onEnter(fileInfo); + component.onNext(0, null as any).subscribe(); + component.deployer.deploying = true; + fixture.destroy(); + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts index c203ebd5a6..2a1c48d2a0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts @@ -61,8 +61,9 @@ export class DeployApplicationStepSourceUploadComponent implements OnDestroy { return observableOf({ success: true, data: this.deployer }); } + // A running push belongs to the deploy step, which reports its end ngOnDestroy() { - if (this.deployer) { + if (this.deployer && !this.deployer.deploying) { this.deployer.close(); } } From e1de84d4534ec51e709d18c048abb24fd0fbde61 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:06:29 -0700 Subject: [PATCH 044/121] docs(store): note observeSignal view injector end observeSignal() is safe with a long-lived injector, but with a component's injector the effect is a view effect. Angular destroys it with the view even when manualCleanup is set, and the observable then stops emitting without completing. Say so, and name the injector to use when the subscriber can outlive the view. --- src/frontend/packages/store/src/helpers/signal-interop.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/frontend/packages/store/src/helpers/signal-interop.ts b/src/frontend/packages/store/src/helpers/signal-interop.ts index b1e050dbf5..8e027201e5 100644 --- a/src/frontend/packages/store/src/helpers/signal-interop.ts +++ b/src/frontend/packages/store/src/helpers/signal-interop.ts @@ -10,6 +10,11 @@ import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; * it safe with a long-lived injector such as the root one, where * toObservable() would leave an effect running forever per call. * + * A component's injector makes the effect a view effect: it ends with that + * view, manualCleanup or not, and the observable then goes silent without + * completing. Pass an EnvironmentInjector when the subscriber can outlive + * the view. + * * Timing matches toObservable(): the first value comes from the effect's * first run, not synchronously on subscribe. */ From 8dc22b01aab86161f768041f518476b8b8ae74dc Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:06:29 -0700 Subject: [PATCH 045/121] fix(cf): build the deployer on the root injector Both deploy steps built the deployer with their own injector, so its deploy state watch was a view effect that Angular ends with the wizard page. The deployer outlives the page when the user leaves mid-push, and from then on it no longer saw state changes. The steps now pass the environment injector, and the deployer constructor takes only an EnvironmentInjector so a view injector no longer compiles. --- .../deploy-application-deployer.spec.ts | 4 +-- .../deploy-application-deployer.ts | 4 +-- ...ation-step-source-upload.component.spec.ts | 29 ++++++++++++++++++- ...pplication-step-source-upload.component.ts | 6 ++-- .../deploy-application-step3.component.ts | 6 ++-- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index e4f0d2c88b..1c9876c560 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ApplicationRef, Injector } from '@angular/core'; +import { ApplicationRef, EnvironmentInjector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SocketEventTypes } from '../../../store/types/deploy-application.types'; @@ -10,7 +10,7 @@ describe('DeployApplicationDeployer', () => { let deployer: DeployApplicationDeployer; beforeEach(() => { - deployer = new DeployApplicationDeployer({} as CfOrgSpaceDataService, TestBed.inject(Injector)); + deployer = new DeployApplicationDeployer({} as CfOrgSpaceDataService, TestBed.inject(EnvironmentInjector)); }); it('keeps the specific failure when a generic close follows it', () => { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index aa15f945a4..b03a51e5e6 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -1,4 +1,4 @@ -import { Injector } from '@angular/core'; +import { EnvironmentInjector } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import websocketConnect from 'rxjs-websockets'; import { take, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'; @@ -119,7 +119,7 @@ export class DeployApplicationDeployer { constructor( public cfOrgSpaceService: CfOrgSpaceDataService, - private injector: Injector, + private injector: EnvironmentInjector, ) { this.status$ = createSignalWrapper({ error: false, diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 51a0669e15..4cd9177685 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -1,18 +1,23 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { ApplicationRef, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { NEVER } from 'rxjs'; import { CATALOGUE_ENTITIES, entityCatalog, generateStratosEntities, TestEntityCatalog } from '@stratosui/store'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCFEntities } from '../../../../cf-entity-generator'; +import { CfDeployAppDataService } from '../../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; +import { OverrideAppDetails } from '../../../../store/types/deploy-application.types'; import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStepSourceUploadComponent } from "./deploy-application-step-source-upload.component"; +vi.mock('rxjs-websockets', () => ({ default: () => NEVER })); + describe('DeployApplicationStepSourceUploadComponent', () => { let component: DeployApplicationStepSourceUploadComponent; let fixture: ComponentFixture; @@ -86,4 +91,26 @@ describe('DeployApplicationStepSourceUploadComponent', () => { expect(closeSpy).not.toHaveBeenCalled(); }); }); + + it('keeps the deployer following the deploy state after the step is gone', async () => { + // The deploy step keeps this deployer, and after the user leaves the + // page mid-push it still waits on it for the completion notice + const appRef = TestBed.inject(ApplicationRef); + const deployData = TestBed.inject(CfDeployAppDataService); + const overrides = (name: string) => ({ name } as OverrideAppDetails); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.saveAppOverrides(overrides('first')); + component.onEnter({} as FileScannerInfo); + await appRef.whenStable(); + expect(component.deployer.applicationOverrides.name).toBe('first'); + + const deployer = component.deployer; + deployer.deploying = true; + fixture.destroy(); + deployData.saveAppOverrides(overrides('second')); + await appRef.whenStable(); + + expect(deployer.applicationOverrides.name).toBe('second'); + deployer.close(); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts index 2a1c48d2a0..b2724b33cb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Injector, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, EnvironmentInjector, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; import { Observable, of as observableOf } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -23,7 +23,9 @@ import { FileScannerInfo } from '../deploy-application-step2/deploy-application- }) export class DeployApplicationStepSourceUploadComponent implements OnDestroy { cfOrgSpaceService = inject(CfOrgSpaceDataService); - private injector = inject(Injector); + // The deployer outlives this step; a view injector would tie its state + // effects to the view and end them when the view is destroyed + private injector = inject(EnvironmentInjector); public deployer: DeployApplicationDeployer; 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 3b1a77a31d..3c8e8da5f8 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 @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { ChangeDetectorRef, Component, Injector, Input, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, EnvironmentInjector, Input, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, @@ -38,7 +38,9 @@ export class DeployApplicationStep3Component implements OnDestroy { private deployData = inject(CfDeployAppDataService); private snackBarService = inject(TailwindSnackBarService); cfOrgSpaceService = inject(CfOrgSpaceDataService); - private injector = inject(Injector); + // The deployer outlives this step; a view injector would tie its state + // effects to the view and end them when the view is destroyed + private injector = inject(EnvironmentInjector); private cdr = inject(ChangeDetectorRef); private http = inject(HttpClient); From 44b42f749d43fe162c5f25045f517dcebcf91c69 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:13:09 -0700 Subject: [PATCH 046/121] fix(cf): redeploy the recorded commit A redeploy saved the commit from the commit card lookup. Pressing Next before that lookup resolved, or after it failed, saved no commit, and the backend deployed the branch HEAD instead of the recorded commit. The step now saves the SHA it already knows; the lookup only feeds the card. The card read a plain field set in a subscribe, so under zoneless OnPush it did not paint until something else marked the view; it is a signal now. --- .../deploy-application-step2.component.html | 2 +- ...deploy-application-step2.component.spec.ts | 57 +++++++++++++++++++ .../deploy-application-step2.component.ts | 22 ++++--- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html index e8bfb42369..218130883d 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html @@ -139,7 +139,7 @@ } - @if (isRedeploy && commitInfo) { + @if (isRedeploy && commitInfo(); as commitInfo) {
diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts index 39ae80fe23..973f7413dc 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts @@ -415,6 +415,63 @@ describe('DeployApplicationStep2Component', () => { }); }); + // A redeploy looks up the commit recorded for the deployed app. The lookup + // only feeds the commit card; the recorded SHA is what gets deployed, so a + // slow or failed lookup must not fall back to the branch HEAD. + describe('redeploy commit', () => { + const githubType = { id: 'github', group: 'gitscm', name: 'GitHub' } as any; + const branch = { name: 'main', projectName: 'o/r', commit: { sha: 'head000' } } as any; + let commit$: Subject; + + beforeEach(async () => { + TestBed.inject(ApplicationDeploySourceTypes).types$ = of([githubType]); + const gitData = TestBed.inject(GitDataService); + vi.spyOn(gitData, 'getBranches').mockReturnValue(of([branch])); + vi.spyOn(gitData, 'getRepository') + .mockReturnValue({ waitForValue$: of({ clone_url: 'https://github.com/o/r.git' }) } as any); + commit$ = new Subject(); + vi.spyOn(gitData, 'getCommit').mockReturnValue({ waitForValue$: commit$ } as any); + + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf-1', org: 'org-1', space: 'space-1' }); + deployData.setSourceType(githubType); + deployData.setDeployBranch('main'); + deployData.setDeployCommit('rec1234abcd'); + + fixture = TestBed.createComponent(DeployApplicationStep2Component); + component = fixture.componentInstance; + fixture.componentRef.setInput('isRedeploy', true); + await fixture.whenStable(); + // The repository lookup resolves after the step is set up, as it does + // in the app; that starts the branch and then the commit lookup. + const repo = { full_name: 'o/r', html_url: 'https://github.com/o/r', owner: { avatar_url: '' } }; + deployData.checkProjectExists({ getRepository: () => of(repo) } as any, 'o/r'); + await fixture.whenStable(); + expect(gitData.getCommit).toHaveBeenCalledWith(expect.anything(), 'o/r', 'rec1234abcd'); + }); + + it('shows the commit card once the lookup resolves', async () => { + commit$.next({ sha: 'rec1234abcd', commit: { message: 'Recorded change' } }); + await fixture.whenStable(); + + const card = fixture.nativeElement.querySelector('.deploy-step2-form__commit'); + expect(card?.textContent).toContain('Recorded change'); + }); + + it('saves the recorded commit when Next is pressed before the lookup resolves', async () => { + const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); + await firstValueFrom(component.onNext(0, null as any)); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ commit: 'rec1234abcd' }), null); + }); + + it('saves the recorded commit when the lookup fails', async () => { + commit$.error(new Error('gone')); + const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); + await firstValueFrom(component.onNext(0, null as any)); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ commit: 'rec1234abcd' }), null); + }); + }); + describe('applyGithubEnterpriseAndToken — form wiring', () => { let scmSpy: { setPublicApi: ReturnType; 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 9d04c50ba9..ff2bd5cd7c 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 @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { AfterContentInit, Component, Input, OnDestroy, OnInit, ViewChild, ChangeDetectionStrategy, inject } from '@angular/core'; +import { AfterContentInit, Component, Input, OnDestroy, OnInit, ViewChild, ChangeDetectionStrategy, inject, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { AbstractControl, FormsModule, NgForm } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -91,7 +91,12 @@ export class DeployApplicationStep2Component @Input() isRedeploy = false; - commitInfo?: GitCommit; + // Display details of the redeploy commit. A signal, so the OnPush view + // repaints when the lookup resolves. + commitInfo = signal(undefined); + // The SHA a redeploy saves. Known before the lookup, which only adds the + // display details, so Next does not wait on or depend on the lookup. + private redeployCommitSha?: string; public DEPLOY_TYPES_IDS = DEPLOY_TYPES_IDS; sourceType$!: Observable; INITIAL_SOURCE_TYPE = 0; // Fall back to GitHub, for cases where there's no type in store (refresh) or url (removed & nav) @@ -232,7 +237,7 @@ export class DeployApplicationStep2Component branch, url: repo.clone_url, accessToken: this.accessToken, - commit: this.isRedeploy ? this.commitInfo?.sha : undefined, + commit: this.isRedeploy ? this.redeployCommitSha : undefined, endpointGuid, }, null); return { success: true, data }; @@ -385,16 +390,18 @@ export class DeployApplicationStep2Component if (this.isRedeploy) { const commitSha = commit || branch.commit?.sha; + this.redeployCommitSha = commitSha; if (this.commitSubscription) { this.commitSubscription.unsubscribe(); } if (!commitSha) { return; } - // A failed lookup leaves commitInfo unset, so onNext saves no commit. + // A failed lookup only hides the commit card; onNext still saves + // commitSha, and the backend reports a commit that is gone. this.commitSubscription = this.gitData.getCommit(this.scm, projectInfo.full_name, commitSha) .waitForValue$.subscribe({ - next: p => this.commitInfo = p, - error: () => this.commitInfo = undefined, + next: p => this.commitInfo.set(p), + error: () => this.commitInfo.set(undefined), }); } } @@ -417,7 +424,8 @@ export class DeployApplicationStep2Component if (this.scm && newScm.getType() !== this.scm.getType()) { // User changed the SCM type, so reset the project and branch this.repository = ''; - this.commitInfo = undefined; + this.commitInfo.set(undefined); + this.redeployCommitSha = undefined; this.repositoryBranch = undefined; this.deployData.setBranch(null); this.deployData.projectDoesntExist(''); From 920e52572d471ed37f4add9822fabe37e64a6937 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:17:43 -0700 Subject: [PATCH 047/121] fix(cf): show the deploy title once the manifest is in The deploy title was a plain field. Step 3 repainted only when the deployer pushed a status, and the manifest message pushes none, so the title stayed at "Preparing..." until the push started. It is a signal now. Pushing a status on the manifest instead would start the app GUID lookup before the push; the new spec checks that no lookup runs at that point. --- .../deploy-application-deployer.spec.ts | 2 +- .../deploy-application-deployer.ts | 12 ++++++------ .../deploy-application-step3.component.html | 2 +- .../deploy-application-step3.component.spec.ts | 17 ++++++++++++++++- .../deploy-application-step3.component.ts | 16 ++++++++-------- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index 1c9876c560..d1582c2110 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -18,7 +18,7 @@ describe('DeployApplicationDeployer', () => { deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_NO_MANIFEST, message: reason }); deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_FAILURE, message: reason }); - expect(deployer.streamTitle).toBe('Deploy Failed - No manifest present!'); + expect(deployer.streamTitle()).toBe('Deploy Failed - No manifest present!'); expect(deployer.status$().errorMsg).toContain('enter an application name'); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index b03a51e5e6..edbe9bf2ff 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -1,4 +1,4 @@ -import { EnvironmentInjector } from '@angular/core'; +import { EnvironmentInjector, signal } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import websocketConnect from 'rxjs-websockets'; import { take, catchError, filter, map, share, switchMap, tap } from 'rxjs/operators'; @@ -76,7 +76,7 @@ export class DeployApplicationDeployer { connectSub!: Subscription; updateSub!: Subscription; msgSub!: Subscription; - streamTitle = 'Preparing...'; + streamTitle = signal('Preparing...'); // strict: populated from the MANIFEST websocket event before any read appData!: AppData; proxyAPIVersion = environment.proxyAPIVersion; @@ -353,7 +353,7 @@ export class DeployApplicationDeployer { processWebSocketMessage = (log: any) => { switch (log.type) { case SocketEventTypes.MANIFEST: - this.streamTitle = 'Starting deployment...'; + this.streamTitle.set('Starting deployment...'); // This info is will be used to retrieve the app Id this.appData = JSON.parse(log.message).Applications[0]; break; @@ -362,13 +362,13 @@ export class DeployApplicationDeployer { this.applicationGuid$.next(log.message); break; case SocketEventTypes.EVENT_PUSH_STARTED: - this.streamTitle = 'Deploying...'; + this.streamTitle.set('Deploying...'); this.deploying = true; this.updateStatus(); break; case SocketEventTypes.EVENT_PUSH_COMPLETED: // Done - this.streamTitle = 'Deployed'; + this.streamTitle.set('Deployed'); this.deploying = false; this.updateStatus(); break; @@ -468,7 +468,7 @@ export class DeployApplicationDeployer { } this.closeReported = true; if (title) { - this.streamTitle = title; + this.streamTitle.set(title); } this.deploying = false; this.updateStatus( diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.html index a730e38361..f6f9dc9afc 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.html @@ -1,5 +1,5 @@
-
{{ deployer ? deployer.streamTitle : 'Pending' }}
+
{{ deployer ? deployer.streamTitle() : 'Pending' }}
@if (deployer && deployer.deploying) { } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index def07b8899..69bf782d56 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ApplicationRef, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; import { NEVER } from 'rxjs'; @@ -42,6 +42,21 @@ describe('DeployApplicationStep3Component', () => { expect(component).toBeTruthy(); }); + it('shows the manifest title before the push starts', async () => { + component.onEnter(undefined as any); + await fixture.whenStable(); + const deployer = component.deployer; + deployer.processWebSocketMessage({ + type: SocketEventTypes.MANIFEST, + message: JSON.stringify({ Applications: [{ Name: 'a' }] }), + }); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.font-semibold').textContent.trim()).toBe('Starting deployment...'); + // The app GUID lookup waits for the push to finish + TestBed.inject(HttpTestingController).expectNone(() => true); + }); + it('reports a deploy that finishes after the user leaves the page', async () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); 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 3c8e8da5f8..aa724b5ca2 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 @@ -78,12 +78,12 @@ export class DeployApplicationStep3Component implements OnDestroy { private initDeployer() { const status$ = this.deployer.status$.asObservable(); - // The template reads plain fields (deployer.streamTitle, deployer.deploying) - // which are mutated directly by the deployer's websocket handlers, not - // through observables. Under OnPush + zoneless CD, those mutations don't - // trigger a re-render on their own. Subscribe to status$ (which IS emitted - // via the signal wrapper every time updateStatus() is called) and force a - // view check so the title bar and spinner reflect the current state. + // The template reads a plain field (deployer.deploying) which is mutated + // directly by the deployer's websocket handlers, not through observables. + // Under OnPush + zoneless CD, that mutation doesn't trigger a re-render on + // its own. Subscribe to status$ (which IS emitted via the signal wrapper + // every time updateStatus() is called) and force a view check so the + // spinner reflects the current state. The title is a signal. this.subscriptions.push( status$.subscribe(() => this.cdr.markForCheck()) ); @@ -132,7 +132,7 @@ export class DeployApplicationStep3Component implements OnDestroy { this.subscriptions.push( status$.pipe( - map(status => !status.deploying || status.deploying && !this.deployer.streamTitle) + map(status => !status.deploying || status.deploying && !this.deployer.streamTitle()) ).subscribe(v => this.showOverlaySubject.next(v)) ); @@ -243,7 +243,7 @@ export class DeployApplicationStep3Component implements OnDestroy { this.busy = true; // Under OnPush + zoneless CD, assigning this.deployer to a plain field // does not trigger a view update. Force one so the template (which - // reads deployer.streamTitle and renders the log-viewer under an @if + // reads deployer.streamTitle() and renders the log-viewer under an @if // deployer check) actually picks up the new reference. this.cdr.markForCheck(); }; From dfec5172c1048879d54da8b04adc93aba49f51b7 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:28:27 -0700 Subject: [PATCH 048/121] fix(cf): report every end of a deploy after leaving Leaving the deploy page kept the deploy only once the push had started; leaving during the clone, manifest fetch or after an upload closed the socket and the deploy ended with no notice. A deploy now stays open from when it starts until the server reports its end, and the notice waits for that end rather than for the push to stop. A socket that ends before any close message is reported as a lost connection, so the notice no longer waits forever. A deploy whose socket never connected is still closed on leave: leaving clears the deploy details it waits for. --- .../deploy-application-deployer.spec.ts | 51 ++++++++++- .../deploy-application-deployer.ts | 37 ++++++-- ...ation-step-source-upload.component.spec.ts | 30 ++++--- ...pplication-step-source-upload.component.ts | 5 +- ...deploy-application-step3.component.spec.ts | 85 +++++++++++++++++-- .../deploy-application-step3.component.ts | 12 ++- 6 files changed, 189 insertions(+), 31 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index d1582c2110..0867c674e5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -1,16 +1,63 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApplicationRef, EnvironmentInjector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Subject, of } from 'rxjs'; import { SocketEventTypes } from '../../../store/types/deploy-application.types'; +import { CfDeployAppDataService } from '../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; +import { FileScannerInfo } from './deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationDeployer } from './deploy-application-deployer'; +// Each connection gets a server stream the test drives +const socket = vi.hoisted(() => ({ server: undefined as any })); +vi.mock('rxjs-websockets', () => ({ + default: () => { + socket.server = new Subject(); + return of(() => socket.server); + }, +})); + describe('DeployApplicationDeployer', () => { let deployer: DeployApplicationDeployer; beforeEach(() => { - deployer = new DeployApplicationDeployer({} as CfOrgSpaceDataService, TestBed.inject(EnvironmentInjector)); + const orgSpace = { orgList: () => [], spaceList: () => [] } as unknown as CfOrgSpaceDataService; + deployer = new DeployApplicationDeployer(orgSpace, TestBed.inject(EnvironmentInjector)); + }); + + describe('when the connection ends', () => { + beforeEach(async () => { + socket.server = undefined; + TestBed.inject(CfDeployAppDataService).setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployer.fsFileInfo = {} as FileScannerInfo; + deployer.open(); + await TestBed.inject(ApplicationRef).whenStable(); + expect(socket.server).toBeDefined(); + }); + + it('reports a failure when no close message came first', () => { + socket.server.complete(); + + expect(deployer.streamTitle()).toBe('Deploy Failed - Connection lost'); + expect(deployer.status$().error).toBeTruthy(); + expect(deployer.status$().errorMsg).toBe('The connection to the server was lost before the deploy finished.'); + }); + + it('keeps the reported close when the server then closes the connection', () => { + socket.server.next(JSON.stringify({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' })); + socket.server.complete(); + + expect(deployer.status$().error).toBeFalsy(); + expect(deployer.streamTitle()).not.toBe('Deploy Failed - Connection lost'); + }); + + it('reports nothing when the deployer itself closes the connection', () => { + deployer.close(); + socket.server.complete(); + + expect(deployer.status$().error).toBe(false); + }); }); it('keeps the specific failure when a generic close follows it', () => { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index edbe9bf2ff..e24962c3eb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -111,6 +111,21 @@ export class DeployApplicationDeployer { // only the first close of a deploy is reported private closeReported = false; + // A remote source deploys as soon as the socket opens; a local source + // only once deploy() acknowledges the upload + private started = false; + + // Started, connected and not yet ended by a reported close. A deploy in + // flight reports its end even after the deploy step is gone; one still + // waiting to connect never will, as leaving clears its deploy details. + get inFlight() { + return this.isOpen && this.started && !!this.msgSub && !this.msgSub.closed && !this.closeReported; + } + + get finished() { + return this.closeReported; + } + public fsFileInfo!: FileScannerInfo; private fileTransfers: any; @@ -177,6 +192,7 @@ export class DeployApplicationDeployer { }; this.isOpen = true; this.closeReported = false; + this.started = !this.fsFileInfo; this.connectSub = this.deployState$.pipe( filter((appDetail): appDetail is DeployApplicationState & { cloudFoundryDetails: NonNullable; } => !!appDetail && !!appDetail.cloudFoundryDetails && readyFilter(appDetail)), @@ -227,12 +243,18 @@ export class DeployApplicationDeployer { // Buffer messages until each newline character let b = ''; - this.msgSub = buffer.subscribe(m => { - b = b + m; - if (b.endsWith('\n')) { - this.messages.next(b); - b = ''; - } + this.msgSub = buffer.subscribe({ + next: m => { + b = b + m; + if (b.endsWith('\n')) { + this.messages.next(b); + b = ''; + } + }, + // The socket ended (errors end it too) without a close message. + // close() unsubscribes, which does not get here. + complete: () => this.onClose({}, 'Deploy Failed - Connection lost', + 'The connection to the server was lost before the deploy finished.'), }); }) ).subscribe(); @@ -249,6 +271,7 @@ export class DeployApplicationDeployer { } deploy() { + this.started = true; // After the source has been sent, acknowledge the wait const msg = { message: '', @@ -473,7 +496,7 @@ export class DeployApplicationDeployer { this.deploying = false; this.updateStatus( error, - error ? `${error}\nReason: ${log.message}` : undefined + error && log.message ? `${error}\nReason: ${log.message}` : error || undefined ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 4cd9177685..9d99455c80 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -12,7 +12,7 @@ import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCFEntities } from '../../../../cf-entity-generator'; import { CfDeployAppDataService } from '../../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; -import { OverrideAppDetails } from '../../../../store/types/deploy-application.types'; +import { OverrideAppDetails, SocketEventTypes } from '../../../../store/types/deploy-application.types'; import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStepSourceUploadComponent } from "./deploy-application-step-source-upload.component"; @@ -62,33 +62,41 @@ describe('DeployApplicationStepSourceUploadComponent', () => { }); describe('deployer ownership on destroy', () => { - const fileInfo = {} as FileScannerInfo; let closeSpy: ReturnType; - beforeEach(() => { - vi.spyOn(component.deployer, 'open').mockImplementation(() => {}); + beforeEach(async () => { + // Enter with the deploy target set, so the deployer connects + TestBed.inject(CfDeployAppDataService).setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + component.onEnter({} as FileScannerInfo); + await TestBed.inject(ApplicationRef).whenStable(); closeSpy = vi.spyOn(component.deployer, 'close'); }); - it('closes the deployer when no push is running', () => { - component.onEnter(fileInfo); + it('closes the deployer while only the upload has run', () => { fixture.destroy(); expect(closeSpy).toHaveBeenCalled(); }); it('closes a handed-on deployer the deploy step never started', () => { - component.onEnter(fileInfo); component.onNext(0, null as any).subscribe(); fixture.destroy(); expect(closeSpy).toHaveBeenCalled(); }); - it('leaves the deployer open while its push is running', () => { - component.onEnter(fileInfo); + it('leaves the deployer open once the deploy step started it, before the push', () => { component.onNext(0, null as any).subscribe(); - component.deployer.deploying = true; + component.deployer.deploy(); fixture.destroy(); expect(closeSpy).not.toHaveBeenCalled(); + component.deployer.close(); + }); + + it('closes a started deploy that has finished', () => { + component.onNext(0, null as any).subscribe(); + component.deployer.deploy(); + component.deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_FAILURE, message: 'boom' }); + fixture.destroy(); + expect(closeSpy).toHaveBeenCalled(); }); }); @@ -105,7 +113,7 @@ describe('DeployApplicationStepSourceUploadComponent', () => { expect(component.deployer.applicationOverrides.name).toBe('first'); const deployer = component.deployer; - deployer.deploying = true; + deployer.deploy(); fixture.destroy(); deployData.saveAppOverrides(overrides('second')); await appRef.whenStable(); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts index b2724b33cb..1c3708c3b5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts @@ -63,9 +63,10 @@ export class DeployApplicationStepSourceUploadComponent implements OnDestroy { return observableOf({ success: true, data: this.deployer }); } - // A running push belongs to the deploy step, which reports its end + // A deploy the deploy step started belongs to it, which reports its end; + // an upload the deploy step never started is cancelled ngOnDestroy() { - if (this.deployer && !this.deployer.deploying) { + if (this.deployer && !this.deployer.inFlight) { this.deployer.close(); } } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 69bf782d56..ad5a000d92 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -9,10 +9,17 @@ import { NEVER } from 'rxjs'; import { generateCfBaseTestModulesNoShared } from "@test-framework/cf"; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { TailwindSnackBarService } from '@stratosui/core'; +import { CfDeployAppDataService } from '../../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; -import { SocketEventTypes } from '../../../../store/types/deploy-application.types'; +import { + GitAppDetails, + OverrideAppDetails, + SocketEventTypes, + SourceType } from '../../../../store/types/deploy-application.types'; import { DeployApplicationStep3Component } from "./deploy-application-step3.component"; +vi.mock('rxjs-websockets', () => ({ default: () => NEVER })); + describe('DeployApplicationStep3Component', () => { let component: DeployApplicationStep3Component; let fixture: ComponentFixture; @@ -38,6 +45,17 @@ describe('DeployApplicationStep3Component', () => { await fixture.whenStable(); }); + // Enter the step with a complete git source, so the deployer connects + const enterConnected = async () => { + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.setSourceType({ id: 'giturl', name: 'Git URL' } as SourceType); + deployData.saveAppDetails({ projectName: 'p', branch: { name: 'main' } } as GitAppDetails, null); + deployData.saveAppOverrides({} as OverrideAppDetails); + component.onEnter(undefined as any); + await fixture.whenStable(); + }; + it('should create', () => { expect(component).toBeTruthy(); }); @@ -61,8 +79,7 @@ describe('DeployApplicationStep3Component', () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); - component.onEnter(undefined as any); - await fixture.whenStable(); + await enterConnected(); const deployer = component.deployer; const close = vi.spyOn(deployer, 'close'); deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); @@ -71,6 +88,8 @@ describe('DeployApplicationStep3Component', () => { // Leave the page mid-deploy, then let the deploy finish. fixture.destroy(); deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + expect(close).not.toHaveBeenCalled(); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); await TestBed.inject(ApplicationRef).whenStable(); expect(show).toHaveBeenCalledWith('Application deployment complete', 'View', 10000, true); @@ -81,8 +100,7 @@ describe('DeployApplicationStep3Component', () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); - component.onEnter(undefined as any); - await fixture.whenStable(); + await enterConnected(); const deployer = component.deployer; const close = vi.spyOn(deployer, 'close'); deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); @@ -95,4 +113,61 @@ describe('DeployApplicationStep3Component', () => { expect(show).toHaveBeenCalledWith(expect.stringContaining('Reason: boom'), 'Dismiss'); expect(close).toHaveBeenCalled(); }); + + it('reports a deploy that fails after the user leaves before the push starts', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); + + await enterConnected(); + const deployer = component.deployer; + const close = vi.spyOn(deployer, 'close'); + + // Leave during the clone, then let the deploy fail. + fixture.destroy(); + await TestBed.inject(ApplicationRef).whenStable(); + expect(close).not.toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_FAILED_CLONE, message: 'no repo' }); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(show).toHaveBeenCalledWith(expect.stringContaining('Reason: no repo'), 'Dismiss'); + expect(close).toHaveBeenCalled(); + }); + + it('closes a deploy whose connection never opened when the user leaves', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); + + // No deploy details yet, so the deployer is still waiting to connect; + // leaving clears them, so it never would. + component.onEnter(undefined as any); + await fixture.whenStable(); + const close = vi.spyOn(component.deployer, 'close'); + + fixture.destroy(); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(close).toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + }); + + it('closes a finished deploy when the user leaves', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); + + await enterConnected(); + const deployer = component.deployer; + const close = vi.spyOn(deployer, 'close'); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + await fixture.whenStable(); + + fixture.destroy(); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(close).toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + }); }); 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 aa724b5ca2..e0209a2cd7 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 @@ -185,17 +185,21 @@ export class DeployApplicationStep3Component implements OnDestroy { this.deployData.resetState(); this.destroyDeployer(); if (this.deployer) { - if (!this.deployer.deploying) { - this.deployer.close(); - } else { + // A deploy in flight (before or during the push) keeps running and + // reports its end + if (this.deployer.inFlight) { this.setupCompletionNotification(); + } else { + this.deployer.close(); } } } private setupCompletionNotification() { + // status$ replays its current value; wait for the reported close, which + // every end of a deploy (including a lost connection) produces this.deployer.status$.asObservable().pipe( - filter(status => !status.deploying), + filter(() => this.deployer.finished), take(1) ).subscribe(status => { if (status.error) { From 5b14868322613b2e40c62a8810440d11d19ebd5d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:34:18 -0700 Subject: [PATCH 049/121] fix(cf): open the app from a notice after leaving The deploy step looks up a new app's GUID once the push succeeds, but leaving the page cancelled that lookup. The completion notice for a first-time deploy then offered "View", and the click did nothing. The click now runs the same lookup when the GUID is not yet known and opens the app summary. --- ...deploy-application-step3.component.spec.ts | 53 ++++++++++++++++++- .../deploy-application-step3.component.ts | 42 +++++++++------ 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index ad5a000d92..6172f1bf17 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -3,8 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ApplicationRef, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; -import { NEVER } from 'rxjs'; +import { provideRouter, Router } from '@angular/router'; +import { NEVER, Subject } from 'rxjs'; import { generateCfBaseTestModulesNoShared } from "@test-framework/cf"; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; @@ -96,6 +96,55 @@ describe('DeployApplicationStep3Component', () => { expect(close).toHaveBeenCalled(); }); + it('looks up the app GUID once when the deploy finishes on the page', async () => { + await enterConnected(); + const deployer = component.deployer; + deployer.processWebSocketMessage({ + type: SocketEventTypes.MANIFEST, + message: JSON.stringify({ Applications: [{ Name: 'my-app' }] }), + }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + await fixture.whenStable(); + + const httpMock = TestBed.inject(HttpTestingController); + httpMock.expectOne('/pp/v1/cf/apps/cf?space_guids=space') + .flush({ resources: [{ name: 'my-app', guid: 'g1' }] }); + httpMock.verify(); + expect(component.appGuid).toBe('g1'); + }); + + it('opens a first-time deploy that finishes after the user leaves the page', async () => { + const snackBar = TestBed.inject(TailwindSnackBarService); + const action = new Subject(); + vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => action } as any); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + const httpMock = TestBed.inject(HttpTestingController); + + await enterConnected(); + const deployer = component.deployer; + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + deployer.processWebSocketMessage({ + type: SocketEventTypes.MANIFEST, + message: JSON.stringify({ Applications: [{ Name: 'my-app' }] }), + }); + await fixture.whenStable(); + + fixture.destroy(); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + await TestBed.inject(ApplicationRef).whenStable(); + // The lookup waits for the click + httpMock.expectNone('/pp/v1/cf/apps/cf?space_guids=space'); + + action.next(); + httpMock.expectOne('/pp/v1/cf/apps/cf?space_guids=space') + .flush({ resources: [{ name: 'other', guid: 'g0' }, { name: 'my-app', guid: 'g1' }] }); + + expect(navigate).toHaveBeenCalledWith(['applications', 'cf', 'g1'], undefined); + }); + it('reports a deploy that fails after the user leaves the page', async () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); 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 e0209a2cd7..ab0d3f2bb0 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 @@ -154,28 +154,30 @@ export class DeployApplicationStep3Component implements OnDestroy { filter(status => !status.deploying && !status.error), filter(() => !!this.deployer.appData?.Name && !this.appGuid), take(1), - switchMap(() => { - const appName = this.deployer.appData.Name; - const cfGuid = this.deployer.cfGuid; - const spaceGuid = this.deployer.spaceGuid; - // Native handler returns apps for the space; client-side filter - // for the just-deployed name. Single HTTP call replaces the - // legacy ngrx pagination that walked the space-apps list. - return this.http.get( - `/pp/v1/cf/apps/${cfGuid}?space_guids=${encodeURIComponent(spaceGuid)}`, - ).pipe( - map(resp => (resp?.resources ?? []).find((a: StApp) => a.name === appName)), - filter((app): app is StApp => !!app), - map(app => app.guid), - take(1), - ); - }), + switchMap(() => this.lookUpAppGuid()), ).subscribe(guid => { this.deployer.applicationGuid$.next(guid); }) ); } + private lookUpAppGuid(): Observable { + const appName = this.deployer.appData?.Name; + const cfGuid = this.deployer.cfGuid; + const spaceGuid = this.deployer.spaceGuid; + // Native handler returns apps for the space; client-side filter + // for the just-deployed name. Single HTTP call replaces the + // legacy ngrx pagination that walked the space-apps list. + return this.http.get( + `/pp/v1/cf/apps/${cfGuid}?space_guids=${encodeURIComponent(spaceGuid)}`, + ).pipe( + map(resp => (resp?.resources ?? []).find((a: StApp) => a.name === appName)), + filter((app): app is StApp => !!app), + map(app => app.guid), + take(1), + ); + } + private destroyDeployer() { safeUnsubscribe(...this.subscriptions); this.subscriptions = []; @@ -206,7 +208,13 @@ export class DeployApplicationStep3Component implements OnDestroy { this.snackBarService.show(status.errorMsg ?? 'Application deployment failed', 'Dismiss'); } else { const ref = this.snackBarService.show('Application deployment complete', 'View', 10000, true); - ref.onAction().subscribe(() => { this.goToAppSummary(); }); + // A first-time deploy left mid-way never looked up its app GUID + ref.onAction().pipe( + switchMap(() => this.appGuid ? observableOf(this.appGuid) : this.lookUpAppGuid()), + ).subscribe(guid => { + this.appGuid = guid; + this.goToAppSummary(); + }); } this.deployer.close(); }); From feb5f9ccc8be330d7e31451013e85260065eff65 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:37:33 -0700 Subject: [PATCH 050/121] test(cf): match only the app lookup in a title spec The manifest title spec asserted that no request at all was pending. The test bed always has an unrelated branding request in flight, so the check held only while that request happened to come later. It now matches the app GUID lookup it is about. --- .../deploy-application-step3.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 6172f1bf17..c24f3b606e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -72,7 +72,7 @@ describe('DeployApplicationStep3Component', () => { expect(fixture.nativeElement.querySelector('.font-semibold').textContent.trim()).toBe('Starting deployment...'); // The app GUID lookup waits for the push to finish - TestBed.inject(HttpTestingController).expectNone(() => true); + TestBed.inject(HttpTestingController).expectNone(req => req.url.startsWith('/pp/v1/cf/apps/')); }); it('reports a deploy that finishes after the user leaves the page', async () => { From 4a3ef5b6dd6062824d75b4f9d0b0e3a782aab6f1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 16:45:37 -0700 Subject: [PATCH 051/121] fix(cf): upload afresh when coming back to a step Going back to the source upload step and on again reused the old run. After a file deploy had ended, the deployer was still marked open, so the step neither reconnected nor uploaded, and the deploy step sent its acknowledgement on a socket the server had closed: the retry did nothing. After Previous mid-upload, the step kept the old progress and could count as done before the new upload began. Each entry now closes the old run and opens a new one, the deployer clears what the last deploy reported when it reopens, and a step with no transfer under way is not valid. --- .../deploy-application-deployer.spec.ts | 17 ++++ .../deploy-application-deployer.ts | 11 ++- ...ation-step-source-upload.component.spec.ts | 82 ++++++++++++++++++- ...pplication-step-source-upload.component.ts | 18 ++-- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index 0867c674e5..e3829925eb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -58,6 +58,23 @@ describe('DeployApplicationDeployer', () => { expect(deployer.status$().error).toBe(false); }); + + it('starts a reopened deploy afresh', () => { + const send = (type: SocketEventTypes, message = '') => socket.server.next(JSON.stringify({ type, message })); + send(SocketEventTypes.MANIFEST, JSON.stringify({ Applications: [{ Name: 'a' }] })); + send(SocketEventTypes.APP_GUID_NOTIFY, 'g1'); + send(SocketEventTypes.CLOSE_FAILURE, 'boom'); + + deployer.close(); + deployer.open(); + + expect(deployer.finished).toBe(false); + expect(deployer.status$()).toEqual({ error: false, errorMsg: undefined, deploying: false }); + expect(deployer.streamTitle()).toBe('Preparing...'); + expect(deployer.applicationGuid$()).toBeNull(); + expect(deployer.appData).toBeUndefined(); + deployer.close(); + }); }); it('keeps the specific failure when a generic close follows it', () => { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index e24962c3eb..3d76caa542 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -77,8 +77,8 @@ export class DeployApplicationDeployer { updateSub!: Subscription; msgSub!: Subscription; streamTitle = signal('Preparing...'); - // strict: populated from the MANIFEST websocket event before any read - appData!: AppData; + // From the MANIFEST websocket event; unset until the deploy sends it + appData?: AppData; proxyAPIVersion = environment.proxyAPIVersion; cfGuid!: string; orgGuid!: string; @@ -193,6 +193,13 @@ export class DeployApplicationDeployer { this.isOpen = true; this.closeReported = false; this.started = !this.fsFileInfo; + // A reopened deployer runs a new deploy: drop what the last one reported + this.deploying = false; + this.updateStatus(); + this.streamTitle.set('Preparing...'); + this.appData = undefined; + this.applicationGuid$.next(null); + this.fileTransferStatus$.next(undefined); this.connectSub = this.deployState$.pipe( filter((appDetail): appDetail is DeployApplicationState & { cloudFoundryDetails: NonNullable; } => !!appDetail && !!appDetail.cloudFoundryDetails && readyFilter(appDetail)), diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 9d99455c80..8f8eb029f8 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -5,18 +5,30 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; -import { NEVER } from 'rxjs'; +import { Observable, Subject, of } from 'rxjs'; import { CATALOGUE_ENTITIES, entityCatalog, generateStratosEntities, TestEntityCatalog } from '@stratosui/store'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCFEntities } from '../../../../cf-entity-generator'; import { CfDeployAppDataService } from '../../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; -import { OverrideAppDetails, SocketEventTypes } from '../../../../store/types/deploy-application.types'; +import { OverrideAppDetails, SocketEventTypes, SourceType } from '../../../../store/types/deploy-application.types'; import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStepSourceUploadComponent } from "./deploy-application-step-source-upload.component"; -vi.mock('rxjs-websockets', () => ({ default: () => NEVER })); +// Each connection gets a server stream the test drives; what the client +// sends is recorded +const socket = vi.hoisted(() => ({ server: undefined as any, connections: 0, sent: [] as unknown[] })); +vi.mock('rxjs-websockets', () => ({ + default: () => { + socket.connections++; + socket.server = new Subject(); + return of((input: Observable) => { + input.subscribe(m => socket.sent.push(m)); + return socket.server; + }); + }, +})); describe('DeployApplicationStepSourceUploadComponent', () => { let component: DeployApplicationStepSourceUploadComponent; @@ -100,6 +112,70 @@ describe('DeployApplicationStepSourceUploadComponent', () => { }); }); + describe('coming back to the step', () => { + const fromServer = (type: SocketEventTypes, message = '') => socket.server.next(JSON.stringify({ type, message })); + const oneFile = () => ({ root: { files: [new File(['x'], 'a.txt')] }, total: 1 } as unknown as FileScannerInfo); + let appRef: ApplicationRef; + + beforeEach(() => { + appRef = TestBed.inject(ApplicationRef); + socket.connections = 0; + socket.sent = []; + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); + }); + + // Upload the one file: the server asks for the source, then + // acknowledges the name and the data + const upload = async () => { + fromServer(SocketEventTypes.SOURCE_REQUIRED); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + await vi.waitFor(() => expect(socket.sent.some(m => m instanceof ArrayBuffer)).toBe(true)); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + }; + + it('uploads again after the deploy ended', async () => { + component.onEnter(oneFile()); + await appRef.whenStable(); + expect(socket.connections).toBe(1); + component.onNext(0, null as any).subscribe(); + component.deployer.deploy(); + fromServer(SocketEventTypes.CLOSE_FAILURE, 'boom'); + expect(component.deployer.finished).toBe(true); + + // Previous through the overrides step; the wizard passes no file info + component.onEnter(undefined as any); + await appRef.whenStable(); + + expect(socket.connections).toBe(2); + expect(component.deployer.finished).toBe(false); + expect(component.deployer.status$().error).toBe(false); + component.deployer.close(); + }); + + it('shows no earlier upload after Previous', async () => { + const valid: boolean[] = []; + component.valid$.subscribe(v => valid.push(v)); + component.onEnter(oneFile()); + await appRef.whenStable(); + await upload(); + expect(valid.at(-1)).toBe(true); + + component.onLeave(false); + component.onEnter(oneFile()); + await appRef.whenStable(); + + expect(component.deployer.fileTransferStatus$()).toBeUndefined(); + expect(valid.at(-1)).toBe(false); + // A new subscriber (the wizard's, say) sees no completed upload either + const later: boolean[] = []; + component.valid$.subscribe(v => later.push(v)).unsubscribe(); + expect(later).not.toContain(true); + component.deployer.close(); + }); + }); + it('keeps the deployer following the deploy state after the step is gone', async () => { // The deploy step keeps this deployer, and after the user leaves the // page mid-push it still waits on it for the completion notice diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts index 1c3708c3b5..1a99a089f3 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; import { Component, EnvironmentInjector, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; import { Observable, of as observableOf } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { BytesToHumanSize, StepOnNextFunction, UploadProgressIndicatorComponent } from '@stratosui/core'; import { CfOrgSpaceDataService } from '../../../../shared/data-services/cf-org-space-service.service'; -import { DeployApplicationDeployer, FileTransferStatus } from '../deploy-application-deployer'; +import { DeployApplicationDeployer } from '../deploy-application-deployer'; import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; @@ -37,9 +37,9 @@ export class DeployApplicationStepSourceUploadComponent implements OnDestroy { const injector = this.injector; this.deployer = new DeployApplicationDeployer(cfOrgSpaceService, injector); + // No transfer status (a new upload not yet under way) is not valid this.valid$ = this.deployer.fileTransferStatus$.asObservable().pipe( - filter(status => !!status), - map((status: FileTransferStatus) => status.filesSent === status.totalFiles), + map(status => !!status && status.filesSent === status.totalFiles), ); } @@ -51,9 +51,15 @@ export class DeployApplicationStepSourceUploadComponent implements OnDestroy { } onEnter = (data: FileScannerInfo) => { - // Previous step is expected to pass us the file info for the files to be uploaded - this.deployer.fsFileInfo = data; + // Previous step is expected to pass us the file info for the files to be uploaded. + // Coming back from a later step passes none; the files are the same. + if (data) { + this.deployer.fsFileInfo = data; + } + // Each entry uploads afresh. End what the deployer had first: a deploy + // that ended leaves it open on a socket the server has closed. + this.deployer.close(); // Begin the file upload this.deployer.open(); } From 5a8d37ec0ee55ce9217960e3f951f05e19b40cb1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:28:41 -0700 Subject: [PATCH 052/121] fix(cf): count each uploaded file only once The deployer counts a file as sent when the server acknowledges it, but it kept that file as the current transfer afterwards. A further acknowledgement after the last file counted it again, so the upload progress ran past the files and bytes of the source. The deployer now forgets a file once it has counted it. --- .../deploy-application-deployer.spec.ts | 38 ++++++++++++++++++- .../deploy-application-deployer.ts | 2 + 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index e3829925eb..1c801cd0c7 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -1,9 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApplicationRef, EnvironmentInjector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Subject, of } from 'rxjs'; -import { SocketEventTypes } from '../../../store/types/deploy-application.types'; +import { SocketEventTypes, SourceType } from '../../../store/types/deploy-application.types'; import { CfDeployAppDataService } from '../../../services/domain-data/cf-deploy-app-data.service'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; import { FileScannerInfo } from './deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; @@ -77,6 +77,40 @@ describe('DeployApplicationDeployer', () => { }); }); + describe('uploading files', () => { + let read: ReturnType; + + beforeEach(async () => { + // Each file reads at once + read = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer').mockImplementation(function (this: FileReader) { + this.onload?.({} as ProgressEvent); + }); + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); + deployer.fsFileInfo = { root: { files: [new File(['x'], 'a.txt')] }, total: 1 } as unknown as FileScannerInfo; + deployer.open(); + await TestBed.inject(ApplicationRef).whenStable(); + }); + + afterEach(() => { + read.mockRestore(); + deployer.close(); + }); + + it('counts the last file once when an extra acknowledgement follows', () => { + const send = (type: SocketEventTypes) => socket.server.next(JSON.stringify({ type, message: '' })); + send(SocketEventTypes.SOURCE_REQUIRED); + send(SocketEventTypes.SOURCE_FILE_ACK); + send(SocketEventTypes.SOURCE_FILE_ACK); + expect(deployer.fileTransferStatus$()?.filesSent).toBe(1); + + send(SocketEventTypes.SOURCE_FILE_ACK); + + expect(deployer.fileTransferStatus$()).toMatchObject({ filesSent: 1, totalFiles: 1, bytesSent: 1 }); + }); + }); + it('keeps the specific failure when a generic close follows it', () => { const reason = 'Failed due to no manifest.yml or manifest.yaml was found and no application name was given!'; deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_NO_MANIFEST, message: reason }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index 3d76caa542..13662c93a5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -463,6 +463,8 @@ export class DeployApplicationDeployer { this.fileTransferStatus.bytesSent += this.currentFileTransfer.size; this.fileTransferStatus.filesSent++; this.fileTransferStatus$.next(this.fileTransferStatus); + // Counted: a further acknowledgement must not count it again + this.currentFileTransfer = undefined; } if (this.fileTransfers.length > 0) { From 3c7074c7719815f0258b13a16edd82d34afda89a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:28:56 -0700 Subject: [PATCH 053/121] fix(cf): drop a file read from a closed upload A file is read before it is sent, and the read finishes later. When the user went back from the upload step and on again while a read was running, the read finished on the new run: it sent the old file data on the new connection and counted it as sent, so the new upload looked further on than it was. Closing the deployer now forgets its connection, and a read whose connection has closed sends nothing. What the deployer would send once closed is dropped rather than failing on the missing connection. --- .../deploy-application-deployer.spec.ts | 10 +++ .../deploy-application-deployer.ts | 19 +++-- ...ation-step-source-upload.component.spec.ts | 74 ++++++++++++++++++- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index 1c801cd0c7..245c819d76 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -59,6 +59,16 @@ describe('DeployApplicationDeployer', () => { expect(deployer.status$().error).toBe(false); }); + it('sends nothing once closed', () => { + deployer.close(); + + expect(() => { + deployer.deploy(); + deployer.processWebSocketMessage({ type: SocketEventTypes.OVERRIDES_REQUIRED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + }).not.toThrow(); + }); + it('starts a reopened deploy afresh', () => { const send = (type: SocketEventTypes, message = '') => socket.server.next(JSON.stringify({ type, message })); send(SocketEventTypes.MANIFEST, JSON.stringify({ Applications: [{ Name: 'a' }] })); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index 13662c93a5..8bc91e04bb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -173,6 +173,8 @@ export class DeployApplicationDeployer { } this.isOpen = false; this.currentFileTransfer = undefined; + // A file read still running belongs to this connection; see sendNextFile + this.inputStream = undefined; } open() { @@ -285,7 +287,7 @@ export class DeployApplicationDeployer { timestamp: Math.round((new Date()).getTime() / 1000), type: SocketEventTypes.SOURCE_WAIT_ACK }; - this.inputStream.next(JSON.stringify(msg)); + this.inputStream?.next(JSON.stringify(msg)); } sendAppOverride = (appOverrides: OverrideAppDetails) => { @@ -404,7 +406,7 @@ export class DeployApplicationDeployer { break; case SocketEventTypes.CLOSE_SUCCESS: // Acknowledge - this.inputStream.next(this.sendCloseAcknowledgement()); + this.inputStream?.next(this.sendCloseAcknowledgement()); this.onClose(log, null, null); break; case SocketEventTypes.CLOSE_INVALID_MANIFEST: @@ -437,13 +439,13 @@ export class DeployApplicationDeployer { this.onClose(log, 'Deploy Failed - Unknown source type', 'Failed to deploy the app - unknown source type'); } else { - this.inputStream.next(sourceInfo); + this.inputStream?.next(sourceInfo); } break; } case SocketEventTypes.OVERRIDES_REQUIRED: { const overrides = this.sendAppOverride(this.applicationOverrides); - this.inputStream.next(overrides); + this.inputStream?.next(overrides); break; } case SocketEventTypes.EVENT_CLONED: @@ -481,14 +483,19 @@ export class DeployApplicationDeployer { this.fileTransferStatus$.next(this.fileTransferStatus); // Send the file name metadata - this.inputStream.next(JSON.stringify(msg)); + this.inputStream?.next(JSON.stringify(msg)); // Now send the file data as a binary message + const stream = this.inputStream; const reader = new FileReader(); reader.onload = () => { + // The connection the read started for has since closed + if (stream !== this.inputStream) { + return; + } this.currentFileTransfer = file; const output = reader.result; - this.inputStream.next(output); + this.inputStream?.next(output); }; reader.readAsArrayBuffer(file); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 8f8eb029f8..092dda9f6e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ApplicationRef, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; @@ -17,14 +17,21 @@ import { FileScannerInfo } from '../deploy-application-step2/deploy-application- import { DeployApplicationStepSourceUploadComponent } from "./deploy-application-step-source-upload.component"; // Each connection gets a server stream the test drives; what the client -// sends is recorded -const socket = vi.hoisted(() => ({ server: undefined as any, connections: 0, sent: [] as unknown[] })); +// sends is recorded, in all and by connection +const socket = vi.hoisted(() => ({ + server: undefined as any, connections: 0, sent: [] as unknown[], sentOn: [] as unknown[][], +})); vi.mock('rxjs-websockets', () => ({ default: () => { socket.connections++; socket.server = new Subject(); return of((input: Observable) => { - input.subscribe(m => socket.sent.push(m)); + const sentHere: unknown[] = []; + socket.sentOn.push(sentHere); + input.subscribe(m => { + socket.sent.push(m); + sentHere.push(m); + }); return socket.server; }); }, @@ -121,6 +128,7 @@ describe('DeployApplicationStepSourceUploadComponent', () => { appRef = TestBed.inject(ApplicationRef); socket.connections = 0; socket.sent = []; + socket.sentOn = []; const deployData = TestBed.inject(CfDeployAppDataService); deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); @@ -174,6 +182,64 @@ describe('DeployApplicationStepSourceUploadComponent', () => { expect(later).not.toContain(true); component.deployer.close(); }); + + describe('with a file read still running', () => { + let reader: FileReader | undefined; + let read: ReturnType; + // The read the first upload started finishes now + const finishRead = () => { + Object.defineProperty(reader, 'result', { value: new ArrayBuffer(1) }); + reader!.onload!({} as ProgressEvent); + }; + + beforeEach(async () => { + read = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer').mockImplementation(function (this: FileReader) { + reader = this; + }); + component.onEnter(oneFile()); + await appRef.whenStable(); + fromServer(SocketEventTypes.SOURCE_REQUIRED); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + expect(reader).toBeDefined(); + }); + + afterEach(() => { + read.mockRestore(); + component.deployer.close(); + }); + + // Previous, then back: the upload starts again on a new connection + const reenter = () => { + component.onLeave(false); + component.onEnter(oneFile()); + }; + + // The new upload asks for the source and acknowledges the file name; + // the first file is not sent yet + const startNewUpload = () => { + expect(socket.connections).toBe(2); + fromServer(SocketEventTypes.SOURCE_REQUIRED); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + expect(socket.sentOn[1].some(m => m instanceof ArrayBuffer)).toBe(false); + expect(component.deployer.fileTransferStatus$()?.filesSent).toBe(0); + }; + + it('drops a read that finishes after the new connection opened', async () => { + reenter(); + await appRef.whenStable(); + finishRead(); + + startNewUpload(); + }); + + it('drops a read that finishes while the new connection opens', async () => { + reenter(); + finishRead(); + await appRef.whenStable(); + + startNewUpload(); + }); + }); }); it('keeps the deployer following the deploy state after the step is gone', async () => { From fa084980feae60e1b081bd8ecfeeba9f45964b4f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:29:10 -0700 Subject: [PATCH 054/121] fix(cf): look up the app of each new deploy The deploy step kept the app GUID that the first deploy looked up. After Previous to the overrides, a new deploy under another app name skipped the lookup. The step then let the user neither finish nor close it, and the View action of the notice shown after leaving opened the first app. The app given for a redeploy is now kept apart from the deployed app, and each entry starts again from it: a first deploy looks up its app again, and a redeploy keeps the app it was given. --- ...deploy-application-step3.component.spec.ts | 48 +++++++++++++++++++ .../deploy-application-step3.component.ts | 9 +++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index c24f3b606e..c729561f3c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -115,6 +115,54 @@ describe('DeployApplicationStep3Component', () => { expect(component.appGuid).toBe('g1'); }); + describe('deploying again after Previous', () => { + const appsUrl = '/pp/v1/cf/apps/cf?space_guids=space'; + + // The deploy of the named app succeeds on the page + const succeed = async (name: string) => { + const deployer = component.deployer; + deployer.processWebSocketMessage({ + type: SocketEventTypes.MANIFEST, + message: JSON.stringify({ Applications: [{ Name: name }] }), + }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + await fixture.whenStable(); + }; + + it('looks up the app of the new deploy', async () => { + const httpMock = TestBed.inject(HttpTestingController); + await enterConnected(); + await succeed('my-app'); + httpMock.expectOne(appsUrl).flush({ resources: [{ name: 'my-app', guid: 'g1' }] }); + expect(component.appGuid).toBe('g1'); + + // Back to the overrides, where the user renames the app + component.onEnter(undefined as any); + await fixture.whenStable(); + expect(component.appGuid).toBeFalsy(); + await succeed('renamed'); + + httpMock.expectOne(appsUrl).flush({ resources: [{ name: 'my-app', guid: 'g1' }, { name: 'renamed', guid: 'g2' }] }); + expect(component.appGuid).toBe('g2'); + }); + + it('keeps the app given for a redeploy', async () => { + const httpMock = TestBed.inject(HttpTestingController); + fixture.componentRef.setInput('appGuid', 'r1'); + await enterConnected(); + await succeed('my-app'); + + component.onEnter(undefined as any); + await fixture.whenStable(); + await succeed('my-app'); + + httpMock.expectNone(appsUrl); + expect(component.appGuid).toBe('r1'); + }); + }); + it('opens a first-time deploy that finishes after the user leaves the page', async () => { const snackBar = TestBed.inject(TailwindSnackBarService); const action = new Subject(); 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 ab0d3f2bb0..9478743d75 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 @@ -45,7 +45,12 @@ export class DeployApplicationStep3Component implements OnDestroy { private http = inject(HttpClient); - @Input() appGuid!: string; + // The app being redeployed; none on a first deploy + @Input('appGuid') redeployAppGuid?: string; + + // The deployed app: the redeployed one, or the one a first deploy + // looked up. Each deploy starts again from the input. + appGuid?: string; // Stable BehaviorSubjects back the public Observable fields. The field // references (valid$/closeable$/showOverlay$) are created once and never @@ -237,6 +242,8 @@ export class DeployApplicationStep3Component implements OnDestroy { this.showOverlaySubject.next(false); this.disablePreviousSubject.next(true); this.error.set(false); + // A retry of a first deploy may deploy another app; look it up again + this.appGuid = this.redeployAppGuid; // If we were passed data, then we came from the File System step if (fsDeployer) { From 9d9db2e49cdc135d2b91d2bf7d15d5146338f313 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:29:25 -0700 Subject: [PATCH 055/121] fix(cf): keep Previous locked until a deploy ends Previous was locked only while the push ran. During the clone, the manifest fetch or the upload, the user could go back and leave a deploy running behind the wizard. Previous now stays locked from entry until the deployer reports the end of the deploy, and the step header shows the step busy for the same time. So that the lock never traps the user, a deploy whose details are missing, such as a file source entered again without its upload, now ends with a source missing error rather than waiting for details that nothing saves. --- .../deploy-application-deployer.ts | 11 ++- ...deploy-application-step3.component.spec.ts | 93 +++++++++++++++++-- .../deploy-application-step3.component.ts | 9 +- .../deploy-application.component.ts | 7 +- 4 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index 8bc91e04bb..a24d6bdf1b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -203,9 +203,18 @@ export class DeployApplicationDeployer { this.applicationGuid$.next(null); this.fileTransferStatus$.next(undefined); this.connectSub = this.deployState$.pipe( + // The steps before save every detail the deploy needs, and nothing + // saves them later, so missing ones never arrive: report the end + // rather than wait for ever with the deploy step locked + take(1), + tap(appDetail => { + if (!appDetail?.cloudFoundryDetails || !readyFilter(appDetail)) { + this.onClose({}, 'Deploy Failed - Source missing!', + 'Failed to deploy app! Go back through the source steps and deploy again.'); + } + }), filter((appDetail): appDetail is DeployApplicationState & { cloudFoundryDetails: NonNullable; } => !!appDetail && !!appDetail.cloudFoundryDetails && readyFilter(appDetail)), - take(1), tap((appDetail) => { this.cfGuid = appDetail.cloudFoundryDetails.cloudFoundry; this.orgGuid = appDetail.cloudFoundryDetails.org; diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index c729561f3c..b45b0a07c2 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ApplicationRef, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; +import { ApplicationRef, EnvironmentInjector, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter, Router } from '@angular/router'; -import { NEVER, Subject } from 'rxjs'; +import { NEVER, Subject, of } from 'rxjs'; import { generateCfBaseTestModulesNoShared } from "@test-framework/cf"; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; @@ -16,9 +16,18 @@ import { OverrideAppDetails, SocketEventTypes, SourceType } from '../../../../store/types/deploy-application.types'; +import { DeployApplicationDeployer } from '../deploy-application-deployer'; +import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStep3Component } from "./deploy-application-step3.component"; -vi.mock('rxjs-websockets', () => ({ default: () => NEVER })); +// Each connection gets a server stream the test drives +const socket = vi.hoisted(() => ({ server: undefined as any })); +vi.mock('rxjs-websockets', () => ({ + default: () => { + socket.server = new Subject(); + return of(() => socket.server); + }, +})); describe('DeployApplicationStep3Component', () => { let component: DeployApplicationStep3Component; @@ -61,8 +70,7 @@ describe('DeployApplicationStep3Component', () => { }); it('shows the manifest title before the push starts', async () => { - component.onEnter(undefined as any); - await fixture.whenStable(); + await enterConnected(); const deployer = component.deployer; deployer.processWebSocketMessage({ type: SocketEventTypes.MANIFEST, @@ -115,6 +123,76 @@ describe('DeployApplicationStep3Component', () => { expect(component.appGuid).toBe('g1'); }); + describe('the Previous button', () => { + let disabled: boolean[]; + const locked = () => disabled.at(-1); + const fromServer = (type: SocketEventTypes, message = '') => component.deployer.processWebSocketMessage({ type, message }); + + beforeEach(() => { + disabled = []; + component.disablePrevious$.subscribe(d => disabled.push(d)); + }); + + it('stays locked from entry until the deploy ends', async () => { + await enterConnected(); + expect(locked()).toBe(true); + + fromServer(SocketEventTypes.MANIFEST, JSON.stringify({ Applications: [{ Name: 'a' }] })); + expect(locked()).toBe(true); + fromServer(SocketEventTypes.EVENT_PUSH_STARTED); + fromServer(SocketEventTypes.EVENT_PUSH_COMPLETED); + expect(locked()).toBe(true); + fromServer(SocketEventTypes.CLOSE_SUCCESS); + + expect(locked()).toBe(false); + }); + + it('unlocks when the deploy fails before the push', async () => { + await enterConnected(); + fromServer(SocketEventTypes.CLOSE_FAILED_CLONE, 'no repo'); + + expect(locked()).toBe(false); + }); + + it('unlocks when the connection is lost', async () => { + await enterConnected(); + socket.server.complete(); + + expect(component.deployer.finished).toBe(true); + expect(locked()).toBe(false); + }); + + it('locks a file deploy from entry', async () => { + // The upload step's deployer, uploaded and handed on + TestBed.inject(CfDeployAppDataService).setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + const deployer = new DeployApplicationDeployer(TestBed.inject(CfOrgSpaceDataService), TestBed.inject(EnvironmentInjector)); + deployer.fsFileInfo = {} as FileScannerInfo; + deployer.open(); + await TestBed.inject(ApplicationRef).whenStable(); + component.onEnter(deployer); + await fixture.whenStable(); + expect(locked()).toBe(true); + + fromServer(SocketEventTypes.CLOSE_PUSH_ERROR, 'boom'); + expect(locked()).toBe(false); + }); + + it('unlocks a deploy whose details are missing', async () => { + // A file deploy entered again: the wizard hands on the upload + // step's deployer only once, so there is no source to send + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); + deployData.saveAppOverrides({} as OverrideAppDetails); + component.onEnter(undefined as any); + await fixture.whenStable(); + + expect(component.deployer.finished).toBe(true); + expect(component.deployer.status$().error).toBeTruthy(); + expect(locked()).toBe(false); + }); + }); + describe('deploying again after Previous', () => { const appsUrl = '/pp/v1/cf/apps/cf?space_guids=space'; @@ -236,10 +314,9 @@ describe('DeployApplicationStep3Component', () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); - // No deploy details yet, so the deployer is still waiting to connect; - // leaving clears them, so it never would. + // Leave before the deployer has read the deploy details, so it is + // still waiting to connect; leaving clears them, so it never would. component.onEnter(undefined as any); - await fixture.whenStable(); const close = vi.spyOn(component.deployer, 'close'); fixture.destroy(); 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 9478743d75..7c51e49481 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 @@ -128,10 +128,11 @@ export class DeployApplicationStep3Component implements OnDestroy { this.subscriptions.push( status$.subscribe(status => { this.busy = status.deploying; - // Mirror busy state to disablePrevious so that when a deploy ends - // (error or success) the Previous button unlocks and the user - // isn't trapped on the step. - this.disablePreviousSubject.next(status.deploying); + // Going back would abandon the deploy, so Previous stays locked + // until it ends (error or success), not only while it pushes. + // Every end, a lost connection or missing details included, is + // reported, so the user isn't trapped on the step. + this.disablePreviousSubject.next(!this.deployer.finished); }) ); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts index 54cb33e8c6..9f84427ee4 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts @@ -367,10 +367,9 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { }, canClose: this.step3Closeable.asReadonly(), disablePrevious: this.step3DisablePrevious.asReadonly(), - // step3.busy and step3.disablePrevious$ are both fed by the same - // `status.deploying` signal inside step 3 (see lines ~123-128 of - // deploy-application-step3.component.ts), so we derive showBusy - // from the same local signal rather than polling step3.busy. + // step3.disablePrevious$ is true from entry until the deploy ends, + // which is also when the step header should show it busy, so + // showBusy reuses that signal rather than polling step3.busy. showBusy: this.step3DisablePrevious.asReadonly(), cancelButtonText: signal('Close').asReadonly(), finishButtonText: signal('Go to App Summary').asReadonly(), From aa2e33f69bdec2b09e86f283a6ed042c9c9e3ff4 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:29:51 -0700 Subject: [PATCH 056/121] fix(core): start a new log for a new stream The log viewer kept the lines it had shown when it was given another stream, and lines of the old stream still waiting in its buffer were shown after the switch. A view that gave the viewer a new log showed the old one above it. A different stream now clears the viewer and drops the buffered lines of the old one; the same stream given again keeps its lines. --- .../log-viewer/log-viewer.component.spec.ts | 36 +++++++++++++++++++ .../log-viewer/log-viewer.component.ts | 20 +++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.spec.ts b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.spec.ts index 558145eeb6..a930c705c8 100644 --- a/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/log-viewer/log-viewer.component.spec.ts @@ -74,6 +74,42 @@ describe('LogViewerComponent', () => { expect(contentEl.children[0].children[0].innerHTML).toEqual(logText); }, 3000); + describe('when the log stream changes', () => { + const shown = async (text: string) => vi.waitFor(() => expect(contentEl.textContent).toContain(text)); + + it('clears the lines of the previous stream', async () => { + stream.next('old'); + await shown('old'); + + const next = new Subject(); + component.logViewer.logStream = next; + expect(contentEl.children.length).toEqual(0); + next.next('new'); + await shown('new'); + + expect(contentEl.textContent).not.toContain('old'); + }); + + it('drops lines of the previous stream it had not shown yet', async () => { + stream.next('stale'); + const next = new Subject(); + component.logViewer.logStream = next; + next.next('new'); + await shown('new'); + + expect(contentEl.textContent).not.toContain('stale'); + }); + + it('keeps the lines when given the same stream again', async () => { + stream.next('kept'); + await shown('kept'); + + component.logViewer.logStream = stream; + + expect(contentEl.textContent).toContain('kept'); + }); + }); + it('should only allow max rows', async () => { expect(contentEl.children.length).toEqual(0); 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 3c82a12a07..5c9c81de87 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 @@ -60,9 +60,14 @@ export class LogViewerComponent implements OnInit, OnDestroy { private logStreamSubject = new BehaviorSubject>(NEVER); + // A different stream is a new log: what the last one showed goes @Input() set logStream(stream: Observable) { - this.logStreamSubject.next(stream || NEVER); + const next = stream || NEVER; + if (next !== this.logStreamSubject.value) { + this.clear(); + } + this.logStreamSubject.next(next); } @ViewChild('container', { static: true }) container!: ElementRef; @@ -101,8 +106,10 @@ export class LogViewerComponent implements OnInit, OnDestroy { this.stopped$, this.logStreamSubject.asObservable() ]).pipe( + // Each line keeps its stream, so lines of a replaced stream still + // waiting in the buffer are dropped switchMap( - ([stopped, stream]) => (stopped ? NEVER : stream) + ([stopped, stream]) => (stopped ? NEVER : stream.pipe(map(log => ({ stream, log })))) ), share() ); @@ -226,6 +233,7 @@ export class LogViewerComponent implements OnInit, OnDestroy { const addedLogs$ = stoppableLogStream$.pipe( buffer(buffer$)) .pipe( + map(lines => lines.filter(line => line.stream === this.logStreamSubject.value).map(line => line.log)), filter(log => !!log.length), // Apply filter to messages if applicable map(logs => logs.map(log => { @@ -360,6 +368,14 @@ export class LogViewerComponent implements OnInit, OnDestroy { } } + private clear() { + // Not there yet when the first stream is set + if (this.content) { + this.content.nativeElement.textContent = ''; + } + this.logLinesCount = 0; + } + private binElement() { const toRemove = this.content.nativeElement.firstChild; if (!toRemove) { From e3a01ee866edbd3dc84e843d1fff71ca06848f5e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:30:05 -0700 Subject: [PATCH 057/121] fix(cf): start an empty log for each deploy run A file deploy entered again keeps the upload step's deployer, and with it the same message stream, so the deploy step showed the log of the earlier run above the new one. Each open of the deployer now starts a new message stream, which the log viewer takes as a new log. --- .../deploy-application-deployer.ts | 2 + ...deploy-application-step3.component.spec.ts | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index a24d6bdf1b..6e1e8c7ff7 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -202,6 +202,8 @@ export class DeployApplicationDeployer { this.appData = undefined; this.applicationGuid$.next(null); this.fileTransferStatus$.next(undefined); + // A new stream per deploy: the log viewer starts a new log for it + this.messages = createSignalWrapper('', alwaysNotify); this.connectSub = this.deployState$.pipe( // The steps before save every detail the deploy needs, and nothing // saves them later, so missing ones never arrive: report the end diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index b45b0a07c2..93324368aa 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -241,6 +241,49 @@ describe('DeployApplicationStep3Component', () => { }); }); + describe('the log of a retry', () => { + const logText = (): string => fixture.nativeElement.querySelector('.log-scroller__content').textContent; + const log = async (line: string) => { + component.deployer.messages.next(`${line}\n`); + await vi.waitFor(() => expect(logText()).toContain(line)); + }; + + it('starts empty for a git deploy', async () => { + await enterConnected(); + await log('first run'); + + component.onEnter(undefined as any); + await fixture.whenStable(); + await log('second run'); + + expect(logText()).not.toContain('first run'); + }); + + it('starts empty for a file deploy', async () => { + // The upload step's deployer, uploaded and handed on + const appRef = TestBed.inject(ApplicationRef); + TestBed.inject(CfDeployAppDataService).setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + const deployer = new DeployApplicationDeployer(TestBed.inject(CfOrgSpaceDataService), TestBed.inject(EnvironmentInjector)); + deployer.fsFileInfo = {} as FileScannerInfo; + deployer.open(); + await appRef.whenStable(); + component.onEnter(deployer); + await fixture.whenStable(); + await log('first run'); + + // Back to the upload step, which uploads again on the same deployer + deployer.close(); + deployer.open(); + await appRef.whenStable(); + component.onEnter(deployer); + await fixture.whenStable(); + await log('second run'); + + expect(logText()).not.toContain('first run'); + deployer.close(); + }); + }); + it('opens a first-time deploy that finishes after the user leaves the page', async () => { const snackBar = TestBed.inject(TailwindSnackBarService); const action = new Subject(); From 9ced56568f370df694bb48dd855bd01b9bfa84e9 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:30:20 -0700 Subject: [PATCH 058/121] fix(cf): upload again when retrying a file deploy After a file deploy ended, Previous to the overrides and Next again gave the deploy step no deployer: the wizard handed on the upload step's deployer on the first entry only. The retry had no source to send and failed. The wizard now hands on the same deployer on every entry until the source is chosen again. The deploy step closes a deployer whose deploy has ended, uploads the same files again on a new connection, and tells the server to deploy only once it has acknowledged the last file. --- ...deploy-application-step3.component.spec.ts | 61 +++++++++++++++++-- .../deploy-application-step3.component.ts | 17 +++++- .../deploy-application.component.spec.ts | 22 +++++++ .../deploy-application.component.ts | 11 ++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 93324368aa..26ba6d89e3 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -4,7 +4,7 @@ import { ApplicationRef, EnvironmentInjector, importProvidersFrom, provideZonele import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter, Router } from '@angular/router'; -import { NEVER, Subject, of } from 'rxjs'; +import { NEVER, Observable, Subject, of } from 'rxjs'; import { generateCfBaseTestModulesNoShared } from "@test-framework/cf"; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; @@ -20,12 +20,19 @@ import { DeployApplicationDeployer } from '../deploy-application-deployer'; import { FileScannerInfo } from '../deploy-application-step2/deploy-application-fs/deploy-application-fs-scanner'; import { DeployApplicationStep3Component } from "./deploy-application-step3.component"; -// Each connection gets a server stream the test drives -const socket = vi.hoisted(() => ({ server: undefined as any })); +// Each connection gets a server stream the test drives; what the client +// sends is recorded by connection +const socket = vi.hoisted(() => ({ server: undefined as any, connections: 0, sentOn: [] as unknown[][] })); vi.mock('rxjs-websockets', () => ({ default: () => { + socket.connections++; socket.server = new Subject(); - return of(() => socket.server); + return of((input: Observable) => { + const sent: unknown[] = []; + socket.sentOn.push(sent); + input.subscribe(m => sent.push(m)); + return socket.server; + }); }, })); @@ -178,8 +185,8 @@ describe('DeployApplicationStep3Component', () => { }); it('unlocks a deploy whose details are missing', async () => { - // A file deploy entered again: the wizard hands on the upload - // step's deployer only once, so there is no source to send + // A file source without the upload step's deployer has no source + // to send const deployData = TestBed.inject(CfDeployAppDataService); deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); @@ -193,6 +200,48 @@ describe('DeployApplicationStep3Component', () => { }); }); + it('uploads again before deploying a file deploy entered again', async () => { + const appRef = TestBed.inject(ApplicationRef); + const fromServer = (type: SocketEventTypes, message = '') => socket.server.next(JSON.stringify({ type, message })); + const sentTypes = (connection: number) => socket.sentOn[connection] + .filter((m): m is string => typeof m === 'string').map(m => JSON.parse(m).type); + const deployData = TestBed.inject(CfDeployAppDataService); + deployData.setCfDetails({ cloudFoundry: 'cf', org: 'org', space: 'space' }); + deployData.setSourceType({ id: 'file', name: 'File' } as SourceType); + deployData.saveAppOverrides({} as OverrideAppDetails); + socket.connections = 0; + socket.sentOn = []; + + // The upload step uploads the file and hands the deployer on + const deployer = new DeployApplicationDeployer(TestBed.inject(CfOrgSpaceDataService), TestBed.inject(EnvironmentInjector)); + deployer.fsFileInfo = { root: { files: [new File(['x'], 'a.txt')] }, total: 1 } as unknown as FileScannerInfo; + deployer.open(); + await appRef.whenStable(); + fromServer(SocketEventTypes.SOURCE_REQUIRED); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + await vi.waitFor(() => expect(socket.sentOn[0].some(m => m instanceof ArrayBuffer)).toBe(true)); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + component.onEnter(deployer); + await fixture.whenStable(); + fromServer(SocketEventTypes.CLOSE_FAILURE, 'boom'); + + // Previous to the overrides, then Next: the wizard hands it on again + component.onEnter(deployer); + await fixture.whenStable(); + expect(socket.connections).toBe(2); + fromServer(SocketEventTypes.SOURCE_REQUIRED); + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + await vi.waitFor(() => expect(socket.sentOn[1].some(m => m instanceof ArrayBuffer)).toBe(true)); + expect(sentTypes(1)).not.toContain(SocketEventTypes.SOURCE_WAIT_ACK); + + // The server acknowledges the last file; only now may the deploy go on + fromServer(SocketEventTypes.SOURCE_FILE_ACK); + expect(sentTypes(1).at(-1)).toBe(SocketEventTypes.SOURCE_WAIT_ACK); + expect(sentTypes(1).filter(t => t === SocketEventTypes.SOURCE_WAIT_ACK).length).toBe(1); + expect(component.deployer.finished).toBe(false); + deployer.close(); + }); + describe('deploying again after Previous', () => { const appsUrl = '/pp/v1/cf/apps/cf?space_guids=space'; 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 7c51e49481..b825b0e72c 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 @@ -253,10 +253,25 @@ export class DeployApplicationStep3Component implements OnDestroy { this.deployer = new DeployApplicationDeployer(this.cfOrgSpaceService, this.injector); } + // A file deploy entered again after it ended: its connection is over, + // so upload the same files again on a new one + const reupload = !!fsDeployer && fsDeployer.finished; + if (reupload) { + fsDeployer.close(); + } + this.initDeployer(); // Start deploying this.deployer.open(); - if (fsDeployer) { + if (reupload) { + // The server reads the go-ahead only after its ack of the last file + this.subscriptions.push( + this.deployer.fileTransferStatus$.asObservable().pipe( + filter(status => !!status && status.filesSent === status.totalFiles), + take(1), + ).subscribe(() => this.deployer.deploy()) + ); + } else if (fsDeployer) { // Ask the existing deployer to continue deploying this.deployer.deploy(); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts index fbbc308304..a4dde6474e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts @@ -119,6 +119,28 @@ describe('DeployApplicationComponent', () => { expect((component as any).pendingFsFileInfo).toBeUndefined(); }); + // A file deploy that ended can be deployed again from the overrides step: + // the deploy step gets the upload deployer on every entry, until the + // source is chosen again + it('hands the upload deployer to every entry of the deploy step', async () => { + const deployer = {}; + const mockStep3 = { onEnter: vi.fn() }; + (component as any)._step2_2 = { onNext: vi.fn().mockReturnValue(of({ success: true, data: deployer })) }; + (component as any)._step3 = mockStep3; + await component.step2_2Handle.submit!(); + + component.step3Handle.onEnter!(); + // Previous to the overrides, then Next + component.step3Handle.onEnter!(); + expect(mockStep3.onEnter).toHaveBeenNthCalledWith(2, deployer); + + // Back to the source step, which now saves a git source + (component as any)._step2 = { onNext: vi.fn().mockReturnValue(of({ success: true, data: undefined })) }; + await component.step2Handle.submit!(); + component.step3Handle.onEnter!(); + expect(mockStep3.onEnter).toHaveBeenLastCalledWith(undefined); + }); + // Regression guard for the "Source Config shows no options / Next disabled" // bug. step2_1 (the git commit picker) is rendered via [hidden], so its // @ViewChild fires at parent view-init — before step 2 has saved any diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts index 9f84427ee4..894f6ec51d 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts @@ -298,6 +298,9 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { // (file-upload path only — git-url/docker source paths leave data // undefined and step2_2 isn't navigated to). this.pendingFsFileInfo = result.data; + // A source chosen again needs its own upload: a file source passes + // through step 2_2, which hands on a deployer; others need none + this.pendingDeployer = undefined; }, }; @@ -360,11 +363,9 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { // `pendingDeployer === undefined`. Source types that bypass step 2_2 // (git-url, docker-image) leave pendingDeployer undefined and step 3 // instantiates a fresh deployer. - onEnter: () => { - const deployer = this.pendingDeployer; - this.pendingDeployer = undefined; - this._step3?.onEnter(deployer as any); - }, + // The deployer is kept: entering again after a file deploy ended (via + // Previous to the overrides) uploads the same files again and deploys. + onEnter: () => this._step3?.onEnter(this.pendingDeployer as any), canClose: this.step3Closeable.asReadonly(), disablePrevious: this.step3DisablePrevious.asReadonly(), // step3.disablePrevious$ is true from entry until the deploy ends, From b86d589e477e74250c0b9aaa6a64e730e5cca9de Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 19:30:35 -0700 Subject: [PATCH 059/121] fix(cf): allow closing when the app is not found When a deploy succeeded but the space had no app of the deployed name, the app lookup never emitted. The step then offered neither Close nor Go to App Summary, and nothing told the user why. The lookup now reports that no app was found; the step says the app was deployed but could not be found in the space, and can be closed. --- ...deploy-application-step3.component.spec.ts | 18 +++++++++++++++ .../deploy-application-step3.component.ts | 23 +++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 26ba6d89e3..5954193cd0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -275,6 +275,24 @@ describe('DeployApplicationStep3Component', () => { expect(component.appGuid).toBe('g2'); }); + it('can be closed when the deployed app is not found', async () => { + const show = vi.spyOn(TestBed.inject(TailwindSnackBarService), 'show').mockReturnValue({ onAction: () => NEVER } as any); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + let closeable: boolean | undefined; + let valid: boolean | undefined; + component.closeable$.subscribe(c => closeable = c); + component.valid$.subscribe(v => valid = v); + await enterConnected(); + await succeed('my-app'); + + TestBed.inject(HttpTestingController).expectOne(appsUrl).flush({ resources: [{ name: 'other', guid: 'g0' }] }); + + expect(closeable).toBe(true); + expect(valid).toBe(false); + expect(show).toHaveBeenCalledWith('Application my-app was deployed but could not be found in the space', 'Dismiss'); + expect(navigate).not.toHaveBeenCalled(); + }); + it('keeps the app given for a redeploy', async () => { const httpMock = TestBed.inject(HttpTestingController); fixture.componentRef.setInput('appGuid', 'r1'); 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 b825b0e72c..8549cadd87 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 @@ -66,6 +66,9 @@ export class DeployApplicationStep3Component implements OnDestroy { // false on terminal states (success or error) so the user can go back // and fix inputs when the deploy fails. private disablePreviousSubject = new BehaviorSubject(true); + // A deployed app the lookup did not find: there is no app to go to, but + // the deploy is over, so the step can be closed + private appNotFoundSubject = new BehaviorSubject(false); readonly valid$: Observable = this.validSubject.asObservable(); readonly closeable$: Observable = this.closeableSubject.asObservable(); @@ -120,8 +123,8 @@ export class DeployApplicationStep3Component implements OnDestroy { ); this.subscriptions.push( - observableCombineLatest(this.validSubject, status$).pipe( - map(([validated, status]) => validated || status.error) + observableCombineLatest(this.validSubject, status$, this.appNotFoundSubject).pipe( + map(([validated, status, appNotFound]) => validated || status.error || appNotFound) ).subscribe(v => this.closeableSubject.next(v)) ); @@ -162,12 +165,19 @@ export class DeployApplicationStep3Component implements OnDestroy { take(1), switchMap(() => this.lookUpAppGuid()), ).subscribe(guid => { - this.deployer.applicationGuid$.next(guid); + if (guid) { + this.deployer.applicationGuid$.next(guid); + } else { + this.appNotFoundSubject.next(true); + this.snackBarService.show( + `Application ${this.deployer.appData?.Name} was deployed but could not be found in the space`, 'Dismiss'); + } }) ); } - private lookUpAppGuid(): Observable { + // Emits undefined when the space has no app of that name + private lookUpAppGuid(): Observable { const appName = this.deployer.appData?.Name; const cfGuid = this.deployer.cfGuid; const spaceGuid = this.deployer.spaceGuid; @@ -177,9 +187,7 @@ export class DeployApplicationStep3Component implements OnDestroy { return this.http.get( `/pp/v1/cf/apps/${cfGuid}?space_guids=${encodeURIComponent(spaceGuid)}`, ).pipe( - map(resp => (resp?.resources ?? []).find((a: StApp) => a.name === appName)), - filter((app): app is StApp => !!app), - map(app => app.guid), + map(resp => (resp?.resources ?? []).find((a: StApp) => a.name === appName)?.guid), take(1), ); } @@ -242,6 +250,7 @@ export class DeployApplicationStep3Component implements OnDestroy { this.closeableSubject.next(false); this.showOverlaySubject.next(false); this.disablePreviousSubject.next(true); + this.appNotFoundSubject.next(false); this.error.set(false); // A retry of a first deploy may deploy another app; look it up again this.appGuid = this.redeployAppGuid; From 2a734afe089a1a45b3e4411b0d2b01d66e36ad8e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:11:18 -0700 Subject: [PATCH 060/121] fix(cf): close and report a failed app lookup After a successful deploy the step looks up the deployed app by name. When that request failed, the error went unhandled: the step offered neither Close nor Go to App Summary, and nothing told the user why. The step now says that the app was deployed but looking it up in the space failed, and can be closed. The View action on the message for a deploy that finishes after the user has left had the same gaps: it did nothing when the app was not found, and left the error unhandled when the lookup failed. It now shows the not found or lookup failed message instead of opening the app. --- ...deploy-application-step3.component.spec.ts | 62 +++++++++++++++++++ .../deploy-application-step3.component.ts | 46 ++++++++++---- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 5954193cd0..cb8a392ef1 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -72,6 +72,28 @@ describe('DeployApplicationStep3Component', () => { await fixture.whenStable(); }; + // A first deploy of my-app that the user leaves mid-way; it then succeeds + // and the user clicks View on the snackbar it shows + const viewAfterLeaving = async () => { + const action = new Subject(); + const show = vi.spyOn(TestBed.inject(TailwindSnackBarService), 'show').mockReturnValue({ onAction: () => action } as any); + await enterConnected(); + const deployer = component.deployer; + deployer.processWebSocketMessage({ + type: SocketEventTypes.MANIFEST, + message: JSON.stringify({ Applications: [{ Name: 'my-app' }] }), + }); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_STARTED, message: '' }); + await fixture.whenStable(); + + fixture.destroy(); + deployer.processWebSocketMessage({ type: SocketEventTypes.EVENT_PUSH_COMPLETED, message: '' }); + deployer.processWebSocketMessage({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' }); + await TestBed.inject(ApplicationRef).whenStable(); + action.next(); + return show; + }; + it('should create', () => { expect(component).toBeTruthy(); }); @@ -293,6 +315,24 @@ describe('DeployApplicationStep3Component', () => { expect(navigate).not.toHaveBeenCalled(); }); + it('can be closed when the lookup of the deployed app fails', async () => { + const show = vi.spyOn(TestBed.inject(TailwindSnackBarService), 'show').mockReturnValue({ onAction: () => NEVER } as any); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + let closeable: boolean | undefined; + let valid: boolean | undefined; + component.closeable$.subscribe(c => closeable = c); + component.valid$.subscribe(v => valid = v); + await enterConnected(); + await succeed('my-app'); + + TestBed.inject(HttpTestingController).expectOne(appsUrl).flush('boom', { status: 500, statusText: 'Server Error' }); + + expect(closeable).toBe(true); + expect(valid).toBe(false); + expect(show).toHaveBeenCalledWith('Application my-app was deployed but looking it up in the space failed', 'Dismiss'); + expect(navigate).not.toHaveBeenCalled(); + }); + it('keeps the app given for a redeploy', async () => { const httpMock = TestBed.inject(HttpTestingController); fixture.componentRef.setInput('appGuid', 'r1'); @@ -381,6 +421,17 @@ describe('DeployApplicationStep3Component', () => { expect(navigate).toHaveBeenCalledWith(['applications', 'cf', 'g1'], undefined); }); + it('reports on View that the app of a deploy left mid-way is not found', async () => { + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + const show = await viewAfterLeaving(); + + TestBed.inject(HttpTestingController).expectOne('/pp/v1/cf/apps/cf?space_guids=space') + .flush({ resources: [{ name: 'other', guid: 'g0' }] }); + + expect(show).toHaveBeenCalledWith('Application my-app was deployed but could not be found in the space', 'Dismiss'); + expect(navigate).not.toHaveBeenCalled(); + }); + it('reports a deploy that fails after the user leaves the page', async () => { const snackBar = TestBed.inject(TailwindSnackBarService); const show = vi.spyOn(snackBar, 'show').mockReturnValue({ onAction: () => NEVER } as any); @@ -454,4 +505,15 @@ describe('DeployApplicationStep3Component', () => { expect(close).toHaveBeenCalled(); expect(show).not.toHaveBeenCalled(); }); + + it('reports on View that the lookup of the app of a deploy left mid-way failed', async () => { + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + const show = await viewAfterLeaving(); + + TestBed.inject(HttpTestingController).expectOne('/pp/v1/cf/apps/cf?space_guids=space') + .flush('boom', { status: 500, statusText: 'Server Error' }); + + expect(show).toHaveBeenCalledWith('Application my-app was deployed but looking it up in the space failed', 'Dismiss'); + expect(navigate).not.toHaveBeenCalled(); + }); }); 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 8549cadd87..30d03b2cfd 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 @@ -66,8 +66,8 @@ export class DeployApplicationStep3Component implements OnDestroy { // false on terminal states (success or error) so the user can go back // and fix inputs when the deploy fails. private disablePreviousSubject = new BehaviorSubject(true); - // A deployed app the lookup did not find: there is no app to go to, but - // the deploy is over, so the step can be closed + // A deployed app the lookup did not find, or a lookup that failed: there + // is no app to go to, but the deploy is over, so the step can be closed private appNotFoundSubject = new BehaviorSubject(false); readonly valid$: Observable = this.validSubject.asObservable(); @@ -164,18 +164,33 @@ export class DeployApplicationStep3Component implements OnDestroy { filter(() => !!this.deployer.appData?.Name && !this.appGuid), take(1), switchMap(() => this.lookUpAppGuid()), - ).subscribe(guid => { - if (guid) { - this.deployer.applicationGuid$.next(guid); - } else { + ).subscribe({ + next: guid => { + if (guid) { + this.deployer.applicationGuid$.next(guid); + } else { + this.appNotFoundSubject.next(true); + this.showAppNotFound(); + } + }, + error: () => { this.appNotFoundSubject.next(true); - this.snackBarService.show( - `Application ${this.deployer.appData?.Name} was deployed but could not be found in the space`, 'Dismiss'); - } + this.showAppLookupFailed(); + }, }) ); } + private showAppNotFound() { + this.snackBarService.show( + `Application ${this.deployer.appData?.Name} was deployed but could not be found in the space`, 'Dismiss'); + } + + private showAppLookupFailed() { + this.snackBarService.show( + `Application ${this.deployer.appData?.Name} was deployed but looking it up in the space failed`, 'Dismiss'); + } + // Emits undefined when the space has no app of that name private lookUpAppGuid(): Observable { const appName = this.deployer.appData?.Name; @@ -225,9 +240,16 @@ export class DeployApplicationStep3Component implements OnDestroy { // A first-time deploy left mid-way never looked up its app GUID ref.onAction().pipe( switchMap(() => this.appGuid ? observableOf(this.appGuid) : this.lookUpAppGuid()), - ).subscribe(guid => { - this.appGuid = guid; - this.goToAppSummary(); + ).subscribe({ + next: guid => { + if (guid) { + this.appGuid = guid; + this.goToAppSummary(); + } else { + this.showAppNotFound(); + } + }, + error: () => this.showAppLookupFailed(), }); } this.deployer.close(); From c8abda67c1111d4da68076e3b9c4fb763f973b59 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:11:32 -0700 Subject: [PATCH 061/121] fix(cf): keep deploy errors boolean The deployer put the message of a failed deploy in the error flag of its status, and null there after a success, although the flag is a boolean. Readers that only test the flag still worked, but the closeable state of the deploy step could carry the message text instead of true. The flag is now true or false, and the message stays in errorMsg. --- .../deploy-application/deploy-application-deployer.spec.ts | 4 ++-- .../deploy-application/deploy-application-deployer.ts | 4 ++-- .../deploy-application-step3.component.spec.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts index 245c819d76..9905856232 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.spec.ts @@ -40,7 +40,7 @@ describe('DeployApplicationDeployer', () => { socket.server.complete(); expect(deployer.streamTitle()).toBe('Deploy Failed - Connection lost'); - expect(deployer.status$().error).toBeTruthy(); + expect(deployer.status$().error).toBe(true); expect(deployer.status$().errorMsg).toBe('The connection to the server was lost before the deploy finished.'); }); @@ -48,7 +48,7 @@ describe('DeployApplicationDeployer', () => { socket.server.next(JSON.stringify({ type: SocketEventTypes.CLOSE_SUCCESS, message: '' })); socket.server.complete(); - expect(deployer.status$().error).toBeFalsy(); + expect(deployer.status$().error).toBe(false); expect(deployer.streamTitle()).not.toBe('Deploy Failed - Connection lost'); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index 6e1e8c7ff7..64b0eb6b50 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -512,7 +512,7 @@ export class DeployApplicationDeployer { } } - private onClose(log: any, title: string | null, error: any) { + private onClose(log: any, title: string | null, error: string | null) { if (this.closeReported) { return; } @@ -522,7 +522,7 @@ export class DeployApplicationDeployer { } this.deploying = false; this.updateStatus( - error, + !!error, error && log.message ? `${error}\nReason: ${log.message}` : error || undefined ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index cb8a392ef1..9553b1a925 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -217,7 +217,7 @@ describe('DeployApplicationStep3Component', () => { await fixture.whenStable(); expect(component.deployer.finished).toBe(true); - expect(component.deployer.status$().error).toBeTruthy(); + expect(component.deployer.status$().error).toBe(true); expect(locked()).toBe(false); }); }); From b255b7f261b50c2b3425b7b0031466de0e43d63b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:11:47 -0700 Subject: [PATCH 062/121] refactor(cf): drop unused deploy step state The deploy step kept an overlay flag, a busy flag and an error flag that nothing reads, and a subscription that did nothing. They are removed. The comment on the Previous lock now states the current rule, locked from entry until the deploy has ended, and a typo in a deployer comment is fixed. Behaviour does not change. --- .../deploy-application-deployer.ts | 2 +- .../deploy-application-step3.component.ts | 32 ++++--------------- .../deploy-application.component.ts | 2 +- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts index 64b0eb6b50..92b3e65937 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-deployer.ts @@ -397,7 +397,7 @@ export class DeployApplicationDeployer { switch (log.type) { case SocketEventTypes.MANIFEST: this.streamTitle.set('Starting deployment...'); - // This info is will be used to retrieve the app Id + // This info will be used to retrieve the app Id this.appData = JSON.parse(log.message).Applications[0]; break; case SocketEventTypes.APP_GUID_NOTIFY: 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 30d03b2cfd..5511863028 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 @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { ChangeDetectorRef, Component, EnvironmentInjector, Input, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, EnvironmentInjector, Input, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, @@ -53,7 +53,7 @@ export class DeployApplicationStep3Component implements OnDestroy { appGuid?: string; // Stable BehaviorSubjects back the public Observable fields. The field - // references (valid$/closeable$/showOverlay$) are created once and never + // references (valid$/closeable$/disablePrevious$) are created once and never // reassigned — only the values pushed through the subjects change. // Reassigning these fields after the parent template's async pipe has // already subscribed would leave the pipe stranded on a completed @@ -61,10 +61,9 @@ export class DeployApplicationStep3Component implements OnDestroy { // is never re-evaluated. private validSubject = new BehaviorSubject(false); private closeableSubject = new BehaviorSubject(false); - private showOverlaySubject = new BehaviorSubject(false); - // Locks Previous while a deploy is actively in flight. Flips back to - // false on terminal states (success or error) so the user can go back - // and fix inputs when the deploy fails. + // Locks Previous from entry until the deploy has ended (success or error, + // a lost connection or missing details included), so the user can then + // go back and fix inputs. private disablePreviousSubject = new BehaviorSubject(true); // A deployed app the lookup did not find, or a lookup that failed: there // is no app to go to, but the deploy is over, so the step can be closed @@ -72,17 +71,12 @@ export class DeployApplicationStep3Component implements OnDestroy { readonly valid$: Observable = this.validSubject.asObservable(); readonly closeable$: Observable = this.closeableSubject.asObservable(); - readonly showOverlay$: Observable = this.showOverlaySubject.asObservable(); readonly disablePrevious$: Observable = this.disablePreviousSubject.asObservable(); - error = signal(false); - public deployer!: DeployApplicationDeployer; private subscriptions: Subscription[] = []; - public busy = false; - private initDeployer() { const status$ = this.deployer.status$.asObservable(); @@ -96,10 +90,6 @@ export class DeployApplicationStep3Component implements OnDestroy { status$.subscribe(() => this.cdr.markForCheck()) ); - this.subscriptions.push( - status$.pipe(filter(status => status.deploying)).subscribe() - ); - this.subscriptions.push( status$.pipe(filter(status => status.error)) .subscribe(status => this.snackBarService.show(status.errorMsg ?? 'Application deployment failed', 'Dismiss')) @@ -129,8 +119,7 @@ export class DeployApplicationStep3Component implements OnDestroy { ); this.subscriptions.push( - status$.subscribe(status => { - this.busy = status.deploying; + status$.subscribe(() => { // Going back would abandon the deploy, so Previous stays locked // until it ends (error or success), not only while it pushes. // Every end, a lost connection or missing details included, is @@ -139,12 +128,6 @@ export class DeployApplicationStep3Component implements OnDestroy { }) ); - this.subscriptions.push( - status$.pipe( - map(status => !status.deploying || status.deploying && !this.deployer.streamTitle()) - ).subscribe(v => this.showOverlaySubject.next(v)) - ); - // Resolve the app GUID without depending on APP_GUID_NOTIFY. That // backend event (cfapppush/deploy.go:205-208) is useless either way: // absent on first-time deploy (the cfV2Actor wrapper in push_actor.go @@ -270,10 +253,8 @@ export class DeployApplicationStep3Component implements OnDestroy { // App Summary" staying active from a previous successful push). this.validSubject.next(false); this.closeableSubject.next(false); - this.showOverlaySubject.next(false); this.disablePreviousSubject.next(true); this.appNotFoundSubject.next(false); - this.error.set(false); // A retry of a first deploy may deploy another app; look it up again this.appGuid = this.redeployAppGuid; @@ -306,7 +287,6 @@ export class DeployApplicationStep3Component implements OnDestroy { // Ask the existing deployer to continue deploying this.deployer.deploy(); } - this.busy = true; // Under OnPush + zoneless CD, assigning this.deployer to a plain field // does not trigger a view update. Force one so the template (which // reads deployer.streamTitle() and renders the log-viewer under an @if diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts index 894f6ec51d..4821e5582e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts @@ -370,7 +370,7 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { disablePrevious: this.step3DisablePrevious.asReadonly(), // step3.disablePrevious$ is true from entry until the deploy ends, // which is also when the step header should show it busy, so - // showBusy reuses that signal rather than polling step3.busy. + // showBusy reuses that signal. showBusy: this.step3DisablePrevious.asReadonly(), cancelButtonText: signal('Close').asReadonly(), finishButtonText: signal('Go to App Summary').asReadonly(), From f8dbbc8744e95ac6e3918666bda2e4a9b7f10aa4 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:36:23 -0700 Subject: [PATCH 063/121] chore(e2e): drop the removed users page from ratchet The e2e ratchet still listed remove-users.page.ts, which went away with the remove-user page. The ratchet fails on entries for files that no longer exist, so lint failed on this branch. --- tools/eslint-rules/e2e-legacy-files.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/eslint-rules/e2e-legacy-files.mjs b/tools/eslint-rules/e2e-legacy-files.mjs index d093b4b148..6209f81cda 100644 --- a/tools/eslint-rules/e2e-legacy-files.mjs +++ b/tools/eslint-rules/e2e-legacy-files.mjs @@ -43,7 +43,6 @@ export const E2E_LEGACY_FILES = [ "e2e/pages/cloud-foundry/cf-level/config-invite-client-dialog.page.ts", "e2e/pages/cloud-foundry/manage-users.page.ts", "e2e/pages/cloud-foundry/org-level/cf-org-level.page.ts", - "e2e/pages/cloud-foundry/remove-users.page.ts", "e2e/pages/endpoints/endpoints.page.ts", "e2e/pages/endpoints/register-dialog.page.ts", "e2e/pages/marketplace/base-create-service-instance-stepper.page.ts", From a218f48f3e4d520346e778f4277de6dac6d2e934 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:36:23 -0700 Subject: [PATCH 064/121] test(cf): match git lookups with a predicate The build tab spec matched its GitHub requests by URL string. Code scanning reads such a string as a regular expression with unescaped dots and flags it as an incomplete hostname pattern. A predicate that compares the URL exactly says what the test means and clears the alerts. --- .../tabs/build-tab/build-tab.component.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts index 63a355c7a4..6d634fab74 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/build-tab/build-tab.component.spec.ts @@ -1,3 +1,4 @@ +import { HttpRequest } from '@angular/common/http'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection, signal, WritableSignal } from '@angular/core'; @@ -174,6 +175,8 @@ describe('BuildTabComponent', () => { const source = { type: 'gitscm', scm: 'github', project: 'org/missing', branch: 'main', commit: 'abc', endpointGuid: '' }; const repoUrl = 'https://api.github.com/repos/org/missing'; const commitUrl = 'https://api.github.com/repos/org/missing/commits/abc'; + const isRepo = (req: HttpRequest) => req.urlWithParams === repoUrl; + const isCommit = (req: HttpRequest) => req.urlWithParams === commitUrl; const notFound = { status: 404, statusText: 'Not Found' }; let stratosProject: WritableSignal; let app: WritableSignal; @@ -196,8 +199,8 @@ describe('BuildTabComponent', () => { component.deploySource$.subscribe(), ]; await fixture.whenStable(); - const repoRequests = httpMock.match(repoUrl); - const commitRequests = httpMock.match(commitUrl); + const repoRequests = httpMock.match(isRepo); + const commitRequests = httpMock.match(isCommit); expect(repoRequests.length).toBe(1); expect(commitRequests.length).toBe(1); repoRequests.forEach(r => r.flush('nope', notFound)); @@ -210,8 +213,8 @@ describe('BuildTabComponent', () => { await fixture.whenStable(); } - expect(httpMock.match(repoUrl).length).toBe(0); - expect(httpMock.match(commitUrl).length).toBe(0); + expect(httpMock.match(isRepo).length).toBe(0); + expect(httpMock.match(isCommit).length).toBe(0); subs.forEach(sub => sub.unsubscribe()); }); }); From 69c595b0e9b9d4126c563131290b1358c8856ec5 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:36:23 -0700 Subject: [PATCH 065/121] refactor(cf): bind the redeploy app without alias The deploy step's redeploy input kept the old public name appGuid through an alias, which the lint rule against aliased inputs warns on. The wizard now binds redeployAppGuid directly. --- .../deploy-application-step3.component.spec.ts | 2 +- .../deploy-application-step3.component.ts | 2 +- .../deploy-application/deploy-application.component.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts index 9553b1a925..d2ea3ac156 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step3/deploy-application-step3.component.spec.ts @@ -335,7 +335,7 @@ describe('DeployApplicationStep3Component', () => { it('keeps the app given for a redeploy', async () => { const httpMock = TestBed.inject(HttpTestingController); - fixture.componentRef.setInput('appGuid', 'r1'); + fixture.componentRef.setInput('redeployAppGuid', 'r1'); await enterConnected(); await succeed('my-app'); 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 5511863028..90a66ed49b 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 @@ -46,7 +46,7 @@ export class DeployApplicationStep3Component implements OnDestroy { // The app being redeployed; none on a first deploy - @Input('appGuid') redeployAppGuid?: string; + @Input() redeployAppGuid?: string; // The deployed app: the redeployed one, or the one a first deploy // looked up. Each deploy starts again from the input. diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.html index 4cc4bf4bac..c7a6d2d6c4 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.html @@ -27,6 +27,6 @@

{{ getTitle() | async }}

- + From e0565c227945215c8ba9af13d3e9c8f6fb156634 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sun, 18 Oct 2026 20:36:23 -0700 Subject: [PATCH 066/121] test(cf): take the file reader from the spy The upload spec captured the FileReader by aliasing this inside its mock, which lint warns on. The spy already records each call's this, so the spec reads it from there. --- .../deploy-application-step-source-upload.component.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts index 092dda9f6e..bc777cc2ef 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step-source-upload/deploy-application-step-source-upload.component.spec.ts @@ -193,13 +193,12 @@ describe('DeployApplicationStepSourceUploadComponent', () => { }; beforeEach(async () => { - read = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer').mockImplementation(function (this: FileReader) { - reader = this; - }); + read = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer').mockImplementation(() => undefined); component.onEnter(oneFile()); await appRef.whenStable(); fromServer(SocketEventTypes.SOURCE_REQUIRED); fromServer(SocketEventTypes.SOURCE_FILE_ACK); + reader = read.mock.contexts.at(-1) as FileReader | undefined; expect(reader).toBeDefined(); }); From f1c94dab4f0f07141befb8e491afe4581fc0d43b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 02:42:58 -0700 Subject: [PATCH 067/121] fix(cf): hide a stale commit card on redeploy The commit card kept the previous commit until a lookup for a new SHA resolved. Clear it as soon as the SHA changes. --- .../deploy-application-step2.component.spec.ts | 10 ++++++++++ .../deploy-application-step2.component.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts index 973f7413dc..b025000446 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.spec.ts @@ -458,6 +458,16 @@ describe('DeployApplicationStep2Component', () => { expect(card?.textContent).toContain('Recorded change'); }); + it('hides the previous commit while a new commit is looked up', async () => { + commit$.next({ sha: 'rec1234abcd', commit: { message: 'Recorded change' } }); + await fixture.whenStable(); + + TestBed.inject(CfDeployAppDataService).setDeployCommit('new5678efgh'); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.deploy-step2-form__commit')).toBeNull(); + }); + it('saves the recorded commit when Next is pressed before the lookup resolves', async () => { const save = vi.spyOn(TestBed.inject(CfDeployAppDataService), 'saveAppDetails'); await firstValueFrom(component.onNext(0, null as any)); 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 ff2bd5cd7c..ae7b039a1c 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 @@ -395,6 +395,9 @@ export class DeployApplicationStep2Component if (this.commitSubscription) { this.commitSubscription.unsubscribe(); } + if (this.commitInfo()?.sha !== commitSha) { + this.commitInfo.set(undefined); + } if (!commitSha) { return; } // A failed lookup only hides the commit card; onNext still saves // commitSha, and the backend reports a commit that is gone. From 44631a29e25b6bf06bf5fe23de3ab3ec56c74a06 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 02:42:58 -0700 Subject: [PATCH 068/121] refactor(cf): drop dead description markup The project and commit cards used
, an invalid attribute, so the .description rule never applied. Remove the attribute, the rule and its .centered child; the layout is unchanged. --- .../deploy-application-step2.component.html | 6 +++--- .../deploy-application-step2.component.scss | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html index 218130883d..166c1520b1 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.html @@ -112,11 +112,11 @@
-
+
-
+
{{projectInfo.description}}
@@ -145,7 +145,7 @@
-
+
diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.scss b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.scss index 459fa4fd38..2dcd02945e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.scss +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application-step2/deploy-application-step2.component.scss @@ -22,12 +22,6 @@ img { @apply h-[51px] w-[51px]; } - .description { - @apply flex-row ml-6 absolute; - .centered { - @apply block mx-auto w-2/5; - } - } } } From 3741e492dd609a6270fd2499a52a711c074a5c74 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 02:42:58 -0700 Subject: [PATCH 069/121] docs(cf): correct the app GUID fallback comment Since the backend sends APP_GUID_NOTIFY after a first-time push, the lookup by name is a fallback for when that best-effort notify is missing, not the only source of the GUID. --- .../deploy-application-step3.component.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 90a66ed49b..f751afda7c 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 @@ -128,14 +128,11 @@ export class DeployApplicationStep3Component implements OnDestroy { }) ); - // Resolve the app GUID without depending on APP_GUID_NOTIFY. That - // backend event (cfapppush/deploy.go:205-208) is useless either way: - // absent on first-time deploy (the cfV2Actor wrapper in push_actor.go - // that used to send it was dropped when CF CLI internals were migrated - // from v2action to v7action and no v7 equivalent was written) and - // redundant on redeploy (it just echoes the value the client passed in - // the URL). Even the Stratos "Redeploy" button path currently ends up - // as a first-time deploy from the server's perspective because + // Fallback for a missing APP_GUID_NOTIFY. After a first-time push the + // backend resolves the new app's GUID by name and sends it, but that + // lookup is best-effort (cfapppush/deploy.go), so the event can be + // absent. The Stratos "Redeploy" button path also ends up as a + // first-time deploy from the server's perspective because // DeployApplicationDeployer.isRedeploy is never assigned from the // parent component — the &app=X URL parameter is never set. // From b5fc8fb0e65f840028e6bf08e16310125bb0be21 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 04:21:47 -0700 Subject: [PATCH 070/121] fix(cf): render git tab lookups as signals On a direct load the git tab set its fields inside subscribes, so a failed repository lookup left "Retrieving Git Details" up forever. The repo, commit and branch lookups are now signals, and the template derives from them. The tab no longer subscribes to the lookups, so a lookup still running when the tab closes ends with it. --- .../tabs/gitscm-tab/gitscm-tab.component.html | 20 +- .../gitscm-tab/gitscm-tab.component.spec.ts | 67 ++++- .../tabs/gitscm-tab/gitscm-tab.component.ts | 228 ++++++++---------- 3 files changed, 175 insertions(+), 140 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.html index 91e2dbe75b..74fde488aa 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.html @@ -1,6 +1,6 @@
- @if (hasRepo$ | async; as hasRepo) { + @if (hasRepo()) { @if (data.stratosProject(); as stratosProject) { @@ -12,13 +12,13 @@

Deployment Information

- {{ + {{ stratosProject.deploySource.project }} {{ stratosProject.deploySource.branch }} @@ -27,12 +27,12 @@

Deployment Information

- @if ((isHead$ | async) === false) { + @if (isHead() === false) {
warning @@ -53,7 +53,7 @@

Deployment Information

} - @if (gitSCMRepo$ | async; as gitSCMRepo) { + @if (gitSCMRepo(); as gitSCMRepo) {
@@ -105,7 +105,7 @@

Repository Details

} - @if (commit$ | async; as commitInfo) { + @if (commit(); as commitInfo) {
@@ -155,10 +155,10 @@

Commit Details

} } - @if ((hasRepo$ | async) === false) { + @if (hasRepo() === false) { , stratosProject ], schemas: [NO_ERRORS_SCHEMA] }) - // Keep CommonModule (async pipe) and the template's pipe but strip deep - // child components that pull in the full ngrx entity store. + // Keep CommonModule (async pipe), the template's pipe and the loading and + // no-content pages but strip deep child components that pull in the full + // ngrx entity store. .overrideComponent(GitSCMTabComponent, { - set: { imports: [CommonModule, TruncatePipe] }, + set: { imports: [CommonModule, TruncatePipe, LoadingPageComponent, NoContentMessageComponent] }, }) .compileComponents(); } @@ -195,5 +200,59 @@ describe('GitSCMTabComponent', () => { expect(fixture.nativeElement.textContent).toContain('branch has been updated since this app was deployed'); }); + + it('ends its lookups when the tab closes before they settle', async () => { + fixture.destroy(); + + expect(open).toEqual({ repo: 0, commit: 0, branch: 0 }); + }); + + it('ends the commit lookup when the tab closes before it settles', async () => { + httpMock.expectOne(repoUrl).flush({ full_name: 'org/repo', html_url: 'h', owner: {} }); + await fixture.whenStable(); + fixture.destroy(); + + expect(open).toEqual({ repo: 0, commit: 0, branch: 0 }); + }); + }); + + // A direct load or reload: the app's env vars, and so the project, arrive + // after the tab has first rendered. + describe('when the project arrives after the first render', () => { + const repoUrl = 'https://api.github.com/repos/org/repo'; + let project$: Subject; + let httpMock: HttpTestingController; + + beforeEach(async () => { + project$ = new Subject(); + await configure(project$, project); + httpMock = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(GitSCMTabComponent); + await fixture.whenStable(); + project$.next(project); + await fixture.whenStable(); + }); + + afterEach(() => { + httpMock.match(() => true); + }); + + it('leaves the loading page when the repository lookup fails', async () => { + httpMock.expectOne(repoUrl).flush('nope', { status: 404, statusText: 'Not Found' }); + await fixture.whenStable(); + + const text = fixture.nativeElement.textContent; + expect(text).not.toContain('Retrieving Git Details'); + expect(text).toContain('Unable to fetch details'); + }); + + it('shows the repository once its lookup resolves', async () => { + httpMock.expectOne(repoUrl).flush({ full_name: 'org/repo', html_url: 'h', owner: {} }); + await fixture.whenStable(); + + const text = fixture.nativeElement.textContent; + expect(text).not.toContain('Retrieving Git Details'); + expect(text).toContain('Repository Details'); + }); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts index d70aaeeb19..7a49b8cd9d 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/gitscm-tab/gitscm-tab.component.ts @@ -1,13 +1,28 @@ import { CommonModule, DatePipe } from '@angular/common'; -import { Component, OnDestroy, OnInit, ChangeDetectionStrategy, WritableSignal, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnDestroy, + OnInit, + WritableSignal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { TailwindSnackBarService, TailwindSnackBarRef } from '@stratosui/core'; import { + GitBranch, GitCommit, GitDataService, GithubCommitsSignalConfigService, GitMeta, GitRepo, + GitResource, GitSCM, GitSCMService, GitSCMType, @@ -15,19 +30,8 @@ import { GithubCommitAuthorComponent, } from '@stratosui/git'; import { getUnixTime } from 'date-fns'; -import { EMPTY, Observable, Subscription, combineLatest, of } from 'rxjs'; -import { - catchError, - distinctUntilChanged, - filter, - map, - publishReplay, - refCount, - startWith, - switchMap, - take, - withLatestFrom, -} from 'rxjs/operators'; +import { combineLatest } from 'rxjs'; +import { take } from 'rxjs/operators'; import { ApplicationService } from '../../../../application.service'; @@ -88,34 +92,57 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { private readonly signalConfig = inject(GithubCommitsSignalConfigService); + private readonly destroyRef = inject(DestroyRef); + + // The git lookups this tab reads, set once the project resolves (and, for + // the commit and branch, once the repo does). Template state derives from + // their signals, so a lookup that settles after the first render, or fails, + // still reaches the view. + private readonly repo = signal | undefined>(undefined); + private readonly commitLookup = signal | undefined>(undefined); + private readonly branchLookup = signal | undefined>(undefined); + // undefined while the repo state is still resolving (before value/error). - public hasRepo$!: Observable; - public isLoading$!: Observable; + public readonly hasRepo = computed(() => { + const repo = this.repo(); + return repo?.value() ? true : repo?.error() ? false : undefined; + }); + public readonly isLoading$ = toObservable(computed(() => this.hasRepo() === undefined)); - public gitSCMRepo$!: Observable; - public commit$!: Observable; - public isHead$!: Observable; + public readonly gitSCMRepo = computed(() => this.repo()?.value() ?? null); + // A failed commit or branch lookup leaves these null: the template leaves + // the details out. + public readonly commit = computed(() => this.commitLookup()?.value() ?? null); + public readonly isHead = computed(() => { + const branch = this.branchLookup()?.value(); + // strict: a resolved branch carries its head commit. + return branch ? branch.commit!.sha === this.deployedCommitSha : null; + }); + public readonly icon = signal(undefined); - // Signal-native commit list config — undefined until the repo + branch - // resolve (the commits load happens in the same block). + // Signal-native commit list config — undefined until the repo resolves. public readonly listConfig: WritableSignal | undefined> = signal(undefined); - private gitSCMRepoErrorSub!: Subscription; private snackBarRef?: TailwindSnackBarRef; // Context needed by the per-row Deploy / Compare actions, captured as the - // observables resolve so the action callbacks can read them synchronously. + // lookups resolve so the action callbacks can read them synchronously. private scm!: GitSCM; private projectName = ''; private branchName = ''; private deployedCommitSha = ''; - private deployedTime = 0; - private repoHtmlUrl = ''; private cfGuid = ''; private orgGuid = ''; private spaceGuid = ''; private appGuid = ''; + // The Compare action is only offered for commits newer than the deployed + // one (see the legacy createEnabled gating). + private readonly deployedTime = computed(() => { + const deployed = this.commit()?.commit; + return deployed ? getUnixTime(new Date(deployed.author.date)) : 0; + }); + public noContentFirstLine = 'Unable to fetch details'; public noContentSecondLine: NoContentMessageLine = { text: 'This repository may be private or has been removed.' @@ -123,18 +150,33 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { public noContentOtherLines: NoContentMessageLine[] = [{ text: 'Alternatively this may be due to a communication issue.' }]; - public icon$!: Observable; // Exposed for the SHA cell template's "(deployed)" marker. public isDeployedCommit = (c: GitCommit): boolean => this.signalConfig.isHighlighted(c); + constructor() { + // Once the repo resolves, look up the deployed commit and the branch head + // and kick off the signal-native commit list. + effect(() => { + const repo = this.gitSCMRepo(); + if (repo && !untracked(this.listConfig)) { + untracked(() => this.loadRepoDetails()); + } + }); + + effect(() => { + const repo = this.repo(); + if (repo?.error()) { + const errorMessage = repo.errorMessage(); + untracked(() => this.reportRepoError(errorMessage)); + } + }); + } + ngOnDestroy(): void { if (this.snackBarRef) { this.snackBarRef.dismiss(); } - if (this.gitSCMRepoErrorSub) { - this.gitSCMRepoErrorSub.unsubscribe(); - } } private createBaseGitMeta(stProject: EnvVarStratosProject): GitMeta { @@ -149,15 +191,11 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { } ngOnInit() { - // Show the loading spinner until applicationStratProject$ resolves. - // The fields built inside the subscribe overwrite this once the - // project (and its repo) become available. - this.isLoading$ = of(true); - // Resolve the CF org/space/app guids the Deploy action needs to seed the // redeploy wizard. combineLatest([this.appService.waitForAppEntity$, this.appService.appSpace$]).pipe( take(1), + takeUntilDestroyed(this.destroyRef), ).subscribe(([app, space]) => { // strict: cfGuid is stamped on every loaded app entity (optional on IApp // only because the raw CF payload omits it before Stratos decorates it). @@ -171,104 +209,42 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { // applicationStratProject$ is filtered to non-null by the facade // (application.service.ts:197-199), so it suspends until env vars - // load and STRATOS_PROJECT is parsed. take(1) gives us a one-shot - // value with the same timing as the legacy observable. - this.appService.applicationStratProject$.pipe(take(1)).subscribe(stProject => { - const baseGitMeta = this.createBaseGitMeta(stProject); - const coreInfo$: Observable<[EnvVarStratosProject, GitMeta]> = of( - [stProject, baseGitMeta] as [EnvVarStratosProject, GitMeta] - ); - - this.scm = baseGitMeta.scm; + // load and STRATOS_PROJECT is parsed; on a direct load that is after + // the first render. + this.appService.applicationStratProject$.pipe( + take(1), + takeUntilDestroyed(this.destroyRef), + ).subscribe(stProject => { + const { projectName, scm } = this.createBaseGitMeta(stProject); + this.scm = scm; + this.projectName = projectName; // strict: git-deploy STRATOS_PROJECT always carries project/branch/commit // (optional on the shared source type for docker/other deploy kinds). - this.projectName = stProject.deploySource.project!; this.branchName = stProject.deploySource.branch!; this.deployedCommitSha = stProject.deploySource.commit!.trim(); - this.icon$ = of(baseGitMeta.scm.getIcon()); - + this.icon.set(scm.getIcon()); // Read once and held: a failed lookup leaves the cache, so a second - // read here would start a new request instead of seeing this one. - const repo = this.gitData.getRepository(baseGitMeta.scm, baseGitMeta.projectName); - - this.hasRepo$ = repo.state$.pipe( - map(state => state.value ? true : state.error ? false : undefined), - startWith(undefined), - publishReplay(1), - refCount() - ); - - this.isLoading$ = this.hasRepo$.pipe( - filter(hasRepo => hasRepo !== undefined), - map(() => false), - startWith(true) - ); - - // Completes once the repo lookup settles, without emitting when it - // failed, so the one-shot subscriptions below end either way. - const blockedOnRepo$: Observable<[EnvVarStratosProject, GitMeta]> = this.hasRepo$.pipe( - filter(hasRepo => hasRepo !== undefined), - take(1), - filter(hasRepo => !!hasRepo), - switchMap(() => coreInfo$) - ); - - this.gitSCMRepo$ = blockedOnRepo$.pipe( - switchMap(() => repo.waitForValue$) - ); + // read would start a new request instead of seeing this one. + this.repo.set(this.gitData.getRepository(scm, projectName)); + }); + } - // Once the repo resolves, capture its URL (for Compare links) and kick - // off the signal-native commit list. - this.gitSCMRepo$.pipe(take(1)).subscribe(repo => { - this.repoHtmlUrl = repo.html_url; - this.signalConfig.initialize(this.scm, this.projectName, this.branchName, this.deployedCommitSha); - void this.signalConfig.loadAll(); - this.listConfig.set(this.buildListConfig()); - }); + private loadRepoDetails(): void { + this.commitLookup.set(this.gitData.getCommit(this.scm, this.projectName, this.deployedCommitSha)); + // The branch head moves with every push, so it is read afresh each + // time the tab opens rather than taken from an earlier visit's cache. + this.branchLookup.set(this.gitData.getBranch(this.scm, this.projectName, this.branchName, { refresh: true })); - this.gitSCMRepoErrorSub = this.hasRepo$.pipe( - filter(hasRepo => hasRepo === false), - switchMap(() => repo.state$), - map(state => state.errorMessage), - distinctUntilChanged(), - withLatestFrom(coreInfo$) - ).subscribe(([errorMessage, [, meta]]) => { - if (this.snackBarRef) { - this.snackBarRef.dismiss(); - } - this.snackBarRef = this.snackBar.error(`Unable to fetch ${meta.scm.getLabel()} project: ${errorMessage}`); - }); + this.signalConfig.initialize(this.scm, this.projectName, this.branchName, this.deployedCommitSha); + void this.signalConfig.loadAll(); + this.listConfig.set(this.buildListConfig()); + } - // A failed commit or branch lookup ends these streams without a value: - // the template leaves the details out, as it did while they hung. - this.commit$ = blockedOnRepo$.pipe( - // strict: git-deploy deploySource always carries commit/branch. - switchMap(([project, meta]) => - this.gitData.getCommit(meta.scm, meta.projectName, project.deploySource.commit!.trim()).waitForValue$ - ), - catchError(() => EMPTY) - ); - // Capture the deployed commit's timestamp — the Compare action is only - // offered for commits newer than the deployed one (see the legacy - // createEnabled gating). - this.commit$.pipe(take(1)).subscribe(deployedCommit => { - // strict: getCommit resolves the full commit detail before emitting. - this.deployedTime = getUnixTime(new Date(deployedCommit.commit!.author.date)); - }); - // The branch head moves with every push, so it is read afresh each - // time the tab opens rather than taken from an earlier visit's cache. - this.isHead$ = blockedOnRepo$.pipe( - // strict: git-deploy deploySource always carries branch. - switchMap(([project, meta]) => - this.gitData.getBranch(meta.scm, meta.projectName, project.deploySource.branch!, { refresh: true }).waitForValue$ - ), - withLatestFrom(blockedOnRepo$), - // strict: a resolved branch carries its head commit; deploySource.commit - // is present for git-deployed apps. - map(([branch, [project]]) => branch.commit!.sha === project.deploySource.commit!.trim()), - catchError(() => EMPTY) - ); - }); + private reportRepoError(errorMessage: string): void { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + this.snackBarRef = this.snackBar.error(`Unable to fetch ${this.scm.getLabel()} project: ${errorMessage}`); } private buildListConfig(): SignalListConfig { @@ -310,7 +286,7 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { // (GitHub renders "no change" otherwise) and never for the deployed // commit itself. const isDeployed = c.sha === this.deployedCommitSha; - if (!isDeployed && this.deployedTime < getUnixTime(new Date(c.commit?.author?.date ?? 0))) { + if (!isDeployed && this.deployedTime() < getUnixTime(new Date(c.commit?.author?.date ?? 0))) { items.push({ label: 'Compare', icon: 'compare_arrows', invoke: () => this.compare(c) }); } return items; @@ -357,6 +333,6 @@ export class GitSCMTabComponent implements OnInit, OnDestroy { } private compare(commit: GitCommit): void { - window.open(this.scm.getCompareCommitURL(this.repoHtmlUrl, this.deployedCommitSha, commit.sha), '_blank'); + window.open(this.scm.getCompareCommitURL(this.gitSCMRepo()?.html_url ?? '', this.deployedCommitSha, commit.sha), '_blank'); } } From f2dab3ff1bf884abd1e40154e816bbf0ba2acb30 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Mon, 19 Oct 2026 04:21:47 -0700 Subject: [PATCH 071/121] fix(autoscaler): load gauges once the policy loads The metric gauges read the policy with take(1) and got the initial null on a first visit, so they never loaded. An effect on the policy signal loads them now. The "no Autoscaler Policy" notice sat behind a comparison that was never true; it follows noPolicy(). --- .../autoscaler-tab-extension.component.html | 22 +-- ...autoscaler-tab-extension.component.spec.ts | 44 +++++ .../autoscaler-tab-extension.component.ts | 179 ++++++++---------- 3 files changed, 135 insertions(+), 110 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html index 8022b1bec2..c1bf2e9392 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html @@ -1,5 +1,5 @@ - @if (appAutoscalerPolicy$ | async; as policy) { + @if (appAutoscalerPolicy(); as policy) { } - @if (!sshViewer.isConnected) { - } @@ -19,4 +19,4 @@

SSH (#{{ instanceId }})

- + diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.spec.ts index 618a5326d5..9634b5ffd6 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.spec.ts @@ -1,33 +1,124 @@ +import { TemplatePortal } from '@angular/cdk/portal'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Observable, of, Subject } from 'rxjs'; import { TabNavService } from '@stratosui/core'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; -import { ApplicationServiceMock } from "@test-framework/application-service-helper"; import { ApplicationService } from '../application.service'; import { SshApplicationComponent } from "./ssh-application.component"; +// happy-dom has no canvas, so xterm cannot open a real terminal. +vi.mock('xterm', () => ({ + Terminal: class { + loadAddon() { } + open() { } + onKey() { } + onResize() { } + focus() { } + reset() { } + write() { } + dispose() { } + } +})); +vi.mock('xterm-addon-fit', () => ({ + FitAddon: class { + fit() { } + } +})); + +// Each subscription to the connection opens a new fake web socket. +const sockets = vi.hoisted(() => [] as { next: (v: unknown) => void, error: (e: Error) => void }[]); +vi.mock('rxjs-websockets', async () => { + const { Subject } = await import('rxjs'); + return { + normalClosureMessage: 'Normal closure', + default: () => new Observable(subscriber => { + const socket = new Subject(); + sockets.push(socket); + return socket.subscribe(subscriber); + }), + }; +}); + describe('SshApplicationComponent', () => { let component: SshApplicationComponent; let fixture: ComponentFixture; - beforeEach(() => { + // The page header renders through a portal; attach it next to the + // header so its buttons are in the fixture's DOM. + const renderHeader = () => { + const portal = TestBed.inject(TabNavService).pageHeader() as TemplatePortal; + portal.viewContainerRef.createEmbeddedView(portal.templateRef, portal.context); + }; + const text = (): string => fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim(); + const button = (name: string): HTMLButtonElement | null => fixture.nativeElement.querySelector(`button[name="${name}"]`); + const lastSocket = () => sockets[sockets.length - 1]; + // A web socket opens by sending its response function. + const open = () => lastSocket().next(() => new Subject()); + + beforeEach(async () => { + sockets.length = 0; TestBed.configureTestingModule({ imports: [ SshApplicationComponent, ], providers: [ ...STORE_TEST_PROVIDERS, - { provide: ApplicationService, useClass: ApplicationServiceMock }, + { + provide: ApplicationService, + useValue: { + cfGuid: 'cf-guid', + appGuid: 'app-guid', + waitForAppEntity$: of({ entity: { entity: { name: 'test-app' } } }), + } + }, TabNavService, + provideRouter([]), + { provide: ActivatedRoute, useValue: { snapshot: { params: { index: '0' } } } }, provideZonelessChangeDetection(), ], }); + fixture = TestBed.createComponent(SshApplicationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + renderHeader(); + await fixture.whenStable(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('shows Disconnect instead of Connect once connected', async () => { + expect(button('start')).not.toBeNull(); + open(); + await fixture.whenStable(); + expect(button('stop')).not.toBeNull(); + expect(button('start')).toBeNull(); }); - // TODO: Fix EntityCatalogHelper initialization to enable component creation test - // The component requires EntityCatalogHelper to be initialized, which needs proper entity catalog setup - it('should be defined', () => { - expect(SshApplicationComponent).toBeDefined(); + it('shows the web socket error on the first failed attempt', async () => { + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + expect(text()).toContain('Error connecting to web socket'); + }); + + it('clears the error on reconnect and shows it again when the retry fails', async () => { + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + + button('start')!.click(); + await fixture.whenStable(); + expect(text()).not.toContain('Error connecting to web socket'); + + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + expect(text()).toContain('Error connecting to web socket'); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.ts index 96223e2687..71e4c9d410 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, ViewChild, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, OnInit, ViewChild, ChangeDetectionStrategy, inject, signal } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { NEVER, Observable, Subject } from 'rxjs'; import websocketConnect, { normalClosureMessage } from 'rxjs-websockets'; @@ -34,7 +34,8 @@ export class SshApplicationComponent implements OnInit { public sshInput!: Subject; - public errorMessage!: string; + // Set when the web socket fails, after the first render + public readonly errorMessage = signal(undefined); public sshRoute!: string; @@ -89,11 +90,13 @@ export class SshApplicationComponent implements OnInit { ); this.messages = connection.pipe( + // Each connect attempt starts without the previous attempt's error + tap({ subscribe: () => this.errorMessage.set(undefined) }), tap(() => this.connectionStatus.next(1)), switchMap((getResponse: any): any[] => getResponse(this.sshInput)), catchError((e: Error): any[] => { if (e.message !== normalClosureMessage) { - this.errorMessage = 'Error connecting to web socket'; + this.errorMessage.set('Error connecting to web socket'); } return []; })); diff --git a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.html b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.html index aea5958df3..7cfb45fd4e 100644 --- a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.html +++ b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.html @@ -1,29 +1,29 @@
- @if (!message && (errorMessage || !isConnected)) { + @if (!message() && (error() || !isConnected())) {
- @if (errorMessage) { + @if (error()) {
- {{ errorMessage || 'Error occurred establishing SSH connection' }} + {{ error() }}
} - @if (!errorMessage && !isConnected && !attemptingConnection) { + @if (!error() && !isConnected() && !attemptingConnection()) {
Disconnected
}
} - @if (message) { + @if (message()) {
- {{ message }} + {{ message() }}
} - @if (attemptingConnection) { + @if (attemptingConnection()) { } -
+
diff --git a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.spec.ts b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.spec.ts index a8a7392928..6d24989c92 100644 --- a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.spec.ts @@ -1,10 +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 { Subject } from 'rxjs'; import { CoreModule } from '../../../core/core.module'; import { SshViewerComponent } from './ssh-viewer.component'; +// happy-dom has no canvas, so xterm cannot open a real terminal. +vi.mock('xterm', () => ({ + Terminal: class { + loadAddon() { } + open() { } + onKey() { } + onResize() { } + focus() { } + reset() { } + write() { } + dispose() { } + } +})); +vi.mock('xterm-addon-fit', () => ({ + FitAddon: class { + fit() { } + } +})); + +// The backend sends a window title as an OSC 2 sequence: ESC ] 2 ; BEL, +// with each character as a hex code separated by spaces and a trailing newline. +function windowTitle(title: string): string { + const codes = [27, 93, 50, 59, ...Array.from(title).map(c => c.charCodeAt(0)), 7]; + return codes.map(c => c.toString(16)).join(' ') + '\n'; +} + describe('SshViewerComponent', () => { let component: SshViewerComponent; let fixture: ComponentFixture<SshViewerComponent>; @@ -20,12 +47,6 @@ describe('SshViewerComponent', () => { TestBed.compileComponents(); }); - beforeEach(() => { - fixture = TestBed.createComponent(SshViewerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - afterEach(() => { // Properly clean up the component if (fixture) { @@ -33,7 +54,95 @@ describe('SshViewerComponent', () => { } }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('without a connection', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SshViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('with a connection', () => { + let stream: Subject<string>; + let status: Subject<number>; + + const text = (): string => fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim(); + const progressBar = (): Element | null => fixture.nativeElement.querySelector('app-progress-bar'); + + beforeEach(async () => { + stream = new Subject<string>(); + status = new Subject<number>(); + fixture = TestBed.createComponent(SshViewerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('sshStream', stream); + fixture.componentRef.setInput('sshInput', new Subject<string>()); + fixture.componentRef.setInput('connectionStatus', status); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('shows the progress bar while connecting', () => { + expect(progressBar()).not.toBeNull(); + }); + + it('shows a window title message that arrives on the stream', async () => { + stream.next(windowTitle('Launching Kubernetes Terminal')); + await fixture.whenStable(); + expect(text()).toContain('Launching Kubernetes Terminal'); + }); + + it('shows an error window title that arrives on the stream', async () => { + stream.next(windowTitle('!Pod failed to start')); + await fixture.whenStable(); + expect(text()).toContain('Pod failed to start'); + }); + + it('keeps the error after the stream closes', async () => { + stream.next(windowTitle('!Pod failed to start')); + stream.complete(); + await fixture.whenStable(); + expect(text()).toContain('Pod failed to start'); + expect(text()).not.toContain('Disconnected'); + expect(progressBar()).toBeNull(); + }); + + it('shows Disconnected when the stream closes without an error', async () => { + status.next(1); + await fixture.whenStable(); + status.next(0); + stream.complete(); + await fixture.whenStable(); + expect(text()).toContain('Disconnected'); + }); + + it('clears the error when it reconnects', async () => { + stream.next(windowTitle('!Pod failed to start')); + stream.complete(); + await fixture.whenStable(); + + stream = new Subject<string>(); + fixture.componentRef.setInput('sshStream', stream); + component.reconnect(); + await fixture.whenStable(); + expect(text()).not.toContain('Pod failed to start'); + expect(progressBar()).not.toBeNull(); + }); + + it('shows an error passed in by the parent', async () => { + fixture.componentRef.setInput('errorMessage', 'Error connecting to web socket'); + await fixture.whenStable(); + expect(text()).toContain('Error connecting to web socket'); + }); + + it('clears the progress bar once connected', async () => { + status.next(1); + await fixture.whenStable(); + expect(component.isConnected()).toBe(true); + expect(progressBar()).toBeNull(); + }); }); }); 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 9eea35eb4b..3321908c78 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'; import { Observable, Subject, Subscription, takeUntil } from 'rxjs'; import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; @@ -20,11 +20,20 @@ import { AppProgressBarComponent } from '../progress-bar/app-progress-bar.compon ] }) export class SshViewerComponent implements OnInit, OnDestroy { - private changeDetector = inject(ChangeDetectorRef); private resizer = inject(EventWatcherService); + // The parent's error, e.g. a failed web socket. The parent clears it at the + // start of each attempt. + private readonly parentError = signal<string | undefined>(undefined); @Input() - errorMessage?: string; + set errorMessage(v: string | undefined) { this.parentError.set(v); } + get errorMessage(): string | undefined { return this.parentError(); } + + // An error the backend sends as a '!' window title. It stays after the + // stream closes, so the user sees why; reconnect() clears it. + private readonly streamError = signal<string | undefined>(undefined); + + public readonly error = computed(() => this.streamError() ?? this.parentError()); @Input() sshStream!: Observable<any>; @@ -35,11 +44,11 @@ export class SshViewerComponent implements OnInit, OnDestroy { @Input() public connectionStatus!: Observable<number>; - public isConnected = false; - public attemptingConnection = false; - private isDestroying = false; + public readonly isConnected = signal(false); + public readonly attemptingConnection = signal(false); - public message = ''; + // A progress window title from the backend, e.g. while a pod starts. + public readonly message = signal(''); @ViewChild('terminal', { static: true }) container!: ElementRef; private xterm!: Terminal; @@ -68,15 +77,12 @@ export class SshViewerComponent implements OnInit, OnDestroy { this.connectSubscription = this.connectionStatus .pipe(takeUntil(this.destroy$)) .subscribe((count: number) => { - this.isConnected = (count !== 0); - if (this.isConnected) { + this.isConnected.set(count !== 0); + if (this.isConnected()) { this.xterm.focus(); - this.attemptingConnection = false; + this.attemptingConnection.set(false); this.resize(); } - if (!this.isDestroying) { - this.changeDetector.detectChanges(); - } }); this.xterm = new Terminal(); @@ -107,7 +113,6 @@ export class SshViewerComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.isDestroying = true; this.destroy$.next(); this.destroy$.complete(); @@ -133,9 +138,8 @@ export class SshViewerComponent implements OnInit, OnDestroy { } disconnect() { - this.attemptingConnection = false; - this.isConnected = false; - this.errorMessage = undefined; + this.attemptingConnection.set(false); + this.isConnected.set(false); if (this.msgSubscription && !this.msgSubscription.closed) { this.msgSubscription.unsubscribe(); } @@ -143,9 +147,9 @@ export class SshViewerComponent implements OnInit, OnDestroy { reconnect() { this.disconnect(); - this.attemptingConnection = true; - this.changeDetector.detectChanges(); - this.errorMessage = undefined; + this.attemptingConnection.set(true); + this.streamError.set(undefined); + this.message.set(''); this.xterm.reset(); this.msgSubscription = this.sshStream .pipe(takeUntil(this.destroy$)) @@ -156,10 +160,6 @@ export class SshViewerComponent implements OnInit, OnDestroy { for (const c of data.split(' ')) { this.xterm.write(String.fromCharCode(parseInt(c, 16))); } - } else { - console.error('Error: ', this.errorMessage); - const eMsg = this.errorMessage; - this.errorMessage = eMsg; } }, (_err) => { @@ -167,9 +167,6 @@ export class SshViewerComponent implements OnInit, OnDestroy { }, () => { this.disconnect(); - if (!this.isDestroying) { - this.changeDetector.detectChanges(); - } } ); } @@ -185,11 +182,11 @@ export class SshViewerComponent implements OnInit, OnDestroy { title += String.fromCharCode(parseInt(chars[i], 16)); } if (title.length > 0 && title.charAt(0) === '!') { - this.errorMessage = title.substr(1); - console.error(this.errorMessage); + this.streamError.set(title.substring(1)); + console.error(this.streamError()); return true; } - this.message = title; + this.message.set(title); } return false; } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html index a07330a0e6..7a6348e79c 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html @@ -2,13 +2,13 @@ <h1>Kubernetes Terminal</h1> <div class="page-header-right"> <span> - @if (sshViewer.isConnected) { - <button class="btn btn-icon" matTooltip="Disconnect" name="stop" (click)="sshViewer.disconnect()" [disabled]="sshViewer.attemptingConnection"> + @if (sshViewer.isConnected()) { + <button class="btn btn-icon" matTooltip="Disconnect" name="stop" (click)="sshViewer.disconnect()" [disabled]="sshViewer.attemptingConnection()"> <span class="material-symbols-outlined">stop</span> </button> } - @if (!sshViewer.isConnected) { - <button class="btn btn-icon" matTooltip="Connect" name="start" (click)="sshViewer.reconnect()" [disabled]="sshViewer.attemptingConnection"> + @if (!sshViewer.isConnected()) { + <button class="btn btn-icon" matTooltip="Connect" name="start" (click)="sshViewer.reconnect()" [disabled]="sshViewer.attemptingConnection()"> <span class="material-symbols-outlined">play_arrow</span> </button> } @@ -19,4 +19,4 @@ <h1>Kubernetes Terminal</h1> </div> </app-page-header> -<app-ssh-viewer #sshViewer [errorMessage]="errorMessage" [sshStream]="messages" [sshInput]="sshInput" [connectionStatus]="connectionStatus"></app-ssh-viewer> +<app-ssh-viewer #sshViewer [errorMessage]="errorMessage()" [sshStream]="messages" [sshInput]="sshInput" [connectionStatus]="connectionStatus"></app-ssh-viewer> diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts index 928a5432fa..7cb17f7e38 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts @@ -1,10 +1,11 @@ +import { TemplatePortal } from '@angular/cdk/portal'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; -import { of } from 'rxjs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Observable, of, Subject } from 'rxjs'; import { EndpointModel } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; @@ -12,11 +13,57 @@ import { TabNavService } from '@stratosui/core'; import { KubeConsoleComponent } from './kube-console.component'; import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +// happy-dom has no canvas, so xterm cannot open a real terminal. +vi.mock('xterm', () => ({ + Terminal: class { + loadAddon() { } + open() { } + onKey() { } + onResize() { } + focus() { } + reset() { } + write() { } + dispose() { } + } +})); +vi.mock('xterm-addon-fit', () => ({ + FitAddon: class { + fit() { } + } +})); + +// Each subscription to the connection opens a new fake web socket. +const sockets = vi.hoisted(() => [] as { next: (v: unknown) => void, error: (e: Error) => void }[]); +vi.mock('rxjs-websockets', async () => { + const { Subject } = await import('rxjs'); + return { + normalClosureMessage: 'Normal closure', + default: () => new Observable(subscriber => { + const socket = new Subject<unknown>(); + sockets.push(socket); + return socket.subscribe(subscriber); + }), + }; +}); + describe('KubeConsoleComponent', () => { let component: KubeConsoleComponent; let fixture: ComponentFixture<KubeConsoleComponent>; + // The page header renders through a portal; attach it next to the + // header so its buttons are in the fixture's DOM. + const renderHeader = () => { + const portal = TestBed.inject(TabNavService).pageHeader() as TemplatePortal; + portal.viewContainerRef.createEmbeddedView(portal.templateRef, portal.context); + }; + const text = (): string => fixture.nativeElement.textContent.replace(/\s+/g, ' ').trim(); + const button = (name: string): HTMLButtonElement | null => fixture.nativeElement.querySelector(`button[name="${name}"]`); + const lastSocket = () => sockets[sockets.length - 1]; + // A web socket opens by sending its response function. + const open = () => lastSocket().next(() => new Subject<string>()); + beforeEach(async () => { + sockets.length = 0; const mockEndpoint: EndpointModel = { guid: 'test-endpoint-guid', name: 'Test Kubernetes Endpoint', @@ -44,25 +91,61 @@ describe('KubeConsoleComponent', () => { providers: [ ...STORE_TEST_PROVIDERS, TabNavService, - { - provide: KubernetesEndpointService, - useValue: mockKubernetesEndpointService - }, provideRouter([]), provideHttpClient(), provideHttpClientTesting(), provideZonelessChangeDetection(), ], - }).compileComponents(); + }) + // The component provides its own KubernetesEndpointService + .overrideComponent(KubeConsoleComponent, { + add: { + providers: [{ provide: KubernetesEndpointService, useValue: mockKubernetesEndpointService }] + } + }) + .compileComponents(); }); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(KubeConsoleComponent); component = fixture.componentInstance; - // Don't call detectChanges to avoid ngOnInit and xterm initialization + fixture.detectChanges(); + renderHeader(); + await fixture.whenStable(); + }); + + afterEach(() => { + fixture?.destroy(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('shows Disconnect instead of Connect once connected', async () => { + expect(button('start')).not.toBeNull(); + open(); + await fixture.whenStable(); + expect(button('stop')).not.toBeNull(); + expect(button('start')).toBeNull(); + }); + + it('shows the launch error on the first failed attempt', async () => { + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + expect(text()).toContain('Error launching Kubernetes Terminal'); + }); + + it('clears the error on reconnect and shows it again when the retry fails', async () => { + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + + button('start')!.click(); + await fixture.whenStable(); + expect(text()).not.toContain('Error launching Kubernetes Terminal'); + + lastSocket().error(new Error('connection refused')); + await fixture.whenStable(); + expect(text()).toContain('Error launching Kubernetes Terminal'); + }); }); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts index fbc9039114..ed9c5730c9 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import {Component, OnInit, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; +import {Component, OnInit, ViewChild, inject, signal, ChangeDetectionStrategy } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { CustomTooltipDirective } from '@stratosui/core'; import { EMPTY, NEVER, Observable, Subject } from 'rxjs'; @@ -53,7 +53,8 @@ export class KubeConsoleComponent implements OnInit { public sshInput!: Subject<string>; // strict: assigned in ngOnInit when an endpoint guid is available - public errorMessage?: string; + // Set when the terminal fails to launch, after the first render + public readonly errorMessage = signal<string | undefined>(undefined); public connected = false; @@ -72,7 +73,7 @@ export class KubeConsoleComponent implements OnInit { if (!guid) { this.messages = NEVER; this.connectionStatus.next(0); - this.errorMessage = 'No Endpoint ID available'; + this.errorMessage.set('No Endpoint ID available'); } else { const host = window.location.host; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; @@ -83,11 +84,13 @@ export class KubeConsoleComponent implements OnInit { const connection = websocketConnect(streamUrl); this.messages = connection.pipe( + // Each connect attempt starts without the previous attempt's error + tap({ subscribe: () => this.errorMessage.set(undefined) }), tap(() => this.connectionStatus.next(1)), switchMap(getResponse => getResponse(this.sshInput)), catchError((e: Error): Observable<never> => { - if (e.message !== normalClosureMessage && !this.sshViewer.isConnected) { - this.errorMessage = 'Error launching Kubernetes Terminal'; + if (e.message !== normalClosureMessage && !this.sshViewer.isConnected()) { + this.errorMessage.set('Error launching Kubernetes Terminal'); } return EMPTY; })); From 09f54d5fccf7364e6e2c14f33cddb815cc261835 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:23:57 -0700 Subject: [PATCH 073/121] fix(cf): keep recent apps in step with app changes removeApp, addApp and updateApp changed the app list but not the recent apps, so a deleted or restarted app stayed as it was in the recent list. They now update it too, newest first and capped at 10 like the backend's recent query. --- .../endpoint-data.service.spec.ts | 59 +++++++++++++++++++ .../endpoint-data/endpoint-data.service.ts | 22 +++++++ 2 files changed, 81 insertions(+) diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts index 4eecca948d..0a05e4c6d3 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts @@ -703,6 +703,65 @@ describe('EndpointDataService', () => { expect(service.appCount()).toBe(0); }); + describe('recentApps follows the app mutators (#6021)', () => { + const older = { guid: 'app-old', name: 'old', state: 'STARTED', updatedAt: '2026-01-01T00:00:00Z' } as any; + const newer = { guid: 'app-new', name: 'new', state: 'STARTED', updatedAt: '2026-02-01T00:00:00Z' } as any; + + // Warm recentApps the way the home card does: load()'s ?return=recent + // call, newest first. + async function warmRecent(apps: any[]): Promise<void> { + service.load().subscribe(); + httpMock.expectOne(ORGS_URL).flush({ resources: [], totalResults: 0 }); + httpMock.expectOne(APPS_URL).flush({ resources: apps, totalResults: apps.length }); + httpMock.expectOne(ROUTES_URL).flush({ totalResults: 0 }); + await Promise.resolve(); + } + + it('updateApp patches the recent entry and moves it to the top', async () => { + await warmRecent([newer, older]); + service.updateApp('app-old', { state: 'STOPPED', updatedAt: '2026-03-01T00:00:00Z' }); + expect(service.recentApps().map(a => `${a.guid}:${a.state}`)) + .toEqual(['app-old:STOPPED', 'app-new:STARTED']); + }); + + it('removeApp drops the app from recentApps', async () => { + await warmRecent([newer, older]); + service.removeApp('app-new'); + expect(service.recentApps().map(a => a.guid)).toEqual(['app-old']); + }); + + it('addApp puts the new app at the top of recentApps', async () => { + await warmRecent([older]); + service.addApp(newer); + expect(service.recentApps().map(a => a.guid)).toEqual(['app-new', 'app-old']); + }); + + it('recentApps stays capped at the ten the backend returns', async () => { + const ten = Array.from({ length: 10 }, (_, i) => + ({ guid: `app-${i}`, name: `${i}`, state: 'STARTED', updatedAt: `2026-01-${String(20 - i).padStart(2, '0')}T00:00:00Z` })); + await warmRecent(ten); + service.addApp(newer); + expect(service.recentApps()).toHaveLength(10); + expect(service.recentApps()[0].guid).toBe('app-new'); + expect(service.recentApps().some(a => a.guid === 'app-9')).toBe(false); + }); + + it('updateApp of an app only in the full list joins recentApps', async () => { + await warmRecent([newer]); + service.refreshApps().subscribe(); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: [newer, older], pagination: { totalResults: 2, totalPages: 1 } }); + await Promise.resolve(); + service.updateApp('app-old', { updatedAt: '2026-03-01T00:00:00Z' }); + expect(service.recentApps().map(a => a.guid)).toEqual(['app-old', 'app-new']); + }); + + it('updateApp of an app outside the recent list leaves the list alone', async () => { + await warmRecent([newer]); + service.updateApp('app-unknown', { state: 'STOPPED' }); + expect(service.recentApps().map(a => a.guid)).toEqual(['app-new']); + }); + }); + it('removeServiceInstance / addServiceInstance / updateServiceInstance work + count tracked', () => { service.addServiceInstance(siA); expect(service.serviceInstances()).toHaveLength(1); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts index 6580fd604d..8a1fc59b5b 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts @@ -20,6 +20,9 @@ import { } from './stratos-types'; import { extractHttpErrorMessage } from '@stratosui/store'; +// Matches the per_page of the backend's ?return=recent apps call. +const RECENT_APPS_COUNT = 10; + export class EndpointDataService { // Counts + recent apps populated by load() (home card fast path). private readonly _orgCount = signal<number>(0); @@ -451,8 +454,11 @@ export class EndpointDataService { (s as { guid?: string }).guid === guid ? { ...s, ...patch } : s)); } + // The app mutators keep recentApps in step with apps, so the home card's + // recent list shows a create/update/delete without another load(). removeApp(guid: string): void { this._apps.update(curr => curr.filter(a => (a as { guid?: string }).guid !== guid)); + this._recentApps.update(curr => curr.filter(a => a.guid !== guid)); this._appCount.update(n => Math.max(0, n - 1)); } addApp(app: StApp): void { @@ -461,11 +467,27 @@ export class EndpointDataService { if (idx >= 0) { const next = [...curr]; next[idx] = app; return next; } return [...curr, app]; }); + this.upsertRecentApp(app); this._appCount.update(n => n + 1); } updateApp(guid: string, patch: Partial<StApp>): void { this._apps.update(curr => curr.map(a => (a as { guid?: string }).guid === guid ? { ...a, ...patch } : a)); + // An update bumps updatedAt, so an app outside the recent list can join + // it — but only when the full list holds the whole record to add. + const recent = this._recentApps().find(a => a.guid === guid); + const app = recent ? { ...recent, ...patch } : this._apps().find(a => a.guid === guid); + if (app) { + this.upsertRecentApp(app); + } + } + + // Mirrors the backend's ?return=recent: newest updatedAt first, capped. + private upsertRecentApp(app: StApp): void { + const updated = (a: StApp) => Date.parse(a.updatedAt) || 0; + this._recentApps.update(curr => [app, ...curr.filter(a => a.guid !== app.guid)] + .sort((a, b) => updated(b) - updated(a)) + .slice(0, RECENT_APPS_COUNT)); } removeServiceInstance(guid: string): void { From 09556e94196789f62b44ce014e03ad1b149560bf Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:23:57 -0700 Subject: [PATCH 074/121] fix(cf): follow the compact app card's input The card's status computed read a plain @Input, so a refreshed app kept its old status icon. The inputs are now signal inputs, stats load from an effect for started apps, and stats not yet fetched give a tentative status rather than "Starting App". A new noStats input skips the stats load. --- .../compact-app-card.component.html | 13 ++- .../compact-app-card.component.spec.ts | 65 ++++++++++++++- .../compact-app-card.component.ts | 80 +++++++++++-------- 3 files changed, 115 insertions(+), 43 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.html b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.html index f84c2d1cc8..dfb654714e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.html @@ -1,11 +1,11 @@ -@if (app) { +@if (app(); as app) { <div class="compact-app-card"> <app-application-state-icon [status]="appStatus()"></app-application-state-icon> <div class="compact-app-card__name"> - <a [routerLink]="['/applications', endpoint, app.metadata.guid]" [queryParams]="bcType">{{ app.entity.name }}</a> + <a [routerLink]="['/applications', endpointGuid(), app.metadata.guid]" [queryParams]="bcType">{{ app.entity.name }}</a> </div> - @if (showDate) { - <div [ngClass]="{'compact-app-card__subtle': dateMode === 'subtle'}" class="compact-app-card__updated">{{ app.metadata.updated_at | date:'medium' }}</div> + @if (showDate()) { + <div [ngClass]="{'compact-app-card__subtle': dateMode() === 'subtle'}" class="compact-app-card__updated">{{ app.metadata.updated_at | date:'medium' }}</div> } </div> } @else { @@ -14,9 +14,8 @@ <div class="compact-app-card__name"> <div class="ph-item ph-large"></div> </div> - @if (showDate) { - <div [ngClass]="{'compact-app-card__subtle': dateMode === 'subtle'}" class="ph-item ph-small compact-app-card__updated"></div> + @if (showDate()) { + <div [ngClass]="{'compact-app-card__subtle': dateMode() === 'subtle'}" class="ph-item ph-small compact-app-card__updated"></div> } </div> } - diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.spec.ts index 884401949d..0741c0ac44 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.spec.ts @@ -1,16 +1,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; +import { importProvidersFrom, provideZonelessChangeDetection, signal } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { of } from 'rxjs'; import { ApplicationStateIconComponent } from '@stratosui/core'; import { EntityCatalogTestModule, generateStratosEntities, TEST_CATALOGUE_ENTITIES, EntityCatalogHelper, EntityCatalogHelpers } from '@stratosui/store'; +import { StratosStatus } from '@stratosui/store'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { CloudFoundryTestingModule, generateCFEntities } from '@test-framework/cf'; +import { AppStatsDataRegistry } from '../../../../services/endpoint-data/app-stats-data.registry'; import { ApplicationStateService } from '../../../../shared/services/application-state.service'; +import { ActiveRouteCfOrgSpace } from '../../../cf/cf-page.types'; import { CompactAppCardComponent } from './compact-app-card.component'; describe('CompactAppCardComponent', () => { let component: CompactAppCardComponent; @@ -54,10 +58,10 @@ describe('CompactAppCardComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(CompactAppCardComponent); component = fixture.componentInstance; - component.app = { + fixture.componentRef.setInput('app', { entity: {}, metadata: {} - }; + }); fixture.detectChanges(); }); @@ -65,3 +69,56 @@ describe('CompactAppCardComponent', () => { expect(component).toBeTruthy(); }); }); + +describe('CompactAppCardComponent status (#6021)', () => { + const app = (state: string) => ({ + metadata: { guid: 'app-1', updated_at: '2026-01-01T00:00:00Z' }, + entity: { name: 'one', state, instances: 1, package_state: 'STAGED' }, + }); + + let fixture: ComponentFixture<CompactAppCardComponent>; + let stats: { + stats: ReturnType<typeof signal<{ state: string }[]>>; + lastFetched: ReturnType<typeof signal<Date | null>>; + load: ReturnType<typeof vi.fn>; + }; + + beforeEach(() => { + stats = { + stats: signal([{ state: 'RUNNING' }]), + lastFetched: signal<Date | null>(new Date()), + load: vi.fn(() => of(undefined)), + }; + TestBed.configureTestingModule({ + imports: [CompactAppCardComponent], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + { provide: ActiveRouteCfOrgSpace, useValue: null }, + { provide: AppStatsDataRegistry, useValue: { acquire: () => stats } }, + ], + }); + fixture = TestBed.createComponent(CompactAppCardComponent); + fixture.componentRef.setInput('endpoint', 'ep-1'); + }); + + it('follows a new app input when the stats do not change', async () => { + fixture.componentRef.setInput('app', app('STARTED')); + await fixture.whenStable(); + expect(fixture.componentInstance.appStatus()).toBe(StratosStatus.OK); + + // A list refresh after a stop hands the card a new app object; the + // cached stats stay as they were. + fixture.componentRef.setInput('app', app('STOPPED')); + await fixture.whenStable(); + expect(fixture.componentInstance.appStatus()).toBe(StratosStatus.WARNING); + }); + + it('shows a tentative status, not a busy one, before the stats arrive', async () => { + stats.stats.set([]); + stats.lastFetched.set(null); + fixture.componentRef.setInput('app', app('STARTED')); + await fixture.whenStable(); + expect(fixture.componentInstance.appStatus()).toBe(StratosStatus.TENTATIVE); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.ts b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.ts index 8d56921d53..be539a73ea 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/compact-app-card/compact-app-card.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ChangeDetectionStrategy, Signal, computed, inject, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, computed, effect, inject, input, untracked } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; import { RouterModule } from '@angular/router'; @@ -22,53 +22,69 @@ import { ActiveRouteCfOrgSpace } from '../../../cf/cf-page.types'; ApplicationStateIconComponent ] }) -export class CompactAppCardComponent implements OnInit { +export class CompactAppCardComponent { private appStateService = inject(ApplicationStateService); private activeRouteCfOrgSpace = inject(ActiveRouteCfOrgSpace); private statsRegistry = inject(AppStatsDataRegistry); + // Null renders a placeholder row. + readonly app = input<any>(); - @Input() app!: any; + readonly endpoint = input<string>(); - @Input() endpoint!: string; + readonly showDate = input(true); + readonly dateMode = input<string>(); + // Show the status from whatever stats are already cached; fetch none. + readonly noStats = input(false); - @Input() showDate = true; - @Input() dateMode!: string; + // The org/space summary pages don't pass an endpoint; take it from the route. + readonly endpointGuid = computed(() => this.endpoint() || this.activeRouteCfOrgSpace?.cfGuid); - applicationState: Signal<ApplicationStateData> = signal<ApplicationStateData>({ label: '', indicator: StratosStatus.NONE, actions: null }); + readonly bcType = this.activeRouteCfOrgSpace ? breadcrumbType(this.activeRouteCfOrgSpace) : undefined; - appStatus: Signal<StratosStatus> = computed(() => this.applicationState().indicator); + private readonly stats = computed(() => { + const app = this.app(); + // strict: endpointGuid is set by the parent or the route whenever a real + // app row renders. + return app ? this.statsRegistry.acquire(this.endpointGuid()!, app.metadata.guid) : null; + }); - bcType!: any; - ngOnInit() { - if (this.activeRouteCfOrgSpace) { - this.bcType = this.setBreadcrumbType(this.activeRouteCfOrgSpace); - if (!this.endpoint) { - this.endpoint = this.activeRouteCfOrgSpace.cfGuid; - } + readonly applicationState = computed<ApplicationStateData>(() => { + const app = this.app(); + const stats = this.stats(); + if (!app || !stats) { + return { label: '', indicator: StratosStatus.NONE, actions: null }; } + // Stats not fetched yet are unknown (null), not "no instances" ([]): + // [] reads as a started app with no running instances. + return this.appStateService.get(app.entity, stats.lastFetched() !== null ? stats.stats() : null); + }); - if (!this.app) { - return; - } + readonly appStatus = computed(() => this.applicationState().indicator); - const stats = this.statsRegistry.acquire(this.endpoint, this.app.metadata.guid); - this.applicationState = computed(() => this.appStateService.get(this.app.entity, stats.stats())); - stats.load().subscribe(); + constructor() { + // Only a started app has instance stats. Re-runs when a refresh hands + // the card a newly started app. + effect(() => { + const stats = this.stats(); + if (stats && !this.noStats() && this.app().entity.state === 'STARTED') { + untracked(() => stats.load().subscribe()); + } + }); } +} - private setBreadcrumbType = (activeRouteCfOrgSpace: ActiveRouteCfOrgSpace) => { - let bcType = 'cf'; - if (activeRouteCfOrgSpace.cfGuid) { - if (activeRouteCfOrgSpace.orgGuid) { - bcType = 'org'; - if (activeRouteCfOrgSpace.spaceGuid) { - bcType = 'space-summary'; - } +function breadcrumbType(activeRouteCfOrgSpace: ActiveRouteCfOrgSpace) { + let bcType = 'cf'; + if (activeRouteCfOrgSpace.cfGuid) { + if (activeRouteCfOrgSpace.orgGuid) { + bcType = 'org'; + if (activeRouteCfOrgSpace.spaceGuid) { + bcType = 'space-summary'; } } - return { - [BREADCRUMB_URL_PARAM]: bcType - }; } + return { + [BREADCRUMB_URL_PARAM]: bcType + }; } From 73c7c6c204fdf5a71c5ee97723eb3d1ce82b8eea Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:23:57 -0700 Subject: [PATCH 075/121] fix(cf): honour noStats in the recent apps card The card's own stats load never sent a request (each row loads its own) and also asked for stopped apps; remove it. noStats now reaches the rows, as its name and the original code meant. Placeholder rows were truthy app objects that rendered as empty rows and requested stats for "placeholder-N"; they are now real placeholders. Inputs are signal inputs. --- .../card-cf-recent-apps.component.html | 12 +- .../card-cf-recent-apps.component.spec.ts | 64 ++++++++++- .../card-cf-recent-apps.component.ts | 103 +++++++----------- 3 files changed, 106 insertions(+), 73 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.html b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.html index eaf4bbbea7..1852486f9e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.html @@ -1,10 +1,10 @@ @if ((hasEntities$ | async) !== false) { @if (show$ | async) { - <app-card class="recent-apps-card" [ngClass]="{'recent-apps-card__plain': mode === 'plain'}"> + <app-card class="recent-apps-card" [ngClass]="{'recent-apps-card__plain': mode() === 'plain'}"> <app-card-header class="recent-apps-card__header"> <app-card-title class="recent-apps-card__title">Recently updated applications</app-card-title> @if (canRefresh) { - <app-polling-indicator [manualPoll]="(loading$ | async) === false" [isPolling]="(loading$ | async) ?? false" + <app-polling-indicator [manualPoll]="(appsLoading$ | async) === false" [isPolling]="(appsLoading$ | async) ?? false" (poll)="refresh.emit()"></app-polling-indicator> } </app-card-header> @@ -18,7 +18,7 @@ @if (apps.length > 0) { <div class="recent-apps-card__scroller custom-scrollbar"> @for (app of apps; track app.metadata.guid) { - <app-compact-app-card [showDate]="showDate" [dateMode]="dateMode" [endpoint]="endpoint" [app]="app"></app-compact-app-card> + <app-compact-app-card [showDate]="showDate()" [dateMode]="dateMode()" [endpoint]="endpoint()" [noStats]="noStats()" [app]="app"></app-compact-app-card> } </div> } @@ -27,14 +27,14 @@ </app-card> } } @else { - <app-card class="recent-apps-card" [ngClass]="{'recent-apps-card__plain': mode === 'plain'}"> + <app-card class="recent-apps-card" [ngClass]="{'recent-apps-card__plain': mode() === 'plain'}"> <app-card-header class="recent-apps-card__header"> <app-card-title class="recent-apps-card__title">Recently updated applications</app-card-title> </app-card-header> <app-card-content class="recent-apps-card__content custom-scrollbar"> <div class="recent-apps-card__scroller custom-scrollbar"> - @for (app of placeholders; track app.metadata.guid) { - <app-compact-app-card [showDate]="showDate" [dateMode]="dateMode" [endpoint]="endpoint" [app]="app"></app-compact-app-card> + @for (row of placeholders(); track row) { + <app-compact-app-card [showDate]="showDate()" [dateMode]="dateMode()" [app]="null"></app-compact-app-card> } </div> </app-card-content> diff --git a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts index 635039da71..855d0798d2 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/card-cf-recent-apps/card-cf-recent-apps.component.spec.ts @@ -1,4 +1,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -6,6 +9,7 @@ import { of as observableOf } from 'rxjs'; import { CardCfRecentAppsComponent } from './card-cf-recent-apps.component'; import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; +import { ActiveRouteCfOrgSpace } from '../../cf/cf-page.types'; describe('CardCfRecentAppsComponent', () => { let component: CardCfRecentAppsComponent; @@ -34,9 +38,9 @@ describe('CardCfRecentAppsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(CardCfRecentAppsComponent); component = fixture.componentInstance; - component.allApps$ = observableOf([]); - component.loading$ = observableOf(false); - component.placeholderMode = true; + fixture.componentRef.setInput('allApps$', observableOf([])); + fixture.componentRef.setInput('loading$', observableOf(false)); + fixture.componentRef.setInput('placeholderMode', true); // Don't call detectChanges() to avoid rendering child components }); @@ -45,3 +49,57 @@ describe('CardCfRecentAppsComponent', () => { expect(component).toBeTruthy(); }); }); + +// Renders the real compact cards over the real stats registry, so the spec +// sees every /cf/app-stats request the card makes (#6019, #6021). +describe('CardCfRecentAppsComponent stats requests', () => { + const app = (guid: string, state: string) => ({ + metadata: { guid, updated_at: '2026-01-01T00:00:00Z' }, + entity: { guid, name: guid, state, instances: 1, package_state: 'STAGED' }, + }); + + let fixture: ComponentFixture<CardCfRecentAppsComponent>; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CardCfRecentAppsComponent], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ActiveRouteCfOrgSpace, useValue: null }, + { provide: EndpointDataRegistry, useValue: { acquire: vi.fn() } }, + ], + }); + httpMock = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(CardCfRecentAppsComponent); + }); + + const statsRequests = () => httpMock.match(req => req.url.startsWith('/pp/v1/cf/app-stats/')).map(r => r.request.url); + + it('loads stats once per started app', async () => { + fixture.componentRef.setInput('endpoint', 'ep-1'); + fixture.componentRef.setInput('allApps$', observableOf([app('a-started', 'STARTED'), app('b-stopped', 'STOPPED')])); + await fixture.whenStable(); + expect(statsRequests()).toEqual(['/pp/v1/cf/app-stats/ep-1/a-started']); + }); + + it('makes no stats requests with noStats', async () => { + fixture.componentRef.setInput('endpoint', 'ep-1'); + fixture.componentRef.setInput('noStats', true); + fixture.componentRef.setInput('allApps$', observableOf([app('a-started', 'STARTED')])); + await fixture.whenStable(); + expect(statsRequests()).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('a-started'); + }); + + it('renders placeholder rows, with no stats requests, in placeholderMode', async () => { + fixture.componentRef.setInput('placeholderMode', true); + fixture.componentRef.setInput('maxRows', 3); + await fixture.whenStable(); + expect(statsRequests()).toEqual([]); + expect(fixture.nativeElement.querySelectorAll('.compact-app-card .ph-icon').length).toBe(3); + }); +}); 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 095952bc24..6c3d342020 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 @@ -1,12 +1,11 @@ -import { Component, EventEmitter, Injector, Input, OnInit, Output, Signal, ChangeDetectionStrategy, computed, inject } from '@angular/core'; +import { Component, EventEmitter, Injector, OnInit, Output, Signal, ChangeDetectionStrategy, computed, inject, input } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { AsyncPipe, CommonModule } from '@angular/common'; -import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; -import { filter, map, startWith, tap } from 'rxjs/operators'; +import { combineLatest, Observable, of } from 'rxjs'; +import { filter, map, startWith } from 'rxjs/operators'; import { APIResource } from '@stratosui/store'; import { IApp } from '../../../cf-api.types'; -import { AppStatsDataRegistry } from '../../../services/endpoint-data/app-stats-data.registry'; import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; import { stAppToAPIResource } from '../../../services/endpoint-data/st-app-adapter'; import { appDataSort } from '../../cf/services/cloud-foundry-endpoint.service'; @@ -45,70 +44,58 @@ export class CardCfRecentAppsComponent implements OnInit { // hasEntities$=of(false) gate makes the template render placeholders and // never read recentApps$, so the field is never observed unassigned. public recentApps$!: Observable<APIResource<IApp>[]>; - @Input() allApps$!: Observable<APIResource<IApp>[]>; - @Input() loading$!: Observable<boolean>; + readonly allApps$ = input<Observable<APIResource<IApp>[]>>(); + readonly loading$ = input<Observable<boolean>>(); @Output() refresh = new EventEmitter<any>(); - @Input() endpoint!: string; - @Input() mode!: string; - @Input() showDate = true; - @Input() dateMode!: string; - @Input() noStats = false; - @Input() placeholderMode = false; - @Input() hideWhenEmpty = false; - + readonly endpoint = input<string>(); + readonly mode = input<string>(); + readonly showDate = input(true); + readonly dateMode = input<string>(); + // Passed to each row: show cached stats only, fetch none. + readonly noStats = input(false); + readonly placeholderMode = input(false); + readonly hideWhenEmpty = input(false); + readonly maxRows = input(RECENT_ITEMS_COUNT); + + // Set once in ngOnInit, before the first render. public canRefresh = false; - public placeholders: any[]; + // Only the count matters: each placeholder row renders with a null app. + readonly placeholders = computed(() => Array.from({ length: this.maxRows() }, (_, i) => i)); hasEntities$!: Observable<boolean>; show$!: Observable<boolean>; + // The loading$ input, or the endpoint data's loading signal when the + // card sources its own apps. + appsLoading$!: Observable<boolean>; private readonly registry = inject(EndpointDataRegistry); - private readonly statsRegistry = inject(AppStatsDataRegistry); private readonly injector = inject(Injector); - private statsRequested = new Set<string>(); - - private maxRowsSubject = new BehaviorSubject<number>(RECENT_ITEMS_COUNT); - - @Input() set maxRows(value: number) { - this.maxRowsSubject.next(value); - this.placeholders = this.createPlaceholders(value); - } - constructor() { - this.placeholders = this.createPlaceholders(RECENT_ITEMS_COUNT); - } - - private createPlaceholders(count: number): any[] { - return Array.from({ length: count }, (_, i) => ({ - metadata: { guid: `placeholder-${i}` }, - entity: {} - })); - } - - trackByAppGuid(index: number, app: any): string { - return app?.metadata?.guid || String(index); - } + private readonly maxRows$ = toObservable(this.maxRows); ngOnInit() { - if (this.placeholderMode) { + if (this.placeholderMode()) { this.canRefresh = false; this.hasEntities$ = of(false); return; } this.canRefresh = this.refresh.observers.length > 0; - if (!this.allApps$) { + let allApps$ = this.allApps$(); + this.appsLoading$ = this.loading$() ?? of(false); + if (!allApps$) { // Signal-native source — EndpointDataService.apps is populated by the // registry-managed loadDetails() call. The home card path warms via // load() (recent apps fast path) and then loadDetails() chains; the // CF/org/space summary acquire path goes straight to loadDetails(). // Either way we read the same signal — no separate ngrx fetch. - const endpointData = this.registry.acquire(this.endpoint); + // strict: a card without allApps$ is always given its endpoint. + const endpointData = this.registry.acquire(this.endpoint()!); const appResources: Signal<APIResource<IApp>[]> = computed( () => endpointData.apps().map(stAppToAPIResource), ); - this.allApps$ = toObservable(appResources, { injector: this.injector }); - this.loading$ = toObservable(endpointData.isLoadingDetails, { injector: this.injector }); + allApps$ = toObservable(appResources, { injector: this.injector }); + this.appsLoading$ = toObservable(endpointData.isLoadingDetails, { injector: this.injector }); this.hasEntities$ = toObservable( computed(() => endpointData.apps().length > 0), { injector: this.injector }, @@ -117,43 +104,31 @@ export class CardCfRecentAppsComponent implements OnInit { this.hasEntities$ = of(true); } + // Stats are loaded by each row (CompactAppCardComponent), once per + // started app; the registry shares the request between rows. this.recentApps$ = combineLatest( - this.allApps$, - this.maxRowsSubject.asObservable() + allApps$, + this.maxRows$, ).pipe( filter(([apps]) => !!apps), map(([apps, maxRows]) => this.restrictApps(apps, maxRows)), - // Stats fetch is gated by a per-(guid,endpoint) Set so the back-to-back - // signal emissions during loadDetails() warming don't fan out into - // duplicate /v2/apps/:guid/stats calls. Each app's stats fire at most - // once per card lifetime; refresh() clears the gate. - tap(apps => this.fetchAppStats(apps)) ); - this.show$ = this.allApps$.pipe( + this.show$ = allApps$.pipe( map(apps => { - return !this.hideWhenEmpty || this.hideWhenEmpty && apps.length > 0; + return !this.hideWhenEmpty() || this.hideWhenEmpty() && apps.length > 0; }), startWith(true), ); } - private fetchAppStats(recentApps: APIResource<IApp>[]) { - if (this.noStats) return; - recentApps.forEach(app => { - if (app.entity.state !== 'STARTED') return; - const key = `${this.endpoint}:${app.metadata.guid}`; - if (this.statsRequested.has(key)) return; - this.statsRequested.add(key); - this.statsRegistry.acquire(this.endpoint, app.metadata.guid).load(); - }); - } - private restrictApps(apps: APIResource<IApp>[], maxRows = RECENT_ITEMS_COUNT): APIResource<IApp>[] { if (!apps) { return []; } - return apps.sort(appDataSort).slice(0, maxRows); + // Copy first: the array may be a signal's value, which must not be + // sorted in place. + return [...apps].sort(appDataSort).slice(0, maxRows); } } From 64739e529ddba651f4ed2bc263b96d028285c9ad Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:23:57 -0700 Subject: [PATCH 076/121] fix(cf): follow app changes on the home card The home card took one snapshot of the recent apps, so a start, stop or delete after load never reached it. Its list and no-apps message are now computed from the endpoint data, and its other template state is signals. --- .../cfhome-card/cfhome-card.component.html | 28 ++--- .../cfhome-card/cfhome-card.component.spec.ts | 98 ++++++++++++++++- .../home/cfhome-card/cfhome-card.component.ts | 104 ++++++++---------- 3 files changed, 153 insertions(+), 77 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.html b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.html index 637096cb46..fa37e876c9 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.html @@ -1,38 +1,38 @@ <app-tile-grid [fit]="true"> <app-tile-group class="cf-home-card__plain-tiles"> <app-tile> - <app-card-number-metric mode="plain" link="/cloud-foundry/{{guid}}/applications" icon="apps" label="Applications" - labelSingular="Application" value="{{ endpointDataService?.appCount() }}"></app-card-number-metric> + <app-card-number-metric mode="plain" link="/cloud-foundry/{{ guid() }}/applications" icon="apps" label="Applications" + labelSingular="Application" value="{{ endpointDataService()?.appCount() }}"></app-card-number-metric> </app-tile> <app-tile> - <app-card-number-metric mode="plain" link="/cloud-foundry/{{guid}}/organizations" iconFont="stratos-icons" - icon="organization" label="Orgs" labelSingular="Org" value="{{ endpointDataService?.orgCount() }}"></app-card-number-metric> + <app-card-number-metric mode="plain" link="/cloud-foundry/{{ guid() }}/organizations" iconFont="stratos-icons" + icon="organization" label="Orgs" labelSingular="Org" value="{{ endpointDataService()?.orgCount() }}"></app-card-number-metric> </app-tile> - @if (layout.x <= 2) { + @if (cardLayout().x <= 2) { <app-tile> - <app-card-number-metric mode="plain" link="/cloud-foundry/{{guid}}/routes" iconFont="stratos-icons" icon="route" - label="Routes" value="{{ endpointDataService?.routeCount() }}"></app-card-number-metric> + <app-card-number-metric mode="plain" link="/cloud-foundry/{{ guid() }}/routes" iconFont="stratos-icons" icon="route" + label="Routes" value="{{ endpointDataService()?.routeCount() }}"></app-card-number-metric> </app-tile> } </app-tile-group> <app-tile-group class="tile-group-1-cols"> <app-tile> - @if (cardLoaded && recentAppsRows > 0) { + @if (cardLoaded() && recentAppsRows() > 0) { <app-card-cf-recent-apps [hideWhenEmpty]="true" [noStats]="true" - dateMode="subtle" [showDate]="true" [maxRows]="recentAppsRows" mode="plain" - [endpoint]="guid" [allApps$]="allApps$"> + dateMode="subtle" [showDate]="true" [maxRows]="recentAppsRows()" mode="plain" + [endpoint]="guid()" [allApps$]="allApps$"> </app-card-cf-recent-apps> } - @if (!cardLoaded && recentAppsRows > 0) { + @if (!cardLoaded() && recentAppsRows() > 0) { <app-card-cf-recent-apps [placeholderMode]="true" - [maxRows]="recentAppsRows" mode="plain"></app-card-cf-recent-apps> + [maxRows]="recentAppsRows()" mode="plain"></app-card-cf-recent-apps> } - @if ((hasNoApps$ | async)) { + @if (hasNoApps()) { <div class="cf-home-card__no-apps"> <span class="material-icons">apps</span> <div class="cf-home-card__no-apps-title">You don't have any applications</div> - @if (showDeployAppTiles) { + @if (showDeployAppTiles()) { <div> <div class="cf-home-card__no-apps-deploy">Get started by deploying an Application from ...</div> <app-tile-selector [options]="(tileSelectorConfig$ | async) ?? []" [compactTiles]="true" diff --git a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.spec.ts index c26d36fa5d..d4414b3b2e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.spec.ts @@ -1,10 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectorRef, signal } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { Component, Input, provideZonelessChangeDetection, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectorRef, signal } from '@angular/core'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { of, Subject } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; import { ApplicationDeploySourceTypes } from '../../applications/deploy-application/deploy-application-steps.types'; import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; +import { StApp } from '../../../services/endpoint-data/stratos-types'; +import { CardCfRecentAppsComponent } from '../card-cf-recent-apps/card-cf-recent-apps.component'; import { CFHomeCardComponent } from "./cfhome-card.component"; describe('CFHomeCardComponent', () => { @@ -65,7 +68,7 @@ describe('CFHomeCardComponent', () => { fixture = TestBed.createComponent(CFHomeCardComponent); component = fixture.componentInstance; // Set required inputs before detectChanges - component.guid = 'test-guid'; + component.endpoint = { guid: 'test-guid' } as any; component.layout = { x: 1, y: 1 } as any; // Don't call detectChanges() to avoid complex initialization }); @@ -74,3 +77,92 @@ describe('CFHomeCardComponent', () => { expect(component).toBeTruthy(); }); }); + +// Stands in for the recent-apps card so the spec sees exactly the list the +// home card hands it. +@Component({ + selector: 'app-card-cf-recent-apps', + standalone: true, + imports: [AsyncPipe], + template: `@for (app of (allApps$ | async) ?? []; track app.metadata.guid) {<span class="stub-app">{{ app.entity.name }}:{{ app.entity.state }}</span>}`, +}) +class StubRecentAppsComponent { + @Input() allApps$!: Observable<any[]>; + @Input() hideWhenEmpty = false; + @Input() noStats = false; + @Input() dateMode!: string; + @Input() showDate = true; + @Input() maxRows = 10; + @Input() mode!: string; + @Input() endpoint!: string; + @Input() placeholderMode = false; +} + +describe('CFHomeCardComponent follows the endpoint data (#6021)', () => { + const app = (state: string): StApp => + ({ guid: 'app-1', name: 'one', state, updatedAt: '2026-01-01T00:00:00Z' } as StApp); + + let fixture: ComponentFixture<CFHomeCardComponent>; + let eds: { + appCount: ReturnType<typeof signal<number>>; + orgCount: ReturnType<typeof signal<number>>; + routeCount: ReturnType<typeof signal<number>>; + recentApps: ReturnType<typeof signal<StApp[]>>; + lastFetched: ReturnType<typeof signal<Date | null>>; + loaded$: Subject<void>; + }; + + beforeEach(async () => { + // Warm cache: load() resolves at once, as on a revisit of the home page. + eds = { + appCount: signal(1), + orgCount: signal(1), + routeCount: signal(0), + recentApps: signal([app('STARTED')]), + lastFetched: signal<Date | null>(new Date()), + loaded$: new Subject<void>(), + }; + await TestBed.configureTestingModule({ + imports: [CFHomeCardComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: EndpointDataRegistry, useValue: { acquire: () => eds, release: vi.fn() } }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }) + .overrideComponent(CFHomeCardComponent, { + set: { providers: [{ provide: ApplicationDeploySourceTypes, useValue: mockTypes }] }, + }) + .overrideComponent(CFHomeCardComponent, { + remove: { imports: [CardCfRecentAppsComponent] }, + add: { imports: [StubRecentAppsComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CFHomeCardComponent); + fixture.componentInstance.endpoint = { guid: 'test-guid' } as any; + fixture.componentInstance.layout = { x: 1, y: 1 } as any; + fixture.componentInstance.load().subscribe(); + await fixture.whenStable(); + }); + + const mockTypes = { types$: of([]) }; + const shown = () => Array.from(fixture.nativeElement.querySelectorAll('.stub-app')) + .map((el: any) => el.textContent); + const noApps = () => fixture.nativeElement.querySelector('.cf-home-card__no-apps'); + + it('shows a state change made after load', async () => { + expect(shown()).toEqual(['one:STARTED']); + eds.recentApps.set([app('STOPPED')]); + await fixture.whenStable(); + expect(shown()).toEqual(['one:STOPPED']); + }); + + it('shows the no-apps message when the last app is deleted', async () => { + expect(noApps()).toBeNull(); + eds.recentApps.set([]); + eds.appCount.set(0); + await fixture.whenStable(); + expect(noApps()).not.toBeNull(); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.ts b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.ts index 7598611e01..2d82febe45 100644 --- a/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/home/cfhome-card/cfhome-card.component.ts @@ -1,8 +1,9 @@ import { AsyncPipe, CommonModule } from '@angular/common'; -import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, inject, OnDestroy } from '@angular/core'; +import { Component, Input, ChangeDetectionStrategy, computed, inject, OnDestroy, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { Observable, of } from 'rxjs'; -import { map, take, startWith } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; import { EndpointDataService } from '../../../services/endpoint-data/endpoint-data.service'; import { stAppToAPIResource } from '../../../services/endpoint-data/st-app-adapter'; @@ -51,39 +52,56 @@ import { TileSelectorComponent } from '@stratosui/core'; }) export class CFHomeCardComponent implements HomePageEndpointCard, OnDestroy { private router = inject(Router); - private cdr = inject(ChangeDetectorRef); private registry = inject(EndpointDataRegistry); - endpointDataService: EndpointDataService | null = null; + // Set by load(); the template and the derived state below read it. + readonly endpointDataService = signal<EndpointDataService | null>(null); - pLayout!: HomePageCardLayout; + readonly cardLayout = signal(new HomePageCardLayout(1, 1)); - get layout(): HomePageCardLayout { return this.pLayout; } + get layout(): HomePageCardLayout { return this.cardLayout(); } @Input() set layout(value: HomePageCardLayout) { - if (value) { this.pLayout = value; } - this.updateLayout(); + if (value) { this.cardLayout.set(value); } } @Input() set endpoint(value: EndpointModel) { // strict: a registered endpoint always carries a guid; it is only // optional on the shared EndpointModel shape. - this.guid = value.guid!; + this.guid.set(value.guid!); } - // strict: populated by the @Input endpoint setter the home framework - // always supplies before load()/template read. - guid!: string; - recentAppsRows = 10; - hasNoApps$!: Observable<boolean>; - allApps$!: Observable<APIResource<IApp>[]>; - cardLoaded = false; - showDeployAppTiles = false; + // Populated by the @Input endpoint setter the home framework always + // supplies before load()/template read. + readonly guid = signal(''); + readonly cardLoaded = computed(() => this.endpointDataService() !== null); + readonly recentAppsRows = computed(() => { + const layout = this.cardLayout(); + if (layout.x > 2) { + return 0; + } else if (layout.y > 1) { + return 5; + } else if (layout.x === 2) { + return 7; + } + return 10; + }); + readonly showDeployAppTiles = computed(() => this.cardLayout().x === 1 && this.cardLayout().y === 1); + + // Follows the service's recent-apps signal, so the list picks up the app + // mutators (start/stop, create, delete) without another load(). + private readonly allApps = computed<APIResource<IApp>[]>(() => + this.endpointDataService()?.recentApps().map(app => stAppToAPIResource(app)) ?? []); + readonly allApps$ = toObservable(this.allApps); + // Only once a load has succeeded: before then appCount is just the default 0. + readonly hasNoApps = computed(() => { + const eds = this.endpointDataService(); + return !!eds && eds.lastFetched() !== null && eds.appCount() === 0; + }); public tileSelectorConfig$: Observable<ITileConfig<IAppTileData>[]>; constructor() { const appDeploySourceTypes = inject(ApplicationDeploySourceTypes); - this.pLayout = new HomePageCardLayout(1, 1); this.tileSelectorConfig$ = appDeploySourceTypes.types$.pipe( map(types => types.map(type => // strict: every SourceType emitted by types$ defines a graphic (the @@ -105,8 +123,8 @@ export class CFHomeCardComponent implements HomePageEndpointCard, OnDestroy { if (tile && tile.data) { const data = tile.data as IAppTileData; const query: Record<string, string> = { - [BASE_REDIRECT_QUERY]: `applications/new/${this.guid}`, - [AUTO_SELECT_CF_URL_PARAM]: this.guid, + [BASE_REDIRECT_QUERY]: `applications/new/${this.guid()}`, + [AUTO_SELECT_CF_URL_PARAM]: this.guid(), }; if (data.subType) { query[AUTO_SELECT_DEPLOY_TYPE_URL_PARAM] = data.subType; } if (data.endpointGuid) { query[AUTO_SELECT_DEPLOY_TYPE_ENDPOINT_PARAM] = data.endpointGuid; } @@ -115,26 +133,14 @@ export class CFHomeCardComponent implements HomePageEndpointCard, OnDestroy { } load(): Observable<boolean> { - this.cardLoaded = true; - this.endpointDataService = this.registry.acquire(this.guid); - this.cdr.markForCheck(); + const endpointDataService = this.registry.acquire(this.guid()); + this.endpointDataService.set(endpointDataService); - // afterLoad$ resolves immediately on cache hit, otherwise completes when the - // parallel native-route fetch finishes. Both allApps$ and hasNoApps$ share it. - const afterLoad$ = this.afterLoad(); - this.allApps$ = afterLoad$.pipe( - map(() => this.endpointDataService!.recentApps().map(app => stAppToAPIResource(app))), - startWith([] as APIResource<IApp>[]), - ); - this.hasNoApps$ = afterLoad$.pipe( - map(() => this.endpointDataService!.appCount() === 0), - ); - - if (this.endpointDataService.lastFetched() !== null) { + if (endpointDataService.lastFetched() !== null) { return of(true); } return new Observable<boolean>(subscriber => { - const sub = this.endpointDataService!.loaded$.pipe(take(1)).subscribe({ + const sub = endpointDataService.loaded$.pipe(take(1)).subscribe({ next: () => { subscriber.next(true); subscriber.complete(); }, error: () => { subscriber.next(true); subscriber.complete(); }, }); @@ -143,31 +149,9 @@ export class CFHomeCardComponent implements HomePageEndpointCard, OnDestroy { } ngOnDestroy(): void { - if (this.endpointDataService) { - this.registry.release(this.guid); + if (this.endpointDataService()) { + this.registry.release(this.guid()); } } - public updateLayout() { - if (this.layout.x > 2) { - this.recentAppsRows = 0; - } else if (this.layout.y > 1) { - this.recentAppsRows = 5; - } else if (this.layout.x === 2) { - this.recentAppsRows = 7; - } else { - this.recentAppsRows = 10; - } - this.showDeployAppTiles = this.layout.x === 1 && this.layout.y === 1; - this.cdr.markForCheck(); - } - - // Emits once when data is available — immediately on cache hit, after load otherwise. - private afterLoad(): Observable<void> { - const svc = this.endpointDataService!; - return svc.lastFetched() !== null - ? of(undefined as void) - : svc.loaded$.pipe(take(1)); - } - } From d8ccf337ae029260db4f5d0b6e5ef594c44b2dd5 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:24:08 -0700 Subject: [PATCH 077/121] fix(kube): render the resource viewer header The viewer set its header, title and favorite in a setTimeout under OnPush, so the Delete header only appeared after another panel interaction. They are now signals and a viewChild computed. Delete threw when the caller gave no definition (the helm release graph never does); it now falls back to the resource kind. --- .../kubernetes-resource-viewer.component.html | 6 +- ...bernetes-resource-viewer.component.spec.ts | 58 ++++++++++++++++ .../kubernetes-resource-viewer.component.ts | 69 ++++++++++++------- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html index f887e6cdfd..8c18083d97 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html @@ -1,4 +1,4 @@ -<app-sidepanel-preview [header]="headerContent" class="text-sm" title="{{title}}" [favorite]="favorite"> +<app-sidepanel-preview [header]="headerContent()" class="text-sm" [title]="title()" [favorite]="favorite()"> @if (resource$ | async; as resource) { <div> @@ -35,9 +35,9 @@ } </app-metadata-item> } - @if (alerts) { + @if (alerts()) { <app-metadata-item class="table" label="Alerts"> - <app-resource-alert-view [showHeader]="false" [alerts]="alerts"></app-resource-alert-view> + <app-resource-alert-view [showHeader]="false" [alerts]="alerts()"></app-resource-alert-view> </app-metadata-item> } <!-- Custom component if there is one --> diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts index 5746ea1377..a285e29e00 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts @@ -2,10 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; import { ConfirmationDialogService } from '../../../../core/src/shared/components/confirmation-dialog.service'; import { TailwindSnackBarService } from '../../../../core/src/shared/services/tailwind-snackbar.service'; import { SidePanelService } from '../../../../core/src/shared/services/side-panel.service'; +import { UserFavoriteManager } from '../../../../store/src/user-favorite-manager'; import { KubePodDataService } from '../../services/domain-data/kube-pod-data.service'; import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../kubernetes.testing.module'; import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; @@ -75,4 +77,60 @@ describe('KubernetesResourceViewerComponent', () => { expect(deleteSpy).toHaveBeenCalledWith('cnsi-1', 'p1', 'ns-a'); expect(snackSpy).toHaveBeenCalledWith(`Deleted resource 'p1'`); }); + const pod = { + kind: 'Pod', + apiVersion: 'v1', + metadata: { name: 'p1', namespace: 'ns-a', creationTimestamp: '2026-01-01T00:00:00Z' }, + } as any; + + // Wait for every pending timer and the zoneless scheduler, with no + // extra fixture.detectChanges(): OnPush only re-renders on its own. + const settle = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await fixture.whenStable(); + }; + + it('renders the delete header once the panel opens, with no other interaction', async () => { + component.setProps({ title: 'Preview', resource$: of(pod), resourceKind: 'Pod' }); + await settle(); + + expect(fixture.nativeElement.querySelector('button[name="delete"]')).not.toBeNull(); + }); + + it('renders the favorite star when the resource can be favorited', async () => { + const favManager = TestBed.inject(UserFavoriteManager); + vi.spyOn(favManager, 'canFavoriteEntityType').mockReturnValue(true); + vi.spyOn(favManager, 'getFavorite').mockReturnValue({ guid: 'fav-1', entityType: 'pod', getPrettyTypeName: () => 'Pod' } as any); + + component.setProps({ + title: 'Preview', + resource$: of(pod), + resourceKind: 'Pod', + definition: { type: 'pod', label: 'Pod' }, + }); + await settle(); + + expect(fixture.nativeElement.querySelector('app-entity-favorite-star')).not.toBeNull(); + }); + + it('deleteWarn falls back to the resource kind when there is no definition', async () => { + const podData = TestBed.inject(KubePodDataService); + const deleteSpy = vi.spyOn(podData, 'delete').mockResolvedValue(undefined); + vi.spyOn(TestBed.inject(TailwindSnackBarService), 'show').mockImplementation(() => undefined as any); + const sidePanel = TestBed.inject(SidePanelService); + vi.spyOn(sidePanel, 'hide').mockImplementation(() => undefined as any); + let title = ''; + vi.spyOn(TestBed.inject(ConfirmationDialogService), 'openWithCancel') + .mockImplementation((config: any, onConfirm: () => void) => { title = config.title; onConfirm(); return undefined as any; }); + + // The helm release resource graph opens the viewer with no definition. + component.setProps({ title: 'Preview', resource$: of(pod), resourceKind: 'Pod' }); + await settle(); + + expect(() => component.deleteWarn()).not.toThrow(); + await Promise.resolve(); + + expect(title).toBe('Delete Pod'); + expect(deleteSpy).toHaveBeenCalledWith(expect.anything(), 'p1', 'ns-a'); + }); }); 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 4ca38b139b..763a6d9e96 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 @@ -8,7 +8,8 @@ import { TemplateRef, ViewChild, ViewContainerRef, - inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; + computed, + inject, ChangeDetectionStrategy, ChangeDetectorRef, signal, viewChild } from '@angular/core'; import { RouterLink } from '@angular/router'; import { formatDistance } from 'date-fns'; import { Observable, of } from 'rxjs'; @@ -106,18 +107,33 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, serviceAccount: inject(KubeServiceAccountDataService), }; + // Resource kind to catalog type, for a delete opened with no definition + // (the helm release resource graph passes only the resource kind). + private kindTypes: Record<string, string> = { + Pod: 'pod', + Service: 'service', + Job: 'job', + Secret: 'secrets', + PersistentVolumeClaim: 'pvc', + ReplicaSet: 'replicaSet', + Role: 'role', + ServiceAccount: 'serviceAccount', + }; + + public title = signal(''); + // strict: the following are assigned by setProps, the previewable-component // entry point the framework invokes before the template renders them. - public title!: string; public resource$!: Observable<KubernetesResourceViewerResource>; public hasPodMetrics$!: Observable<boolean>; public podRouterLink$!: Observable<string[]>; private analysis: any; - public alerts: any; + private resourceKind?: string; + public alerts = signal<any>(null); - public favorite?: UserFavorite<IFavoriteMetadata>; + public favorite = signal<UserFavorite<IFavoriteMetadata> | undefined>(undefined); // Custom component @ViewChild('customComponent', { read: ViewContainerRef, static: false }) customComponentContainer: ViewContainerRef | undefined; @@ -127,9 +143,11 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, data!: KubernetesResourceViewerComponentConfig; // strict: assigned in setProps before deleteWarn can fire - // strict: static:false ViewChild resolved by ngAfterViewInit, where headerContent is built - @ViewChild('header', { static: false }) templatePortalContent!: TemplateRef<unknown>; - headerContent!: Portal<any>; + private headerTemplate = viewChild<TemplateRef<unknown>>('header'); + headerContent = computed<Portal<any> | undefined>(() => { + const template = this.headerTemplate(); + return template ? new TemplatePortal(template, this.viewContainerRef) : undefined; + }); ngOnDestroy(): void { this.removeCustomComponent(); @@ -154,12 +172,12 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, ngAfterViewInit(): void { this.createCustomComponent(); - setTimeout(() => this.headerContent = new TemplatePortal(this.templatePortalContent, this.viewContainerRef), 0); } setProps(props: KubernetesResourceViewerConfig): void { - this.title = props.title; + this.title.set(props.title); this.analysis = props.analysis; + this.resourceKind = props.resourceKind; this.component = props.component; this.resource$ = props.resource$.pipe( @@ -218,7 +236,7 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, }; this.createCustomComponent(); - setTimeout(() => this.setFavorite(props.definition, item), 0); + this.setFavorite(props.definition, item); // Apply analysis if there is one - if this is a k8s resource (i.e. not a container) if (item.metadata) { @@ -273,18 +291,18 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, let id = (resource.kind || 'pod').toLowerCase(); id = `${id}/${resource.raw.metadata.namespace}/${resource.raw.metadata.name}`; if (this.analysis && this.analysis.alerts[id]) { - this.alerts = this.analysis.alerts[id]; + this.alerts.set(this.analysis.alerts[id]); } else { - this.alerts = null; + this.alerts.set(null); } } - private setFavorite(defn: KubeResourceEntityDefinition, item: BasicKubeAPIResource): void { + private setFavorite(defn: KubeResourceEntityDefinition | undefined, item: BasicKubeAPIResource): void { if (defn) { const entityDefn = entityCatalog.getEntity(KUBERNETES_ENDPOINT_TYPE, defn.type); const canFav = this.userFavoriteManager.canFavoriteEntityType(entityDefn); if (canFav) { - this.favorite = this.userFavoriteManager.getFavorite(item, defn.type, KUBERNETES_ENDPOINT_TYPE) ?? undefined; + this.favorite.set(this.userFavoriteManager.getFavorite(item, defn.type, KUBERNETES_ENDPOINT_TYPE) ?? undefined); } } } @@ -293,10 +311,14 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, public deleteWarn(): void { // Namespace vs Pod definition in different places const defnRaw = this.data.definition as any; - const defn = (defnRaw?.definition || this.data.definition) as KubeResourceEntityDefinition; + const defn = (defnRaw?.definition || this.data.definition) as KubeResourceEntityDefinition | undefined; + // With no definition, fall back to the resource kind + const kind = (this.data.resource as { kind?: string }).kind || this.resourceKind || ''; + const label = defn?.label || kind || 'Resource'; + const type = defn?.type || this.kindTypes[kind]; this.sidePanelService.hide(); const confirmation = new ConfirmationDialogConfig( - `Delete ${defn.label}`, + `Delete ${label}`, `Are you sure you want to delete "${this.data.resource.metadata.name}" ?`, 'Delete', true, @@ -305,9 +327,9 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, () => { const name = this.data.resource.metadata.name; const namespace = this.data.resource.metadata.namespace; - const dataService = this.deleteServices[defn.type]; + const dataService = type ? this.deleteServices[type] : undefined; if (!dataService) { - this.snackBarService.show(`Delete not supported for ${defn.label}`); + this.snackBarService.show(`Delete not supported for ${label}`); return; } const endpointId = this.data.endpointId; @@ -320,12 +342,13 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent, // path used to clean up, then surface the same snackbar. dataService.delete(endpointId, name, namespace) .then(() => { - if (this.favorite && this.favorite.entityId) { + const favorite = this.favorite(); + if (favorite && favorite.entityId) { this.deleteCleanup.removeFavoriteAndRecent( - this.favorite.endpointId, - this.favorite.endpointType, - this.favorite.entityType, - this.favorite.entityId, + favorite.endpointId, + favorite.endpointType, + favorite.entityType, + favorite.entityId, ); } this.snackBarService.show(`Deleted resource '${name}'`); From e29c852ddfe4b2046534dc749e6c8ccd4479b76f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:24:08 -0700 Subject: [PATCH 078/121] fix(helm): show the catalog repository sidebar The sidebar's hide and collapsed state were plain fields set after an await under OnPush, so the sidebar stayed hidden on every visit. hide is now a computed and collapsed a linkedSignal, both from the endpoint list. --- .../catalog-tab/catalog-tab.component.html | 13 ++-- .../catalog-tab/catalog-tab.component.spec.ts | 44 ++++++++++- .../tabs/catalog-tab/catalog-tab.component.ts | 76 ++++++------------- 3 files changed, 73 insertions(+), 60 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html index ece224fb91..8623ae8b37 100644 --- a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html @@ -2,13 +2,12 @@ [endpoints$]="connectedHelmEndpoints$" nounPlural="Charts"></app-duplicate-url-banner> <div class="flex flex-row max-h-full"> - @if (!hide) { + @if (!hide()) { <div class="flex flex-col max-h-full overflow-auto" - [ngClass]="{'mr-0 min-w-0 w-0': collapsed, 'mr-2.5 min-w-[200px] w-auto': !collapsed}"> + [ngClass]="{'mr-0 min-w-0 w-0': collapsed(), 'mr-2.5 min-w-[200px] w-auto': !collapsed()}"> <h3 class="text-base my-1.5">Filter By Repository</h3> <app-form-field> - <input class="w-full placeholder:!text-transparent focus:outline-none" appInput placeholder="Filter by Repository" name="searchReposValue" [(ngModel)]="searchReposValue" - (ngModelChange)="searchReposChange($event)"> + <input class="w-full placeholder:!text-transparent focus:outline-none" appInput placeholder="Filter by Repository" name="searchReposValue" [(ngModel)]="searchReposValue"> </app-form-field> <div class="max-h-full overflow-auto pr-2.5"> @if (stratosRepos().length) { @@ -34,12 +33,12 @@ <h4 class="text-[15px] my-2">Artifact Hub Repositories</h4> } <div class="flex-1 min-h-0 flex flex-col"> <div class="flex items-end my-1"> - <button class="btn btn-ghost mr-2.5" (click)="collapsed = !collapsed"> + <button class="btn btn-ghost mr-2.5" (click)="collapsed.set(!collapsed())"> <span class="material-symbols-outlined">filter_list</span> - @if (collapsed) { + @if (collapsed()) { <span class="material-symbols-outlined">chevron_right</span> } - @if (!collapsed) { + @if (!collapsed()) { <span class="material-symbols-outlined">chevron_left</span> } </button> diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts index 8e9adbcce1..a0d932dac7 100644 --- a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts @@ -15,6 +15,7 @@ import { MockChartService } from '../../monocular/shared/services/chart.service. import { ChartsService } from '../../monocular/shared/services/charts.service'; import { CatalogTabComponent } from './catalog-tab.component'; import { generateHelmEntities } from '../../helm-entity-generator'; +import { HELM_ENDPOINT_TYPE, HELM_HUB_ENDPOINT_TYPE, HELM_REPO_ENDPOINT_TYPE } from '../../helm-entity-factory'; describe('CatalogTabComponent', () => { let component: CatalogTabComponent; @@ -51,11 +52,14 @@ describe('CatalogTabComponent', () => { }).compileComponents(); }); + let endpointsList: ReturnType<typeof signal<any[]>>; + beforeEach(() => { // Wave 5 (W36-B): seed EndpointsDataService with empty signals to // satisfy the component's signal-bridge over the data service. const endpointsData = TestBed.inject(EndpointsDataService); - Object.defineProperty(endpointsData, 'endpointsList', { value: signal([]) }); + endpointsList = signal<any[]>([]); + Object.defineProperty(endpointsData, 'endpointsList', { value: endpointsList }); Object.defineProperty(endpointsData, 'loading', { value: signal(false) }); fixture = TestBed.createComponent(CatalogTabComponent); @@ -75,4 +79,42 @@ describe('CatalogTabComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + const helmEndpoint = (guid: string, subType: string) => ({ guid, cnsi_type: HELM_ENDPOINT_TYPE, sub_type: subType }); + const sidebar = (): HTMLElement | null => + [...fixture.nativeElement.querySelectorAll('h3')] + .find((h: HTMLElement) => h.textContent?.includes('Filter By Repository'))?.parentElement ?? null; + + it('shows the repository sidebar when there are several helm repositories', async () => { + endpointsList.set([helmEndpoint('r1', HELM_REPO_ENDPOINT_TYPE), helmEndpoint('r2', HELM_REPO_ENDPOINT_TYPE)]); + await fixture.whenStable(); + + expect(sidebar()).not.toBeNull(); + expect(component.collapsed()).toBe(true); + }); + + it('shows the repository sidebar expanded when Artifact Hub is connected', async () => { + endpointsList.set([helmEndpoint('hub', HELM_HUB_ENDPOINT_TYPE)]); + await fixture.whenStable(); + + expect(sidebar()?.className).toContain('min-w-[200px]'); + }); + + it('hides the repository sidebar when there is exactly one helm repository', async () => { + endpointsList.set([helmEndpoint('r1', HELM_REPO_ENDPOINT_TYPE)]); + await fixture.whenStable(); + + expect(sidebar()).toBeNull(); + }); + it('expands the collapsed sidebar from the filter toggle', async () => { + endpointsList.set([helmEndpoint('r1', HELM_REPO_ENDPOINT_TYPE), helmEndpoint('r2', HELM_REPO_ENDPOINT_TYPE)]); + await fixture.whenStable(); + expect(sidebar()?.className).toContain('w-0'); + + const toggle = [...fixture.nativeElement.querySelectorAll('button')] + .find((b: HTMLElement) => b.textContent?.includes('filter_list')) as HTMLButtonElement; + toggle.click(); + await fixture.whenStable(); + + expect(sidebar()?.className).toContain('min-w-[200px]'); + }); }); 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 b890bccd76..aee8e73c75 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 @@ -1,9 +1,11 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, OnDestroy, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, Injector, OnDestroy, Signal, WritableSignal, computed, inject, linkedSignal, signal, +} from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription, firstValueFrom } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { filter, map, publishReplay, refCount, take } from 'rxjs/operators'; import { EndpointsSignalService } from '../../../../../core/src/core/signals/endpoints-signal.service'; @@ -38,16 +40,6 @@ import { ChartItemComponent } from '../../monocular/chart-item/chart-item.compon }) export class CatalogTabComponent implements OnDestroy { - // Sidebar visibility / collapse state — preserved from the legacy - // component. `hide` flips off when there is exactly one helm endpoint - // (no need for repo filtering); `collapsed` is the user-toggle. - public collapsed = true; - public hide = true; - - // Two-way ngModel target for the sidebar's repo-name search input. - public searchReposValue = ''; - private readonly searchRepos: WritableSignal<string> = signal(''); - private sub: Subscription | undefined; private readonly activatedRoute = inject(ActivatedRoute); private readonly endpointsData = inject(EndpointsDataService); @@ -55,10 +47,24 @@ export class CatalogTabComponent implements OnDestroy { private readonly injector = inject(Injector); readonly signalConfig = inject(MonocularChartsSignalConfigService); - // W36-B Wave 3: Pre-built signal-bridge over EndpointsDataService. - // Reused by both the artifactHubAndHelmRepoTypes$ stream and the - // computeSidebarVisibility() one-shot read so we don't recreate - // toObservable() on every change-detection cycle. + // Sidebar visibility / collapse state — preserved from the legacy + // component. `hide` is on when there is exactly one helm endpoint and it + // is not Artifact Hub (no need for repo filtering). `collapsed` is the + // user-toggle; it starts expanded when Artifact Hub is attached. + private readonly helmEndpoints = computed(() => + this.endpointsData.endpointsList().filter(ep => ep?.cnsi_type === HELM_ENDPOINT_TYPE), + ); + private readonly hasArtifactHub = computed(() => + this.helmEndpoints().some(ep => ep.sub_type === HELM_HUB_ENDPOINT_TYPE), + ); + readonly hide = computed(() => !this.hasArtifactHub() && this.helmEndpoints().length === 1); + readonly collapsed = linkedSignal(() => !this.hasArtifactHub()); + + // Two-way ngModel target for the sidebar's repo-name search input. + readonly searchReposValue: WritableSignal<string> = signal(''); + + // W36-B Wave 3: Pre-built signal-bridge over EndpointsDataService for the + // artifactHubAndHelmRepoTypes$ stream. private readonly endpoints$ = toObservable(this.endpointsData.endpointsList, { injector: this.injector }); // Connected helm endpoints for the duplicate-URL banner — scoped to helm @@ -95,11 +101,11 @@ export class CatalogTabComponent implements OnDestroy { // Repo-name lists filtered by the sidebar search. readonly stratosRepos: Signal<string[]> = computed(() => { - const q = this.searchRepos().toLowerCase(); + const q = this.searchReposValue().toLowerCase(); return this.signalConfig.stratosRepos().filter(n => !q || n.toLowerCase().startsWith(q)); }); readonly artifactHubRepos: Signal<string[]> = computed(() => { - const q = this.searchRepos().toLowerCase(); + const q = this.searchReposValue().toLowerCase(); return this.signalConfig.artifactHubRepos().filter(n => !q || n.toLowerCase().startsWith(q)); }); @@ -107,11 +113,6 @@ export class CatalogTabComponent implements OnDestroy { get filteredRepo(): string { return this.signalConfig.repositoryFilter(); } constructor() { - // Determine sidebar visibility — match the legacy logic. Hide the - // sidebar entirely if there is exactly one helm endpoint (and it's - // not Artifact Hub). - void this.computeSidebarVisibility(); - this.signalConfig.initialize(); void this.signalConfig.loadAll(); @@ -165,39 +166,10 @@ export class CatalogTabComponent implements OnDestroy { }); } - private async computeSidebarVisibility(): Promise<void> { - try { - const endpoints = await firstValueFrom( - this.endpoints$.pipe( - filter(e => !!e), - take(1), - ), - ) as EndpointModel[]; - let stratosHelmEndpoints = 0; - for (const ep of endpoints) { - if (ep.cnsi_type !== HELM_ENDPOINT_TYPE) continue; - stratosHelmEndpoints++; - if (ep.sub_type === HELM_HUB_ENDPOINT_TYPE) { - // Artifact Hub attached — always show the filter sidebar. - this.collapsed = false; - this.hide = false; - return; - } - } - this.hide = stratosHelmEndpoints === 1; - } catch { - this.hide = true; - } - } - filterCharts(repoName?: string): void { this.signalConfig.repositoryFilter.set(repoName ?? ''); } - searchReposChange(repoName: string): void { - this.searchRepos.set(repoName ?? ''); - } - ngOnDestroy(): void { this.sub?.unsubscribe(); } From 0a1d54995c530e2dc79101f8803b442e08beeaba Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:24:08 -0700 Subject: [PATCH 079/121] fix(kube): read the dashboard flag, not the terminal kubeDashboardEnabled$ read kubeTerminalEnabled, so the Summary tab hid the Dashboard block whenever the terminal was off. It reads the kubeDashboardEnabled flag the backend sends. --- .../kubernetes-endpoint.service.spec.ts | 41 +++++++++++++++++++ .../services/kubernetes-endpoint.service.ts | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.spec.ts diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.spec.ts new file mode 100644 index 0000000000..dd83f22b5a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.spec.ts @@ -0,0 +1,41 @@ +import { HttpClient } from '@angular/common/http'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of } from 'rxjs'; +import { describe, it, expect } from 'vitest'; + +import { SessionService } from '../../../../core/src/core/session.service'; +import { EndpointsDataService } from '../../../../store/src/public-api'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from './kubernetes-endpoint.service'; + +describe('KubernetesEndpointService', () => { + // The backend sends each plugin flag under its own key + // (jetstream plugins/kubernetes main.go). + const create = (pluginConfig: Record<string, string>) => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: BaseKubeGuid, useValue: { guid: 'kube-1' } }, + { provide: SessionService, useValue: { sessionData: signal({ 'plugin-config': pluginConfig }) } }, + { provide: HttpClient, useValue: { get: () => of(null) } }, + { provide: EndpointsDataService, useValue: { waitFor: () => new Promise(() => undefined) } }, + ], + }); + return TestBed.inject(KubernetesEndpointService); + }; + + it('kubeDashboardEnabled$ follows the dashboard flag, not the terminal flag', async () => { + const service = create({ kubeDashboardEnabled: 'true', kubeTerminalEnabled: 'false' }); + + expect(await firstValueFrom(service.kubeDashboardEnabled$)).toBe(true); + expect(await firstValueFrom(service.kubeTerminalEnabled$)).toBe(false); + }); + + it('kubeDashboardEnabled$ is off when only the terminal is enabled', async () => { + const service = create({ kubeDashboardEnabled: 'false', kubeTerminalEnabled: 'true' }); + + expect(await firstValueFrom(service.kubeDashboardEnabled$)).toBe(false); + expect(await firstValueFrom(service.kubeTerminalEnabled$)).toBe(true); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts index 3ec6c522cd..ac691ec46a 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts @@ -306,7 +306,7 @@ export class KubernetesEndpointService { // mirrored back into Observables for template consumers. const pluginConfig = () => this.session.sessionData()?.['plugin-config']; - this.kubeDashboardEnabled$ = of(pluginConfig()?.kubeTerminalEnabled === 'true'); + this.kubeDashboardEnabled$ = of(pluginConfig()?.kubeDashboardEnabled === 'true'); this.kubeTerminalEnabled$ = of(pluginConfig()?.kubeTerminalEnabled === 'true'); // Wave-3.5: dashboard status now fetched directly from the kubedash From 7fa51701bea4a5f566c9a582edc9f240dd951d39 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:24:08 -0700 Subject: [PATCH 080/121] fix(helm): show the chart and readme spinners Both components set loading in a setTimeout that never rendered under OnPush, so the page stayed blank while the chart or readme loaded. loading is now a signal that starts true, and the rest of their template state is signals. --- .../chart-details-readme.component.html | 6 +- .../chart-details-readme.component.spec.ts | 17 ++++++ .../chart-details-readme.component.ts | 30 +++++----- .../chart-details.component.html | 36 ++++++------ .../chart-details.component.spec.ts | 45 ++++++++++++++- .../chart-details/chart-details.component.ts | 55 ++++++++----------- 6 files changed, 119 insertions(+), 70 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html index 3f536fdbeb..cd9c9fef84 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html @@ -1,5 +1,5 @@ <div> - <app-loader [loading]="loading"> - <div [innerHTML]="readmeContent$ | async" class="markdown-body"></div> + <app-loader [loading]="loading()"> + <div [innerHTML]="readmeContent()" class="markdown-body"></div> </app-loader> -</div> \ No newline at end of file +</div> diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts index fee1fcd542..f98f362967 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts @@ -1,6 +1,7 @@ import { NO_ERRORS_SCHEMA, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { Subject } from 'rxjs'; import { MockChartService } from '../../shared/services/chart.service.mock'; import { ChartsService } from '../../shared/services/charts.service'; @@ -24,4 +25,20 @@ describe('Component: ChartDetailsReadme', () => { const component = TestBed.createComponent(ChartDetailsReadmeComponent); expect(component).toBeTruthy(); }); + it('shows the spinner while the readme loads, then the readme', async () => { + const readme = new Subject<string>(); + vi.spyOn(TestBed.inject(ChartsService), 'getChartReadme').mockReturnValue(readme as any); + const fixture = TestBed.createComponent(ChartDetailsReadmeComponent); + fixture.componentRef.setInput('currentVersion', { id: 'v1' }); + await new Promise(resolve => setTimeout(resolve, 150)); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-busy')).not.toBeNull(); + + readme.next('# Hello'); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-busy')).toBeNull(); + expect(fixture.nativeElement.querySelector('.markdown-body h1')?.textContent).toBe('Hello'); + }); }); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts index e91e7bd76d..b891b362f5 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts @@ -1,8 +1,8 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, Input, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, inject, ChangeDetectionStrategy, input, signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { marked } from 'marked'; import { Observable, of as observableOf } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, tap } from 'rxjs/operators'; import { ChartVersion } from '../../shared/models/chart-version'; import { ChartsService } from '../../shared/services/charts.service'; @@ -15,43 +15,41 @@ import { LoaderComponent } from '../../loader/loader.component'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ - AsyncPipe, LoaderComponent ] }) export class ChartDetailsReadmeComponent { - @Input() set currentVersion(currentVersion: ChartVersion) { - if (currentVersion) { - this.readmeContent$ = this.getReadme(currentVersion); - } - } + readonly currentVersion = input<ChartVersion>(); - public loading = false; - public readmeContent$!: Observable<string>; + // Nothing renders until the readme arrives, so show the spinner from the start + public loading = signal(true); private renderer = new marked.Renderer(); - private loadingDelay: ReturnType<typeof setTimeout>; private chartsService = inject(ChartsService); + public readmeContent = toSignal(toObservable(this.currentVersion).pipe( + filter((currentVersion): currentVersion is ChartVersion => !!currentVersion), + tap(() => this.loading.set(true)), + switchMap(currentVersion => this.getReadme(currentVersion)), + )); + constructor() { this.renderer.link = ({ href, title, text }) => `<a target="_blank" title="${title}" href="${href}">${text}</a>`; this.renderer.code = ({ text, lang: _lang, escaped: _escaped }: { text: string; lang?: string; escaped?: boolean }) => `<code>${text}</code>`; - this.loadingDelay = setTimeout(() => this.loading = true, 100); } // TODO: See #150 - This should not require loading the specific version and then the readme private getReadme(currentVersion: ChartVersion): Observable<string> { return this.chartsService.getChartReadme(currentVersion).pipe( map(resp => { - clearTimeout(this.loadingDelay); - this.loading = false; + this.loading.set(false); const result = marked(resp, { renderer: this.renderer }); return typeof result === 'string' ? result : ''; }), catchError((error) => { - this.loading = false; + this.loading.set(false); if (error.status === 404) { return observableOf('<h1>No Readme available for this chart</h1>'); } else { diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html index 30d403f7c9..007db9a891 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html @@ -1,29 +1,31 @@ <div> - @if (!chart && !initing) { + @if (!chart() && !initing()) { <app-panel class="app-panel--container"> <h2>Sorry, we couldn't find the chart</h2> </app-panel> } - @if (chart && !currentVersion && !initing) { + @if (chart() && !currentVersion() && !initing()) { <app-panel class="app-panel--container"> <h2>Sorry, we couldn't find the version information for this chart</h2> </app-panel> } - <app-loader [loading]="loading"> - @if (chart && currentVersion) { - <div class="flex"> - <app-entity-summary-title class="flex-[3] block" [title]="chart.attributes.name" [subTitle]="chartSubTitle" - [subText]="chart.attributes.description" [imagePath]="iconUrl"> - <div class="w-full flex flex-col-reverse px-8 pb-8 break-words md:flex-row md:h-full"> - <article class="md:overflow-auto md:pr-4 md:min-w-0 md:flex-[3]"> - <app-chart-details-readme [currentVersion]="currentVersion"></app-chart-details-readme> - </article> - </div> - </app-entity-summary-title> - <aside class="md:mr-12 md:overflow-auto"> - <app-chart-details-info [chart]="chart" [currentVersion]="currentVersion"></app-chart-details-info> - </aside> - </div> + <app-loader [loading]="loading()"> + @if (chart(); as chart) { + @if (currentVersion(); as currentVersion) { + <div class="flex"> + <app-entity-summary-title class="flex-[3] block" [title]="chart.attributes.name" [subTitle]="chartSubTitle()" + [subText]="chart.attributes.description" [imagePath]="iconUrl()"> + <div class="w-full flex flex-col-reverse px-8 pb-8 break-words md:flex-row md:h-full"> + <article class="md:overflow-auto md:pr-4 md:min-w-0 md:flex-[3]"> + <app-chart-details-readme [currentVersion]="currentVersion"></app-chart-details-readme> + </article> + </div> + </app-entity-summary-title> + <aside class="md:mr-12 md:overflow-auto"> + <app-chart-details-info [chart]="chart" [currentVersion]="currentVersion"></app-chart-details-info> + </aside> + </div> + } } </app-loader> </div> \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts index c80da10b2d..43c640205c 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Subject, of } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; import { EntitySummaryTitleComponent } from '@stratosui/core'; @@ -68,3 +69,45 @@ describe('ChartDetailsComponent', () => { expect(component).toBeTruthy(); }); }); + +describe('ChartDetailsComponent loading', () => { + let fixture: ComponentFixture<ChartDetailsComponent>; + let chart: Subject<any>; + // chart-details-info reads the schema once the chart renders + const charts = Object.assign(new MockChartService(), { getChartSchema: () => of(null) }); + + beforeEach(async () => { + chart = new Subject<any>(); + vi.spyOn(charts, 'getChart').mockReturnValue(chart); + await TestBed.configureTestingModule({ + imports: [...BaseTestModulesNoShared, HelmTestingModule, ChartDetailsComponent], + providers: [ + ...STORE_TEST_PROVIDERS, + { provide: ChartsService, useValue: charts }, + { provide: ConfigService, useValue: { appName: 'appName' } }, + { provide: ActivatedRoute, useValue: { + params: { forEach: (fn: any) => fn({ repo: 'incubator', chartName: 'test' }) }, + snapshot: { params: { endpoint: 'stratos' }, queryParams: {} } + }}, + provideZonelessChangeDetection() + ] + }).compileComponents(); + fixture = TestBed.createComponent(ChartDetailsComponent); + fixture.detectChanges(); + }); + + it('shows the spinner while the chart loads, then the chart', async () => { + await new Promise(resolve => setTimeout(resolve, 150)); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-busy')).not.toBeNull(); + + const mockChart = await new Promise<any>(resolve => new MockChartService().getChart().subscribe(resolve)); + chart.next(mockChart); + chart.complete(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-busy')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-chart-details-readme')).not.toBeNull(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts index a65e5ced8c..a369d4e66a 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts @@ -1,5 +1,5 @@ -import {Component, OnInit, inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import {Component, OnInit, inject, ChangeDetectionStrategy, signal } from '@angular/core'; import { ActivatedRoute, Params } from '@angular/router'; import { take, finalize, switchMap, tap } from 'rxjs/operators'; @@ -30,29 +30,18 @@ import { ChartDetailsReadmeComponent } from './chart-details-readme/chart-detail }) export class ChartDetailsComponent implements OnInit { /* This resource will be different, probably ChartVersion */ - chart!: Chart; - loading = false; - initing = true; - currentVersion!: ChartVersion; - iconUrl!: string; - titleVersion!: string; - chartSubTitle!: string; + chart = signal<Chart | undefined>(undefined); + // Nothing renders until the chart arrives, so show the spinner from the start + loading = signal(true); + initing = signal(true); + currentVersion = signal<ChartVersion | undefined>(undefined); + iconUrl = signal(''); + titleVersion = ''; + chartSubTitle = signal(''); - loadingDelay: ReturnType<typeof setTimeout>; private route = inject(ActivatedRoute); private chartsService = inject(ChartsService); private config = inject(ConfigService); - private cdr = inject(ChangeDetectorRef); - - - - constructor() { - - - this.loadingDelay = setTimeout(() => this.loading = true, 100); - - - } ngOnInit() { this.route.params.forEach((params: Params) => { @@ -60,29 +49,28 @@ export class ChartDetailsComponent implements OnInit { const chartName = params.chartName; if (chartName) { + this.loading.set(true); this.chartsService.getChart(repo, chartName).pipe( take(1), switchMap(chart => { - clearTimeout(this.loadingDelay); - this.chart = chart; - this.chartSubTitle = chart.attributes.repo.name; + this.chart.set(chart); + let subTitle = chart.attributes.repo.name; if (getMonocularEndpoint(this.route, chart) !== stratosMonocularEndpointGuid) { - this.chartSubTitle = 'Artifact Hub - ' + this.chartSubTitle; + subTitle = 'Artifact Hub - ' + subTitle; } - const version = params.version || this.chart.relationships.latestChartVersion.data.version; + this.chartSubTitle.set(subTitle); + const version = params.version || chart.relationships.latestChartVersion.data.version; return this.chartsService.getVersion(repo, chartName, version).pipe(take(1)); }), tap(chartVersion => { - this.currentVersion = chartVersion; - this.titleVersion = this.currentVersion.attributes.app_version || ''; + this.currentVersion.set(chartVersion); + this.titleVersion = chartVersion.attributes.app_version || ''; this.updateMetaTags(); - this.iconUrl = this.chartsService.getChartIconURL(this.chart, chartVersion); + this.iconUrl.set(this.chartsService.getChartIconURL(this.chart()!, chartVersion)); }), finalize(() => { - clearTimeout(this.loadingDelay); - this.loading = false; - this.initing = false; - this.cdr.markForCheck(); + this.loading.set(false); + this.initing.set(false); }) ).subscribe(); } @@ -96,6 +84,7 @@ export class ChartDetailsComponent implements OnInit { updateMetaTags(): void { } goToRepoUrl(): string { - return `/charts/${getMonocularEndpoint(undefined, this.chart)}/${this.chart.attributes.repo.name}`; + const chart = this.chart()!; + return `/charts/${getMonocularEndpoint(undefined, chart)}/${chart.attributes.repo.name}`; } } From 19b84906f157910973f6cabfec3d39970250e63c Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:24:08 -0700 Subject: [PATCH 081/121] docs(kube): match release endpoint comments to code The comments on the one-endpoint and several-endpoint blocks were swapped. Specs pin both behaviours. --- .../create-release.component.spec.ts | 23 ++++++++++++++++++- .../create-release.component.ts | 5 ++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts index 292c68202e..ff0b57b128 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts @@ -4,9 +4,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA, Signal, provideZonelessChangeDetection, signal } from '@angular/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { ActivatedRoute, provideRouter } from '@angular/router'; -import { firstValueFrom, Observable } from 'rxjs'; +import { firstValueFrom, Observable, of } from 'rxjs'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { EndpointsService } from '../../../../../core/src/core/endpoints.service'; import { KubeNamespaceDataService } from '../../../services/domain-data/kube-namespace-data.service'; import { AppTestModule } from '../../../../../core/test-framework/core-test.helper'; @@ -156,6 +157,26 @@ describe('CreateReleaseComponent', () => { expect(component).toBeTruthy(); }); + // Endpoint preselection: a single cluster is selected outright, and with + // several the first one is preselected. + const createWithEndpoints = (guids: string[]) => { + vi.spyOn(TestBed.inject(EndpointsService), 'connectedEndpointsOfTypes') + .mockReturnValue(of(guids.map(guid => ({ guid, name: guid }))) as any); + const created = TestBed.createComponent(CreateReleaseComponent); + created.detectChanges(); + // Drop the second component's chart requests; only the form matters here. + httpMock.match(() => true); + return created.componentInstance; + }; + + it('selects the only kubernetes endpoint', () => { + expect(createWithEndpoints(['k1']).details.controls.endpoint.value).toBe('k1'); + }); + + it('preselects the first of several kubernetes endpoints', () => { + expect(createWithEndpoints(['k1', 'k2']).details.controls.endpoint.value).toBe('k1'); + }); + it('createNamespace delegates to KubeNamespaceDataService.create on success', async () => { component.details.controls.endpoint.setValue('k1'); component.details.controls.releaseNamespace.setValue('ns-new'); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts index a42086e7df..60e6f51a6f 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts @@ -226,7 +226,7 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { map(() => this.details.valid) ); - // Auto-select first endpoint + // Auto select endpoint if there is only one this.kubeEndpoints$.pipe(take(1)).subscribe(endpoints => { if (endpoints.length === 1) { this.details.controls.endpoint.setValue(endpoints[0].guid); @@ -271,7 +271,8 @@ export class CreateReleaseComponent implements OnInit, OnDestroy { } ngOnInit() { - // Auto select endpoint if there is only one + // Preselect the first endpoint if there are several (a single endpoint + // is auto selected in setupDetailsStep) this.kubeEndpoints$.pipe(take(1)).subscribe(ep => { if (ep.length > 1) { this.details.controls.endpoint.setValue(ep[0].guid, { onlySelf: true }); From a37a61f2bf8624288443b7020d63435e36baa2f0 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:27:27 -0700 Subject: [PATCH 082/121] fix(core): render meta card items added later The meta card read its items with @ContentChildren and a QueryList under OnPush, so an item that an @if added after async data landed never rendered. The items are now a contentChildren() signal, and menuOpen is a signal. --- .../meta-card-base/meta-card.component.html | 4 +- .../meta-card.component.spec.ts | 42 ++++++++++++++++++- .../meta-card-base/meta-card.component.ts | 17 ++++---- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.html b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.html index e39d01f7eb..537fe203df 100644 --- a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.html +++ b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.html @@ -42,7 +42,7 @@ [disabled]="isDeleting$ | async"> <app-custom-icon>more_vert</app-custom-icon> </button> - @if (menuOpen) { + @if (menuOpen()) { <div class="absolute right-0 top-full mt-1 bg-content-bg rounded-md shadow-lg ring-1 ring-black/5 z-50 min-w-[160px]"> <div class="py-1" role="menu"> @for (menuItem of actionMenu; track menuItem) { @@ -74,7 +74,7 @@ </div> } <div class="meta-card__content"> - @for (metaItem of metaItems; track metaItem) { + @for (metaItem of metaItems(); track metaItem) { <div> @if (metaItem?.content) { <span> diff --git a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.spec.ts b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.spec.ts index 71d0747c13..eedbbaa21c 100644 --- a/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/meta-card/meta-card-base/meta-card.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, provideZonelessChangeDetection, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { Component, ViewChild, provideZonelessChangeDetection, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach } from 'vitest'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -8,6 +8,9 @@ import { createBasicStoreModule, CoreTestingModule } from '@test-framework'; import { generateGenericMockEntities } from '@test-framework/mock-catalog-entities'; import { MetaCardComponent } from './meta-card.component'; import { MetaCardTitleComponent } from '../meta-card-title/meta-card-title.component'; +import { MetaCardItemComponent } from '../meta-card-item/meta-card-item.component'; +import { MetaCardKeyComponent } from '../meta-card-key/meta-card-key.component'; +import { MetaCardValueComponent } from '../meta-card-value/meta-card-value.component'; @Component({ imports: [MetaCardComponent, MetaCardTitleComponent], @@ -21,6 +24,28 @@ class WrapperComponent { metaCard!: MetaCardComponent; } +// An item behind an @if that turns on after the first render, the way +// cards add rows once their async data lands. +@Component({ + imports: [MetaCardComponent, MetaCardItemComponent, MetaCardKeyComponent, MetaCardValueComponent], + template: ` + <app-meta-card> + <app-meta-card-item> + <app-meta-card-key>Always</app-meta-card-key> + <app-meta-card-value>here</app-meta-card-value> + </app-meta-card-item> + @if (showLate()) { + <app-meta-card-item> + <app-meta-card-key>Late</app-meta-card-key> + <app-meta-card-value>arrived</app-meta-card-value> + </app-meta-card-item> + } + </app-meta-card>` +}) +class LateItemWrapperComponent { + readonly showLate = signal(false); +} + class UserFavoriteManagerMock { getPrettyTypeName() { return 'prettyName'; @@ -58,6 +83,7 @@ describe('MetaCardComponent', () => { NoopAnimationsModule, createBasicStoreModule(), WrapperComponent, + LateItemWrapperComponent, ], providers: [ { provide: UserFavoriteManager, useClass: UserFavoriteManagerMock }, @@ -134,4 +160,18 @@ describe('MetaCardComponent', () => { expect(element.querySelector('app-application-state-icon')).toBeTruthy(); }); + + it('renders an item added after the first render', async () => { + const lateFixture = TestBed.createComponent(LateItemWrapperComponent); + await lateFixture.whenStable(); + const host: HTMLElement = lateFixture.nativeElement; + expect(host.textContent).toContain('here'); + expect(host.textContent).not.toContain('arrived'); + + lateFixture.componentInstance.showLate.set(true); + await lateFixture.whenStable(); + + expect(host.textContent).toContain('Late'); + expect(host.textContent).toContain('arrived'); + }); }); 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 b54cd1ac0e..43bc0a1455 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, Input, QueryList, HostListener } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ContentChild, Input, HostListener, contentChildren, signal } from '@angular/core'; import { combineLatest, Observable, of as observableOf, of } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -44,10 +44,11 @@ export function createMetaCardMenuItemSeparator(): MenuItem { changeDetection: ChangeDetectionStrategy.OnPush }) export class MetaCardComponent { - public menuOpen = false; + readonly menuOpen = signal(false); - @ContentChildren(MetaCardItemComponent) - metaItems!: QueryList<MetaCardItemComponent>; + // Signal query so items a card adds after the first render (behind an + // @if waiting on async data) re-render this OnPush view. + readonly metaItems = contentChildren(MetaCardItemComponent); @ContentChild(MetaCardTitleComponent, { static: true }) title!: MetaCardTitleComponent; @@ -118,21 +119,21 @@ export class MetaCardComponent { private pActionMenu!: MenuItem[]; toggleMenu(): void { - this.menuOpen = !this.menuOpen; + this.menuOpen.update(open => !open); } menuItemClick(menuItem: MenuItem): void { menuItem.action(); - this.menuOpen = false; + this.menuOpen.set(false); } @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { // Close menu when clicking outside - if (this.menuOpen) { + if (this.menuOpen()) { const target = event.target as HTMLElement; if (!target.closest('.meta-card__header-container__actions')) { - this.menuOpen = false; + this.menuOpen.set(false); } } } From 1ad718438504cdeffb641b499d66ee3abb21ee83 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:27:27 -0700 Subject: [PATCH 083/121] fix(cf): show the broker card's space and link The Space row's computed read the plain _spaceData field, so it never updated once the space loaded; _spaceData is now a signal. The Name item was nested inside the Broker Link item, so the card read Name in its place and Broker Link never showed; they are now siblings. --- .../service-broker-card.component.html | 8 +- .../service-broker-card.component.spec.ts | 79 ++++++++++++++++++- .../service-broker-card.component.ts | 16 ++-- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html index 72334062cf..87385e989e 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html @@ -6,10 +6,10 @@ <div class="service-broker__title__link">Service Broker</div> </app-meta-card-title> <app-meta-card-item> - <app-meta-card-item> - <app-meta-card-key>Name</app-meta-card-key> - <app-meta-card-value>{{ b?.name }}</app-meta-card-value> - </app-meta-card-item> + <app-meta-card-key>Name</app-meta-card-key> + <app-meta-card-value>{{ b?.name }}</app-meta-card-value> + </app-meta-card-item> + <app-meta-card-item> <app-meta-card-key>Broker Link</app-meta-card-key> <app-meta-card-value> <a [href]="$safeNavigationMigration(b?.url)" target="_blank" appClickStopPropagation> diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts index b6020cb22b..40c1a8ec16 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts @@ -3,12 +3,14 @@ import { importProvidersFrom, provideZonelessChangeDetection, signal } from '@an import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; +import { Observable, Subject } from 'rxjs'; import { describe, it, expect, beforeEach } from 'vitest'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCfBaseTestModulesNoShared } from '@test-framework/cf'; import { ServiceCatalogDataService, SignalSource } from '../../../../services/endpoint-data/service-catalog-data.service'; -import { StServiceBroker } from '../../../../services/endpoint-data/stratos-types'; +import { SpaceDataRegistry } from '../../../../services/endpoint-data/space-data.registry'; +import { StServiceBroker, StSpace } from '../../../../services/endpoint-data/stratos-types'; import { ServiceBrokerCardComponent } from './service-broker-card.component'; class ServiceCatalogDataServiceStub { @@ -25,22 +27,51 @@ class ServiceCatalogDataServiceStub { _meta: { unavailable: ['authUsername'] }, }; + // When set, the broker value starts null and the test sets it, the way + // the HTTP response lands after the first render. + lateBroker: ReturnType<typeof signal<StServiceBroker | null>> | null = null; + serviceBroker(_cnsi: string, _broker: string): SignalSource<StServiceBroker | null> { return { - value: signal(this.brokerResponse).asReadonly(), + value: (this.lateBroker ?? signal(this.brokerResponse)).asReadonly(), isLoading: signal(false).asReadonly(), error: signal<HttpErrorResponse | null>(null).asReadonly(), }; } } +// Space lookup lands after the first render, the same as the real +// SpaceDataService: `space` stays null until `load()` emits. +class SpaceDataRegistryStub { + readonly space = signal<StSpace | null>(null); + readonly loaded = new Subject<void>(); + acquired: string[] = []; + + acquire(cnsi: string, spaceGuid: string) { + this.acquired.push(`${cnsi}/${spaceGuid}`); + return { + space: this.space.asReadonly(), + load: (): Observable<void> => this.loaded, + }; + } + + release(_cnsi: string, _spaceGuid: string): void { /* no-op */ } + + land(space: StSpace): void { + this.space.set(space); + this.loaded.next(); + } +} + describe('ServiceBrokerCardComponent', () => { let component: ServiceBrokerCardComponent; let fixture: ComponentFixture<ServiceBrokerCardComponent>; let catalogStub: ServiceCatalogDataServiceStub; + let spaceStub: SpaceDataRegistryStub; const setup = async () => { catalogStub = new ServiceCatalogDataServiceStub(); + spaceStub = new SpaceDataRegistryStub(); await TestBed.configureTestingModule({ imports: [ServiceBrokerCardComponent], providers: [ @@ -51,6 +82,7 @@ describe('ServiceBrokerCardComponent', () => { ...STORE_TEST_PROVIDERS, importProvidersFrom(generateCfBaseTestModulesNoShared()), { provide: ServiceCatalogDataService, useValue: catalogStub }, + { provide: SpaceDataRegistry, useValue: spaceStub }, ], }).compileComponents(); @@ -75,6 +107,13 @@ describe('ServiceBrokerCardComponent', () => { expect(host.textContent).toContain('app-autoscaler'); }); + it('renders the Broker Link row alongside the Name row', () => { + const host: HTMLElement = fixture.nativeElement; + expect(host.textContent).toContain('Broker Link'); + const launch = host.querySelector('a[target="_blank"]'); + expect(launch?.getAttribute('href')).toBe('https://app-autoscaler-broker.cf-dev.io'); + }); + it('renders "Not Available" for authUsername when listed in _meta.unavailable', () => { const host: HTMLElement = fixture.nativeElement; const unavailable = host.querySelector('app-tristate-value .tristate-value--unavailable'); @@ -100,4 +139,40 @@ describe('ServiceBrokerCardComponent', () => { expect(known?.textContent).toBe('broker-admin'); expect(host.querySelector('app-tristate-value .tristate-value--unavailable')).toBeNull(); }); + + it('renders the Space row once the space-scoped broker\'s space loads', async () => { + const spaceScoped: StServiceBroker = { + ...catalogStub.brokerResponse!, + space: { guid: 'space-guid' }, + }; + catalogStub.lateBroker = signal<StServiceBroker | null>(null); + fixture = TestBed.createComponent(ServiceBrokerCardComponent); + component = fixture.componentInstance; + component.cfGuid = 'test-cf-guid'; + component.brokerGuid = spaceScoped.guid; + await fixture.whenStable(); + expect(spaceStub.acquired).toEqual([]); + + catalogStub.lateBroker.set(spaceScoped); + await fixture.whenStable(); + expect(spaceStub.acquired).toEqual(['test-cf-guid/space-guid']); + + spaceStub.land({ + guid: 'space-guid', + name: 'broker-space', + orgGuid: 'org-guid', + createdAt: '', + updatedAt: '', + cnsiGuid: 'test-cf-guid', + appCount: 0, + routeCount: 0, + } as StSpace); + await fixture.whenStable(); + + const link = (fixture.nativeElement as HTMLElement).querySelector('a[href*="/spaces/"]'); + expect(link?.textContent).toBe('broker-space'); + expect(link?.getAttribute('href')).toBe( + '/cloud-foundry/test-cf-guid/organizations/org-guid/spaces/space-guid/summary', + ); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts index f605f65b5d..1f98274c34 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts @@ -61,8 +61,9 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { // Acquired SpaceDataService (or null when no broker.space.guid yet). // The registry refCounts per (cnsi, spaceGuid); we release on swap + - // destroy so warm caches stay shared with other consumers. - private _spaceData: SpaceDataService | null = null; + // destroy so warm caches stay shared with other consumers. A signal so + // `spaceLink` re-evaluates when the effect swaps it in. + private readonly _spaceData = signal<SpaceDataService | null>(null); private _spaceKey: { cnsi: string, guid: string } | null = null; readonly broker: Signal<StServiceBroker | null> = computed( @@ -71,7 +72,7 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { readonly spaceLink: Signal<BrokerSpaceLink | null> = computed(() => { const cfGuid = this._cfGuid(); - const space = this._spaceData?.space(); + const space = this._spaceData()?.space(); if (!space || !cfGuid) return null; return { name: space.name, @@ -116,12 +117,13 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; if (curr) { this.spaceRegistry.release(curr.cnsi, curr.guid); - this._spaceData = null; + this._spaceData.set(null); this._spaceKey = null; } if (targetKey) { - this._spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - this._spaceData.load().subscribe(); + const spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); + spaceData.load().subscribe(); + this._spaceData.set(spaceData); this._spaceKey = targetKey; } }); @@ -131,7 +133,7 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { ngOnDestroy(): void { if (this._spaceKey) { this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData = null; + this._spaceData.set(null); this._spaceKey = null; } } From 81a794ed2bb5326f7296bc147136659a16350974 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:27:27 -0700 Subject: [PATCH 084/121] fix(cf): make the broker cell's space a signal The cell's space link computed read the plain _spaceData field, so a read before the space landed stuck at undefined. --- ...able-cell-service-broker.component.spec.ts | 69 ++++++++++++++++++- .../table-cell-service-broker.component.ts | 14 ++-- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts index bd3db0dc84..55bce1c54e 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts @@ -1,19 +1,42 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection, signal } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { Observable, Subject } from 'rxjs'; import { describe, it, expect, beforeEach } from 'vitest'; import { TableCellServiceBrokerComponent, TableCellServiceBrokerComponentMode } from './table-cell-service-broker.component'; import { ServiceCatalogDataService, SignalSource } from '../../../../../services/endpoint-data/service-catalog-data.service'; -import { StServiceBroker, StServiceOffering } from '../../../../../services/endpoint-data/stratos-types'; +import { SpaceDataRegistry } from '../../../../../services/endpoint-data/space-data.registry'; +import { StServiceBroker, StServiceOffering, StSpace } from '../../../../../services/endpoint-data/stratos-types'; + +// Space lookup lands after the first render, the same as the real +// SpaceDataService: `space` stays null until `load()` emits. +class SpaceDataRegistryStub { + readonly space = signal<StSpace | null>(null); + readonly loaded = new Subject<void>(); + + acquire(_cnsi: string, _spaceGuid: string) { + return { + space: this.space.asReadonly(), + load: (): Observable<void> => this.loaded, + }; + } + + release(_cnsi: string, _spaceGuid: string): void { /* no-op */ } +} describe('TableCellServiceBrokerComponent', () => { let component: TableCellServiceBrokerComponent; let fixture: ComponentFixture<TableCellServiceBrokerComponent>; let lastBrokerLookup: { cnsiGuid: string; brokerGuid: string } | null; + let lateBroker: ReturnType<typeof signal<StServiceBroker | null>> | null; + let spaceStub: SpaceDataRegistryStub; beforeEach(async () => { lastBrokerLookup = null; + lateBroker = null; + spaceStub = new SpaceDataRegistryStub(); const serviceCatalogStub: Partial<ServiceCatalogDataService> = { serviceBroker: (cnsiGuid: string, brokerGuid: string): SignalSource<StServiceBroker | null> => { lastBrokerLookup = { cnsiGuid, brokerGuid }; @@ -29,7 +52,7 @@ describe('TableCellServiceBrokerComponent', () => { updatedAt: '', }; return { - value: signal<StServiceBroker | null>(broker).asReadonly(), + value: (lateBroker ?? signal<StServiceBroker | null>(broker)).asReadonly(), isLoading: signal(false).asReadonly(), error: signal<HttpErrorResponse | null>(null).asReadonly(), }; @@ -39,7 +62,9 @@ describe('TableCellServiceBrokerComponent', () => { await TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), + provideRouter([]), { provide: ServiceCatalogDataService, useValue: serviceCatalogStub }, + { provide: SpaceDataRegistry, useValue: spaceStub }, ], imports: [TableCellServiceBrokerComponent], }).compileComponents(); @@ -68,4 +93,44 @@ describe('TableCellServiceBrokerComponent', () => { expect(lastBrokerLookup).toEqual({ cnsiGuid: 'cnsi-1', brokerGuid: 'broker-7' }); expect(component.broker()?.name).toBe('global-broker'); }); + + it('renders the space link once the broker and its space land', async () => { + lateBroker = signal<StServiceBroker | null>(null); + component.config = { mode: TableCellServiceBrokerComponentMode.SCOPE }; + component.row = { + guid: 'svc-1', + cnsiGuid: 'cnsi-1', + name: 'svc', + broker: { guid: 'broker-7' }, + createdAt: '2026-01-01T00:00:00Z', + }; + await fixture.whenStable(); + const host: HTMLElement = fixture.nativeElement; + expect(host.textContent?.trim()).toBe('-'); + + lateBroker.set({ + guid: 'broker-7', + name: 'space-broker', + url: 'https://b.example', + space: { guid: 'space-1' }, + labels: {}, + annotations: {}, + cnsiGuid: 'cnsi-1', + createdAt: '', + updatedAt: '', + }); + // Read before the space-acquire effect runs: the computed must still + // pick up the space data the effect swaps in afterwards. + expect(component.spaceLink()).toBeNull(); + await fixture.whenStable(); + expect(host.textContent).toContain('All Spaces'); + + spaceStub.space.set({ guid: 'space-1', name: 'broker-space', orgGuid: 'org-1' } as StSpace); + spaceStub.loaded.next(); + await fixture.whenStable(); + + const link = host.querySelector('a'); + expect(link?.textContent).toBe('broker-space'); + expect(link?.getAttribute('href')).toBe('/cloud-foundry/cnsi-1/organizations/org-1/spaces/space-1/summary'); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts index d72bdf9ba1..b4c939946a 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts @@ -42,7 +42,8 @@ export class TableCellServiceBrokerComponent extends private injector = inject(Injector); private readonly _brokerSource = signal<SignalSource<StServiceBroker | null> | null>(null); - private _spaceData: SpaceDataService | null = null; + // A signal so `spaceLink` re-evaluates when the effect swaps it in. + private readonly _spaceData = signal<SpaceDataService | null>(null); private _spaceKey: { cnsi: string, guid: string } | null = null; readonly broker: Signal<StServiceBroker | null> = computed( @@ -50,7 +51,7 @@ export class TableCellServiceBrokerComponent extends ); readonly spaceLink: Signal<SpaceLink | null> = computed(() => { - const space = this._spaceData?.space(); + const space = this._spaceData()?.space(); const broker = this.broker(); if (!space || !broker) return null; return { @@ -93,12 +94,13 @@ export class TableCellServiceBrokerComponent extends if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; if (curr) { this.spaceRegistry.release(curr.cnsi, curr.guid); - this._spaceData = null; + this._spaceData.set(null); this._spaceKey = null; } if (targetKey) { - this._spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - this._spaceData.load().subscribe(); + const spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); + spaceData.load().subscribe(); + this._spaceData.set(spaceData); this._spaceKey = targetKey; } }); @@ -108,7 +110,7 @@ export class TableCellServiceBrokerComponent extends ngOnDestroy(): void { if (this._spaceKey) { this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData = null; + this._spaceData.set(null); this._spaceKey = null; } } From fbf11ab7a62c12e14e7deed584c88676a46bd14d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:27:27 -0700 Subject: [PATCH 085/121] fix(cf): follow a plan that arrives after render The visibility computed returned early on a missing plan without reading any signal, so "(Service Plan has no visibility)" stayed after the plan arrived. The plan input now writes a signal. --- .../service-plan-public.component.html | 2 +- .../service-plan-public.component.spec.ts | 13 +++++++++++++ .../service-plan-public.component.ts | 13 ++++++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.html index 0fec8c6eba..d1479db5c2 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.html @@ -1,4 +1,4 @@ -@if (servicePlan) { +@if (plan(); as servicePlan) { {{ servicePlan.visibilityType === 'public' ? 'Yes' : 'No' }} } @if (planAccessibility() !== 'ok') { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.spec.ts index b73649ea1b..a7ad36d930 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.spec.ts @@ -125,4 +125,17 @@ describe('ServicePlanPublicComponent', () => { expect(element.textContent).toContain('Service Plan has no visibility'); }); + + it('updates the visibility message when the plan arrives after the first render', async () => { + // Parents pass the plan once their own fetch lands: the first render + // sees no plan, which reads as "no visibility". + await fixture.whenStable(); + expect(element.textContent).toContain('Service Plan has no visibility'); + + fixture.componentRef.setInput('servicePlan', buildPlan({ visibilityType: 'public' })); + await fixture.whenStable(); + + expect(element.textContent).toContain('Yes'); + expect(element.textContent).not.toContain('Service Plan has no visibility'); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.ts index 798c0fdbcf..469f16ea51 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/service-plan-public/service-plan-public.component.ts @@ -25,8 +25,13 @@ export class ServicePlanPublicComponent { // admin/error case. private readonly _visibilitySource = signal<SignalSource<StServicePlanVisibility | null> | null>(null); + // Signal behind the setter input so the computeds below re-run when a + // parent passes the plan after the first render. + private readonly _servicePlan = signal<StServicePlan | null>(null); + readonly plan: Signal<StServicePlan | null> = this._servicePlan.asReadonly(); + readonly planAccessibility: Signal<StratosStatus> = computed(() => { - const plan = this.pServicePlan; + const plan = this._servicePlan(); if (!plan) return StratosStatus.ERROR; const visibility = this._visibilitySource()?.value() ?? null; return getPlanAccessibilityV3(plan.visibilityType === 'public', visibility); @@ -39,15 +44,13 @@ export class ServicePlanPublicComponent { return ''; }); - private pServicePlan: StServicePlan | null = null; - @Input() get servicePlan(): StServicePlan | null { - return this.pServicePlan; + return this._servicePlan(); } set servicePlan(servicePlan: StServicePlan | null) { - this.pServicePlan = servicePlan; + this._servicePlan.set(servicePlan); if (!servicePlan) { this._visibilitySource.set(null); return; From 3e48893b6dedb99b9ed93f9b68ec77e64a859411 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:27:27 -0700 Subject: [PATCH 086/121] fix(core): show succeeded stacked input rows Row state was a plain field under OnPush, so rows that succeeded after processing showed no result (failed rows painted only through a form status side effect), and "Add another" stayed enabled while rows were processing. Row state, inputs and the parent's disabled flag are now signals. --- .../stacked-input-action.component.html | 17 ++--- .../stacked-input-action.component.ts | 35 +++++----- .../stacked-input-actions.component.html | 2 +- .../stacked-input-actions.component.spec.ts | 64 +++++++++++++++++-- .../stacked-input-actions.component.ts | 18 +++--- 5 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.html b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.html index b638656af5..aea2a34195 100644 --- a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.html +++ b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.html @@ -1,8 +1,8 @@ <div class="input-action"> <form class="input-action__form stepper-form"> <app-form-field> - <input appInput class="w-full placeholder:!text-transparent focus:outline-none" [placeholder]="config.text.placeholder" - [formControl]="textFormControl" #inputElement [name]="position" data-test="stacked-input"> + <input appInput class="w-full placeholder:!text-transparent focus:outline-none" [placeholder]="resolvedConfig().text.placeholder" + [formControl]="textFormControl" #inputElement [name]="position()" data-test="stacked-input"> @if (textFormControl.hasError('email') && !textFormControl.hasError('required')) { <app-error> Please enter a valid email address @@ -10,27 +10,28 @@ } @if (textFormControl.hasError('required')) { <app-error> - {{config.text.requiredError}} + {{resolvedConfig().text.requiredError}} </app-error> } @if (textFormControl.hasError('notUnique') && !textFormControl.hasError('email')) { <app-error> - {{config.text.uniqueError}} + {{resolvedConfig().text.uniqueError}} </app-error> } </app-form-field> </form> <div class="input-action__detail"> - @if (showRemove) { + @if (showRemove()) { <app-custom-icon class="input-action__detail__remove" [ngClass]="{'disabled': textFormControl.disabled}" (click)="!textFormControl.disabled && remove.emit()" [matTooltip]="'Remove'">indeterminate_check_box</app-custom-icon> } - @if (state && result && (state.result === result.SUCCEEDED || state.result === result.FAILED)) { + @let st = state(); + @if (st && (st.result === result.SUCCEEDED || st.result === result.FAILED)) { <ng-container class="input-action__detail__result"> - <app-boolean-indicator [isTrue]="state.result === result.SUCCEEDED" type="succeeded-failed" [subtle]="false" + <app-boolean-indicator [isTrue]="st.result === result.SUCCEEDED" type="succeeded-failed" [subtle]="false" [showText]="false"></app-boolean-indicator> - <span>{{ state.message }}</span> + <span>{{ st.message }}</span> </ng-container> } </div> diff --git a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.ts b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.ts index f8003f52eb..efb29aa7f2 100644 --- a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.ts +++ b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-action/stacked-input-action.component.ts @@ -8,6 +8,9 @@ import { ChangeDetectionStrategy, AfterContentInit, OnInit, Output, ViewChild, + computed, + input, + signal, } from '@angular/core'; import { ReactiveFormsModule, Validators, FormControl, AbstractControl } from '@angular/forms'; import { CustomFormFieldComponent, AppErrorComponent, AppInputDirective } from '../../../components/custom-form-field/custom-form-field.component'; @@ -72,17 +75,11 @@ export class StackedInputActionComponent implements OnInit, OnDestroy, AfterCont } }; @Input() stateIn$!: Observable<StackedInputActionsState | null>; - @Input() position!: number; - @Input() showRemove!: boolean; - @Input() key!: number; - private pConfig!: StackedInputActionConfig; - @Input() - set config(config: StackedInputActionConfig) { - this.pConfig = config; - } - get config(): StackedInputActionConfig { - return this.pConfig || StackedInputActionComponent.defaultConfig; - } + readonly position = input<number>(0); + readonly showRemove = input(false); + readonly key = input<number>(0); + readonly config = input<StackedInputActionConfig | undefined>(undefined); + readonly resolvedConfig = computed(() => this.config() || StackedInputActionComponent.defaultConfig); @Output() stateOut = new EventEmitter<StackedInputActionUpdate>(); @Output() remove = new EventEmitter<any>(); @@ -91,13 +88,15 @@ export class StackedInputActionComponent implements OnInit, OnDestroy, AfterCont public result = StackedInputActionResult; public textFormControl = new FormControl<string | null>('', [Validators.required, this.uniqueValidator.bind(this)]); - public state: StackedInputActionsState | null = null; + // Signal so a SUCCEEDED result after PROCESSING re-renders: the form + // control stays disabled across both, so nothing else marks the view. + readonly state = signal<StackedInputActionsState | null>(null); private subs: Subscription[] = []; private otherValues!: string[]; ngOnInit() { const validators = [Validators.required, this.uniqueValidator.bind(this)]; - if (this.config.isEmailInput) { + if (this.resolvedConfig().isEmailInput) { validators.push(Validators.email); } this.textFormControl = new FormControl<string | null>('', validators); @@ -105,10 +104,10 @@ export class StackedInputActionComponent implements OnInit, OnDestroy, AfterCont // Emit any changes of form state outwards. this.subs.push(this.textFormControl.valueChanges.subscribe((value) => { this.stateOut.emit({ - key: this.key, + key: this.key(), value: value ?? '', // Component is valid if form is ok OR it's already succeeded - valid: this.state && this.state.result === StackedInputActionResult.SUCCEEDED || this.textFormControl.valid + valid: this.state()?.result === StackedInputActionResult.SUCCEEDED || this.textFormControl.valid }); })); @@ -148,18 +147,18 @@ export class StackedInputActionComponent implements OnInit, OnDestroy, AfterCont private handleStateIn(incState: StackedInputActionsState | null) { if (!incState) { - this.state = incState; + this.state.set(incState); this.textFormControl.enable(); return; } switch (incState.result) { case StackedInputActionResult.PROCESSING: case StackedInputActionResult.SUCCEEDED: - this.state = incState; + this.state.set(incState); this.textFormControl.disable(); break; case StackedInputActionResult.FAILED: - this.state = incState; + this.state.set(incState); this.textFormControl.enable(); break; case StackedInputActionResult.OTHER_VALUES_UPDATED: { diff --git a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.html b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.html index e9e77d11eb..e4c53baa15 100644 --- a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.html +++ b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.html @@ -1,7 +1,7 @@ <div class="stacked-input__inputs" tabindex="-1" (keydown)="onKeydown($event)"> <div #inputs></div> </div> -<button class="stacked-input__add btn btn-ghost" type="button" (click)="addComponent()" [disabled]="disabled"> +<button class="stacked-input__add btn btn-ghost" type="button" (click)="addComponent()" [disabled]="disabled()"> <div class="flex items-center">Add another <app-custom-icon class="">add_box</app-custom-icon> </div> </button> \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.spec.ts b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.spec.ts index 90bf302a1f..99e51681b7 100644 --- a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.spec.ts @@ -1,16 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; -import { of as observableOf } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { BaseTestModulesNoShared, STORE_TEST_PROVIDERS } from "@test-framework/core-test.helper"; import { BooleanIndicatorComponent } from '../boolean-indicator/boolean-indicator.component'; -import { StackedInputActionComponent } from './stacked-input-action/stacked-input-action.component'; -import { StackedInputActionsComponent } from './stacked-input-actions.component'; +import { StackedInputActionComponent, StackedInputActionResult } from './stacked-input-action/stacked-input-action.component'; +import { StackedInputActionsComponent, StackedInputActionsState } from './stacked-input-actions.component'; describe('StackedInputActionsComponent', () => { let component: StackedInputActionsComponent; let fixture: ComponentFixture<StackedInputActionsComponent>; + let stateIn: BehaviorSubject<StackedInputActionsState[]>; beforeEach(() => { TestBed.configureTestingModule({ @@ -31,11 +32,66 @@ describe('StackedInputActionsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(StackedInputActionsComponent); component = fixture.componentInstance; - component.stateIn$ = observableOf([]); + stateIn = new BehaviorSubject<StackedInputActionsState[]>([]); + component.stateIn$ = stateIn; fixture.detectChanges(); }); + const host = (): HTMLElement => fixture.nativeElement; + const addButton = () => host().querySelector<HTMLButtonElement>('button.stacked-input__add')!; + const resultIcon = () => host().querySelector('app-stacked-input-action app-boolean-indicator .material-icons'); + const pushState = async (result: StackedInputActionResult, message?: string) => { + stateIn.next([{ key: '0', result, message }]); + await fixture.whenStable(); + }; + it('should create', () => { expect(component).toBeTruthy(); }); + + it('disables "Add another" while a row is processing', async () => { + await fixture.whenStable(); + expect(addButton().disabled).toBe(false); + + await pushState(StackedInputActionResult.PROCESSING); + + expect(addButton().disabled).toBe(true); + }); + + it('shows the succeeded result after processing', async () => { + await pushState(StackedInputActionResult.PROCESSING); + expect(resultIcon()).toBeNull(); + + await pushState(StackedInputActionResult.SUCCEEDED, 'Invited'); + + expect(resultIcon()?.textContent?.trim()).toBe('check_circle'); + expect(host().querySelector('app-stacked-input-action')?.textContent).toContain('Invited'); + expect(addButton().disabled).toBe(false); + }); + + it('shows the failed result after processing', async () => { + await pushState(StackedInputActionResult.PROCESSING); + + await pushState(StackedInputActionResult.FAILED, 'Could not invite'); + + expect(resultIcon()?.textContent?.trim()).toBe('remove_circle'); + expect(host().querySelector('app-stacked-input-action')?.textContent).toContain('Could not invite'); + }); + + it('emits each row value under its own key', async () => { + const emitted: { values: Record<string, string> }[] = []; + component.stateOut.subscribe(update => emitted.push(update)); + addButton().click(); + await fixture.whenStable(); + + const inputs = host().querySelectorAll<HTMLInputElement>('input[data-test="stacked-input"]'); + expect(inputs.length).toBe(2); + inputs[0].value = 'a@example.com'; + inputs[0].dispatchEvent(new Event('input')); + inputs[1].value = 'b@example.com'; + inputs[1].dispatchEvent(new Event('input')); + await fixture.whenStable(); + + expect(emitted.at(-1)?.values).toEqual({ 0: 'a@example.com', 1: 'b@example.com' }); + }); }); diff --git a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.ts b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.ts index aefc8c9125..6c53bb32a2 100644 --- a/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.ts +++ b/src/frontend/packages/core/src/shared/components/stacked-input-actions/stacked-input-actions.component.ts @@ -46,7 +46,9 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { @ViewChild('inputs', { read: ViewContainerRef, static: true }) inputs!: ViewContainerRef; - disabled = false; + // True while any row is PROCESSING; a signal so the stateIn$ push + // re-renders the OnPush "Add another" button. + readonly disabled = signal(false); private count = 0; private components: { @@ -89,7 +91,7 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { stackedAction.stateIn$ = toObservable(stateInSignal, { injector: this.injector }); // Track them all together in one pot - this.components[stackedAction.key] = { + this.components[stackedAction.key()] = { stateInSignal: stateInSignal.asReadonly(), stateInUpdate: (state: StackedInputActionsState | null) => stateInSignal.set(state), componentRef, @@ -107,8 +109,8 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { const components = Object.values(this.components); // Emit a list of values for all components that should be processed. This does not include succeeded components const valuesToSubmit = components ? components.reduce((values: Record<string, string>, component) => { - if ((!component.stackedAction.state || component.stackedAction.state.result !== StackedInputActionResult.SUCCEEDED) && component.update) { - values[component.stackedAction.key] = component.update.value; + if (component.stackedAction.state()?.result !== StackedInputActionResult.SUCCEEDED && component.update) { + values[component.stackedAction.key()] = component.update.value; } return values; }, {} as Record<string, string>) : {}; @@ -121,10 +123,10 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { removeComponent(stackedAction: StackedInputActionComponent): void { // Remove the visual component - const componentRef = this.components[stackedAction.key].componentRef; + const componentRef = this.components[stackedAction.key()].componentRef; this.inputs.remove(this.inputs.indexOf(componentRef.hostView)); // Remove the tracked component - delete this.components[stackedAction.key]; + delete this.components[stackedAction.key()]; // Update all components with their new position this.updatePositions(); this.cd.detectChanges(); @@ -147,7 +149,7 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { key, result: StackedInputActionResult.OTHER_VALUES_UPDATED, otherValues: Object.values(this.components) - .filter(fComponent => component.stackedAction.key !== fComponent.stackedAction.key) + .filter(fComponent => component.stackedAction.key() !== fComponent.stackedAction.key()) .map(mComponent => mComponent.update?.value) .filter(value => !!value && value.length) }); @@ -160,7 +162,7 @@ export class StackedInputActionsComponent implements OnInit, OnDestroy { // Push state change into their respective components this.subs.push(this.stateIn$.subscribe(states => { // Disable the 'add new' button - this.disabled = !!states.find(state => state.result === StackedInputActionResult.PROCESSING); + this.disabled.set(states.some(state => state.result === StackedInputActionResult.PROCESSING)); // Push state using signal update function states.forEach((state, _index) => this.components[state.key].stateInUpdate(state)); })); From 3c47913b325fea9cda8da7e1c930ea253ff1a7a4 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:54:52 -0700 Subject: [PATCH 087/121] fix(autoscaler): make the policy tab template build The tab's template was never compiled into the app, so it drifted: it used the core card components without importing them, an app-card-actions element that does not exist, and optional policy fields without guards. Import the cards, use a plain div for the actions, guard the optional fields, and let the running instances count be blank until the app loads. --- .../autoscaler-tab-extension.component.html | 14 +++++++------- .../autoscaler-tab-extension.component.ts | 8 ++++++++ .../running-instances.component.ts | 3 ++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html index c1bf2e9392..66c334e09b 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html @@ -36,10 +36,10 @@ } @if (policy.enabled) { <app-tile class="flex-[2] min-h-[200px]"> - @if (policy.scaling_rules.length === 0) { + @if (!policy.scaling_rules?.length) { <app-card-app-usage></app-card-app-usage> } - @if (policy.scaling_rules.length > 0) { + @if (policy.scaling_rules?.length) { <app-card> <app-card-header> <app-card-title>Latest Metrics</app-card-title> @@ -61,13 +61,13 @@ } </div> </app-card-content> - <app-card-actions class="absolute bottom-2.5 right-6 pt-0 text-right"> + <div class="absolute bottom-2.5 right-6 pt-0 text-right"> <app-polling-indicator [manualPoll]="true" (poll)="loadLatestMetricsUponPolicy()"> </app-polling-indicator> <button (click)="metricChartPage()" class="btn btn-icon" aria-label="Open metric chart"> <app-custom-icon>launch</app-custom-icon> </button> - </app-card-actions> + </div> </app-card> } </app-tile> @@ -252,7 +252,7 @@ </tbody> </table> </div> - @if (policy && policy.schedules && policy.schedules.specific_date && policy.schedules.recurring_schedule.length === 0) { + @if (policy && policy.schedules && policy.schedules.specific_date && !policy.schedules.recurring_schedule?.length) { <div class="mt-2 text-center py-8 text-content-muted" > No recurring schedule.</div> @@ -326,7 +326,7 @@ } </div> </app-card-content> - <app-card-actions class="absolute bottom-2.5 right-6 pt-0 text-right"> + <div class="absolute bottom-2.5 right-6 pt-0 text-right"> <app-polling-indicator (poll)="fetchScalingHistory()" [manualPoll]="!scalingHistoryLoading()" [isPolling]="scalingHistoryLoading()"> @@ -334,7 +334,7 @@ <button (click)="scaleHistoryPage()" class="btn btn-icon" aria-label="Open scale history"> <app-custom-icon>launch</app-custom-icon> </button> - </app-card-actions> + </div> </app-card> </app-tile> } diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts index f10e8533a9..14c0e92129 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts @@ -24,6 +24,10 @@ import { TailwindSnackBarRef, StratosTab, StratosTabType, + CardContentComponent, + CardHeaderComponent, + CardTitleComponent, + CardWrapperComponent, CurrentUserPermissionsService, ConfirmationDialogConfig, ConfirmationDialogService, @@ -127,6 +131,10 @@ interface AutoscalerTabPaginationParams { TileGridComponent, TileGroupComponent, TileComponent, + CardWrapperComponent, + CardHeaderComponent, + CardTitleComponent, + CardContentComponent, CardAutoscalerDefaultComponent, CardAppUsageComponent, CustomIconComponent, diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/running-instances/running-instances.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/running-instances/running-instances.component.ts index c664e06be3..d1d2bc038e 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/running-instances/running-instances.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/running-instances/running-instances.component.ts @@ -15,8 +15,9 @@ import { AppStatsDataRegistry } from '../../../services/endpoint-data/app-stats- export class RunningInstancesComponent implements OnInit { private readonly registry = inject(AppStatsDataRegistry); + // Desired instance count; blank until the host has the app + @Input() instances?: number | null; // strict: required @Inputs, always bound by the host template - @Input() instances!: number; @Input() cfGuid!: string; @Input() appGuid!: string; From 0eae21910ac567d3954292594f143d3c33a786e9 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 04:54:52 -0700 Subject: [PATCH 088/121] fix(autoscaler): register the Autoscale app tab The tab registers through its component's decorator, which runs only when something imports the component. The NgModule removal (f5d764c7c0) deleted the only import of CfAutoscalerModule, so the Autoscale tab vanished and /autoscale rendered a blank page. The package module now imports it. --- .../src/cf-autoscaler-package.module.spec.ts | 13 +++++++++++++ .../src/cf-autoscaler-package.module.ts | 3 +++ .../src/cf-autoscaler-routing.module.ts | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.spec.ts diff --git a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.spec.ts b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.spec.ts new file mode 100644 index 0000000000..cdae48bf15 --- /dev/null +++ b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.spec.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; + +import { getTabsFromExtensions, StratosTabType } from '../../core/src/core/extension/extension-service'; +import { CfAutoscalerPackageModule } from './cf-autoscaler-package.module'; + +describe('CfAutoscalerPackageModule', () => { + // The app imports only this module from the package, so it must load the + // tab component for its decorator to register the Autoscale tab + it('registers the Autoscale application tab', () => { + expect(CfAutoscalerPackageModule).toBeDefined(); + expect(getTabsFromExtensions(StratosTabType.Application).map(t => t.link)).toContain('autoscale'); + }); +}); diff --git a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.ts b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.ts index 12418a9266..d0c7b91ec3 100644 --- a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.ts +++ b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-package.module.ts @@ -1,6 +1,7 @@ import { Injector, NgModule, inject } from '@angular/core'; import { EntityCatalogModule } from '../../store/src/entity-catalog.module'; +import { CfAutoscalerModule } from './cf-autoscaler.module'; import { setAutoscalerHelperInjector } from './core/autoscaler-helpers/autoscaler-available'; import { generateASEntities } from './store/autoscaler-entity-generator'; @@ -17,6 +18,8 @@ import { generateASEntities } from './store/autoscaler-entity-generator'; @NgModule({ imports: [ EntityCatalogModule.forFeature(generateASEntities), + // Loads the tab component, whose decorator registers the Autoscale tab + CfAutoscalerModule, ], }) export class CfAutoscalerPackageModule { diff --git a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-routing.module.ts b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-routing.module.ts index 2248c09bd5..5e25780c15 100644 --- a/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-routing.module.ts +++ b/src/frontend/packages/cf-autoscaler/src/cf-autoscaler-routing.module.ts @@ -3,7 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; // Things in this path include add/edit policy stepper, policy metrics page and policy events page // They're include like this to ensure those parts are lazy loaded. -// The core application policy tab is included via CfAutoscalerModule, which is imported in the Application Module +// The core application policy tab is included via CfAutoscalerModule, which CfAutoscalerPackageModule imports const customRoutes: Routes = [ { path: 'autoscaler', From 5d00495e7007df5b65dc90b15d3ab89db2c38365 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:16:40 -0700 Subject: [PATCH 089/121] fix(autoscaler): keep the autoscaler's reason The autoscaler API gives its reason for a failure as {"error": "..."}. The data services reported the request's own message instead, which only names the URL and the status, e.g. "Http failure response for .../policy: 403 OK". They now report the autoscaler's reason, such as "The application is not bound to Auto-Scaling service". --- .../core/autoscaler-helpers/autoscaler-util.spec.ts | 9 ++++++++- .../src/core/autoscaler-helpers/autoscaler-util.ts | 8 ++++++++ .../domain-data/autoscaler-credential-data.service.ts | 7 ++++--- .../domain-data/autoscaler-health-data.service.ts | 3 ++- .../domain-data/autoscaler-info-data.service.ts | 3 ++- .../domain-data/autoscaler-metric-data.service.ts | 5 +++-- .../autoscaler-policy-data.service.spec.ts | 11 +++++++++++ .../domain-data/autoscaler-policy-data.service.ts | 7 ++++--- .../autoscaler-scaling-history-data.service.ts | 5 +++-- 9 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts index b657ed42bf..0198ebe426 100644 --- a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.spec.ts @@ -1,6 +1,7 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { describe, it, expect } from 'vitest'; import { - isEqual, buildLegendData, shiftArray, + autoscalerErrorMessage, isEqual, buildLegendData, shiftArray, } from './autoscaler-util'; describe('Autoscaler Util Helper', () => { @@ -71,4 +72,10 @@ describe('Autoscaler Util Helper', () => { expect(shiftArray([0, 2, 3], 1)).toEqual([1, 3, 4]); expect(shiftArray([1, 6, 9], -1)).toEqual([0, 5, 8]); }); + it('autoscalerErrorMessage', () => { + const refusal = new HttpErrorResponse({ status: 403, error: { error: 'The application is not bound to Auto-Scaling service' } }); + expect(autoscalerErrorMessage(refusal)).toBe('The application is not bound to Auto-Scaling service'); + const other = new HttpErrorResponse({ status: 500, error: 'boom', url: '/x' }); + expect(autoscalerErrorMessage(other)).toBe(other.message); + }); }); diff --git a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts index 9daea15e43..e8c1d9931a 100644 --- a/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts +++ b/src/frontend/packages/cf-autoscaler/src/core/autoscaler-helpers/autoscaler-util.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { addDays, format, setHours, setMinutes } from 'date-fns'; import { @@ -142,6 +143,13 @@ export const PolicyAlert = { alertInvalidPolicyTriggerScheduleEmpty: 'At least one Scaling Rule or Schedule should be defined.', }; +// The autoscaler API gives its reason as {"error": "..."}; the request's own +// message only names the URL and the status +export function autoscalerErrorMessage(err: unknown): string { + const reason = (err as HttpErrorResponse)?.error?.error; + return typeof reason === 'string' ? reason : (err as HttpErrorResponse)?.message ?? String(err); +} + export function isEqual(a: any, b: any): boolean { if (typeof a !== typeof b) { return false; diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-credential-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-credential-data.service.ts index 95e81f371e..95291cf756 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-credential-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-credential-data.service.ts @@ -1,10 +1,11 @@ -import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { environment } from '@stratosui/core'; import { AppAutoscalerCredential } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; // Per-(endpoint, app) state. Keyed by `${endpointGuid}::${appGuid}` so a // single service instance can manage credentials for multiple apps across @@ -70,7 +71,7 @@ export class AutoscalerCredentialDataService { this.patch(endpointGuid, appGuid, { credential: result, loading: false, error: null }); return result; } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { loading: false, error: message }); throw err; } @@ -83,7 +84,7 @@ export class AutoscalerCredentialDataService { await firstValueFrom(this.http.delete(this.url(appGuid), { headers })); this.patch(endpointGuid, appGuid, { credential: null, loading: false, error: null }); } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { loading: false, error: message }); throw err; } diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-health-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-health-data.service.ts index b6e41bfae5..a3b6ed5bc4 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-health-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-health-data.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { environment } from '@stratosui/core'; import { AppAutoscalerHealth } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; interface HealthState { health: AppAutoscalerHealth | null; @@ -60,7 +61,7 @@ export class AutoscalerHealthDataService { this.patch(endpointGuid, { health: null, loading: false, error: null }); return; } - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, { health: null, loading: false, error: message }); } } diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-info-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-info-data.service.ts index c2d1e4dd7e..5e9964c556 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-info-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-info-data.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { environment } from '@stratosui/core'; import { AutoscalerInfo } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; // Per-endpoint state held in one signal-of-map. Keyed by endpointGuid // so multiple CF endpoints can be queried independently from the same @@ -97,7 +98,7 @@ export class AutoscalerInfoDataService { this.patch(endpointGuid, { info: null, loading: false, error: null }); return; } - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, { info: null, loading: false, error: message }); } } diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-metric-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-metric-data.service.ts index 64615233b3..24767d5d43 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-metric-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-metric-data.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; @@ -6,6 +6,7 @@ import { environment } from '@stratosui/core'; import { PaginationResponse } from '../../../../cloud-foundry/src/store/types/cf-api.types'; import { AppAutoscalerMetricData } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; // Time-window query for the autoscaler aggregated_metric_histories // endpoint. Mirrors the legacy `AutoscalerPaginationParams` shape so @@ -90,7 +91,7 @@ export class AutoscalerMetricDataService { const metrics = response?.resources ?? []; this.patch(endpointGuid, appGuid, metricType, { metrics, loading: false, error: null }); } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, metricType, { metrics: [], loading: false, error: message }); } } diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.spec.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.spec.ts index 920e5f8485..aec8f9f572 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.spec.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.spec.ts @@ -99,6 +99,17 @@ describe('AutoscalerPolicyDataService', () => { expect(svc.noPolicy(ENDPOINT_GUID, APP_GUID)()).toBe(false); }); + it('load() keeps the autoscaler reason for a refusal', async () => { + const promise = svc.load(ENDPOINT_GUID, APP_GUID); + httpMock.expectOne(POLICY_URL).flush( + { error: 'The application is not bound to Auto-Scaling service' }, + { status: 403, statusText: 'Forbidden' }, + ); + await promise; + + expect(svc.error(ENDPOINT_GUID, APP_GUID)()).toBe('The application is not bound to Auto-Scaling service'); + }); + it('update() PUTs the transformed policy and refreshes signals', async () => { const promise = svc.update(ENDPOINT_GUID, APP_GUID, samplePolicy()); diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.ts index a2e11ffe85..cd2e2a1fc6 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-policy-data.service.ts @@ -9,6 +9,7 @@ import { autoscalerTransformMapToArray, } from '../../core/autoscaler-helpers/autoscaler-transform-policy'; import { AppAutoscalerPolicy, AppAutoscalerPolicyLocal } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; interface PolicyState { policy: AppAutoscalerPolicyLocal | null; @@ -101,7 +102,7 @@ export class AutoscalerPolicyDataService { this.patch(endpointGuid, appGuid, { policy: null, loading: false, error: null, noPolicy: true }); return; } - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { policy: null, loading: false, error: message, noPolicy: false }); } } @@ -117,7 +118,7 @@ export class AutoscalerPolicyDataService { const next = autoscalerTransformArrayToMap(wire); this.patch(endpointGuid, appGuid, { policy: next, loading: false, error: null, noPolicy: false }); } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { loading: false, error: message }); throw err; } @@ -135,7 +136,7 @@ export class AutoscalerPolicyDataService { noPolicy: true, }); } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { deleting: false, deletionError: message }); throw err; } diff --git a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-scaling-history-data.service.ts b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-scaling-history-data.service.ts index e6707d089f..0bd6c2a6e1 100644 --- a/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-scaling-history-data.service.ts +++ b/src/frontend/packages/cf-autoscaler/src/services/domain-data/autoscaler-scaling-history-data.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; @@ -6,6 +6,7 @@ import { environment } from '@stratosui/core'; import { PaginationResponse } from '../../../../cloud-foundry/src/store/types/cf-api.types'; import { AppAutoscalerEvent } from '../../store/app-autoscaler.types'; +import { autoscalerErrorMessage } from '../../core/autoscaler-helpers/autoscaler-util'; // Per-(endpoint, app) state. Keyed by `${endpointGuid}::${appGuid}` so a // single service instance can manage scaling-history reads for multiple apps @@ -105,7 +106,7 @@ export class AutoscalerScalingHistoryDataService { error: null, }); } catch (err) { - const message = (err as HttpErrorResponse)?.message ?? String(err); + const message = autoscalerErrorMessage(err); this.patch(endpointGuid, appGuid, { loading: false, error: message }); throw err; } From e5dc8b0ca1b749731c6bef789dc2b1c44d44304f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:16:45 -0700 Subject: [PATCH 090/121] fix(autoscaler): show policy actions and refusals The policy actions sat in a page sub-nav, but the application action bar owns the sub-nav of the application page, so Create, Edit and Delete Policy never showed. They now sit at the top of the tab. An app that is not bound to the autoscaler service said it had no policy and to create one, and a snackbar showed the raw 403 of the events request. The tab now shows the autoscaler's reason in place of the notice, offers Create Policy only when the app has no policy, and asks for the events only when the policy request succeeded. --- .../autoscaler-tab-extension.component.html | 11 ++-- ...autoscaler-tab-extension.component.spec.ts | 30 ++++++++++- .../autoscaler-tab-extension.component.ts | 50 ++++++------------- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html index 66c334e09b..7ac0459f61 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.html @@ -1,4 +1,5 @@ -<app-page-sub-nav> +<!-- The application action bar owns the page sub-nav, so the policy actions sit in the tab --> +<div class="flex items-center px-6 pt-4"> @if (appAutoscalerPolicy(); as policy) { <button class="btn my-0 mr-2.5 ml-0 py-0 px-1.5" name="edit" (click)="updatePolicyPage()"> <span class="flex items-center"> @@ -17,14 +18,14 @@ </span> </button> } - } @else { + } @else if (noPolicy()) { <button class="btn my-0 mr-2.5 ml-0 py-0 px-1.5" name="add" (click)="updatePolicyPage(true)"> <span class="flex items-center"> <app-custom-icon class="mr-1.5">add</app-custom-icon> Create Policy </span> </button> } -</app-page-sub-nav> +</div> <div class="flex relative w-full"> <app-tile-grid class="w-full"> @if (appAutoscalerPolicy(); as policy) { @@ -340,6 +341,10 @@ } </app-tile-group> } + @if (policyError() && !appAutoscalerPolicy()) { + <app-no-content-message [icon]="'meter'" [iconFont]="'stratos-icons'" [firstLine]="policyError()!"> + </app-no-content-message> + } @if (showNoPolicyMessage()) { <app-no-content-message [icon]="'meter'" [iconFont]="'stratos-icons'" [firstLine]="noPolicyMessageFirstLine" [secondLine]="noPolicyMessageSecondLine"> diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts index fbb19eb33f..b340c1f9cc 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.spec.ts @@ -112,6 +112,7 @@ describe('AutoscalerTabExtensionComponent', () => { const httpMock = TestBed.inject(HttpTestingController); httpMock.match(r => r.url.endsWith('/policy')) .forEach(req => req.flush('Not Found', { status: 404, statusText: 'Not Found' })); + await settle(); httpMock.match(r => r.url.endsWith('/event')).forEach(req => req.flush({ resources: [{ old_instances: 1, new_instances: 2, reason: 'r', message: 'm', timestamp: 0, error: '' }], total_results: 1, @@ -120,7 +121,34 @@ describe('AutoscalerTabExtensionComponent', () => { await settle(); expect(fixture.nativeElement.textContent) - .toContain('This application has no Autoscaler Policy. To create a policy click the + icon above'); + .toContain('This application has no Autoscaler Policy. To create a policy click Create Policy above'); + }); + + // The application action bar owns the page sub-nav, so the tab's own + // actions must render in the tab + it('offers Create Policy in the tab when the app has no policy', async () => { + const httpMock = TestBed.inject(HttpTestingController); + httpMock.match(r => r.url.endsWith('/policy')) + .forEach(req => req.flush('Not Found', { status: 404, statusText: 'Not Found' })); + await settle(); + + expect(fixture.nativeElement.querySelector('button[name="add"]')).not.toBeNull(); + }); + + it('shows why the autoscaler refused the app instead of offering a policy', async () => { + const httpMock = TestBed.inject(HttpTestingController); + httpMock.match(r => r.url.endsWith('/policy')).forEach(req => req.flush( + { error: 'The application is not bound to Auto-Scaling service' }, + { status: 403, statusText: 'Forbidden' }, + )); + await settle(); + + const text = fixture.nativeElement.textContent; + expect(text).toContain('The application is not bound to Auto-Scaling service'); + expect(text).not.toContain('no Autoscaler Policy'); + expect(fixture.nativeElement.querySelector('button[name="add"]')).toBeNull(); + // The events would be refused for the same reason + expect(httpMock.match(r => r.url.endsWith('/event')).length).toBe(0); }); afterAll(() => { }); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts index 14c0e92129..3c466bbd8c 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-tab-extension/autoscaler-tab-extension.component.ts @@ -34,7 +34,6 @@ import { CustomIconComponent, MetadataItemComponent, NoContentMessageComponent, - PageSubNavComponent, PollingIndicatorComponent, TileComponent, TileGridComponent, @@ -127,7 +126,6 @@ interface AutoscalerTabPaginationParams { imports: [ CommonModule, BaseChartDirective, - PageSubNavComponent, TileGridComponent, TileGroupComponent, TileComponent, @@ -199,6 +197,9 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { // True once the policy lookup has found none (or it was detached); false // while it loads, so the notice does not flash before the policy lands. noPolicy!: Signal<boolean>; + // Why the policy could not be read, e.g. the app is not bound to the + // autoscaler service; shown in place of the no-policy notice + policyError!: Signal<string | null>; public readonly showAutoscalerHistory = computed(() => { const history = this.appAutoscalerScalingHistory(); @@ -206,12 +207,12 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { }); public readonly showNoPolicyMessage = computed(() => { const history = this.appAutoscalerScalingHistory(); - return !this.appAutoscalerPolicy() && (!history || history.length === 0); + return this.noPolicy() && (!history || history.length === 0); }); public noPolicyMessageFirstLine = 'This application has no Autoscaler Policy'; public noPolicyMessageSecondLine = { - text: 'To create a policy click the + icon above' + text: 'To create a policy click Create Policy above' }; private appAutoscalerPolicySnackBarRef!: TailwindSnackBarRef<any>; @@ -256,12 +257,12 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { this.autoscalerInfoData.load(cfGuid); this.canManageCredentials = this.autoscalerInfoData.canManageCredentials(cfGuid); - // Policy fetch via the signal-native data service. The policy signal - // holds null while loading (or with no policy) and the policy local - // form once loaded. - void this.policyData.load(cfGuid, appGuid); + // Policy state from the signal-native data service, fetched below. The + // policy signal holds null while loading (or with no policy) and the + // policy local form once loaded. this.appAutoscalerPolicy = this.policyData.policy(cfGuid, appGuid); this.noPolicy = this.policyData.noPolicy(cfGuid, appGuid); + this.policyError = this.policyData.error(cfGuid, appGuid); // The gauges follow the policy: it lands after the first render on a // first visit, and changes when the policy is edited or detached. @@ -275,7 +276,12 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { // results per (cnsi, app). this.appAutoscalerScalingHistory = this.scalingHistoryData.events(cfGuid, appGuid); this.scalingHistoryLoading = this.scalingHistoryData.loading(cfGuid, appGuid); - this.fetchScalingHistory(); + // The autoscaler refuses the events for the same reasons as the policy + void this.policyData.load(cfGuid, appGuid).then(() => { + if (!this.policyError()) { + this.fetchScalingHistory(); + } + }); this.initErrorSub(); } @@ -341,30 +347,6 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { const cfGuid = this.applicationService.cfGuid; const appGuid = this.applicationService.appGuid; - // Signal-native policy error surfacing. Replaces the legacy - // EntityService.entityMonitor.entityRequest$ subscription. The data - // service distinguishes "no policy" (404 → noPolicy=true, error=null) - // from real failures (error message), so we only need to surface - // real errors here. The "Autoscaler not available" / "plugin not - // available" filter from the legacy code is preserved. - let lastPolicyError: string | null = null; - effect(() => { - const errorMessage = this.policyData.error(cfGuid, appGuid)(); - if (!errorMessage || errorMessage === lastPolicyError) { - return; - } - const isAutoscalerUnavailable = errorMessage.includes('Autoscaler not available') || - errorMessage.includes('Autoscaler plugin not available'); - lastPolicyError = errorMessage; - if (isAutoscalerUnavailable) { - return; - } - if (this.appAutoscalerPolicySnackBarRef) { - this.appAutoscalerPolicySnackBarRef.dismiss(); - } - this.appAutoscalerPolicySnackBarRef = this.appAutoscalerPolicySnackBar.open(errorMessage, 'Dismiss'); - }, { injector: this.injector }); - // Signal-native scaling-history error surfacing. Effect re-runs whenever // the data-service error signal changes; mirrors the legacy // distinctUntilChanged + isAutoscalerUnavailable filter. @@ -456,7 +438,7 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { this.applicationService.cfGuid, this.applicationService.appGuid, { ...this.paramsHistory }, - ); + ).catch(() => undefined); } getMetricUnit(metricType: string, unit?: string) { From 06ed5b3ac657e31e94c0391da933ac4bfb024409 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:18:01 -0700 Subject: [PATCH 091/121] fix(deploy): label a second deploy "Deploy again" After a deploy, Previous leads back to the overrides step, whose Next starts another deploy. It still read "Deploy" (or "Redeploy"), as if nothing had run yet. Once a deploy has started it now reads "Deploy again". --- .../deploy-application.component.spec.ts | 10 ++++++++++ .../deploy-application/deploy-application.component.ts | 10 ++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts index a4dde6474e..e7c2609a60 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.spec.ts @@ -141,6 +141,16 @@ describe('DeployApplicationComponent', () => { expect(mockStep3.onEnter).toHaveBeenLastCalledWith(undefined); }); + // Previous from the deploy step leads back to the overrides, whose Next + // then starts another deploy + it('labels the overrides Next "Deploy again" once a deploy has started', () => { + (component as any)._step3 = { onEnter: vi.fn() }; + expect(component.step4Handle.nextButtonText!()).toBe('Deploy'); + + component.step3Handle.onEnter!(); + expect(component.step4Handle.nextButtonText!()).toBe('Deploy again'); + }); + // Regression guard for the "Source Config shows no options / Next disabled" // bug. step2_1 (the git commit picker) is rendered via [hidden], so its // @ViewChild fires at parent view-init — before step 2 has saved any diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts index 4821e5582e..79137f9d51 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts @@ -76,7 +76,8 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { selectedSourceType$: Observable<SourceType | undefined>; // Reactive deploy button text — switches to "Redeploy" when the - // wizard is invoked with an existing appGuid. Step 4's handle reads + // wizard is invoked with an existing appGuid, and to "Deploy again" once + // a deploy has started. Step 4's handle reads // this signal so changing the field once at construction time // cascades into the rendered button label. private deployButtonTextSignal = signal<string>('Deploy'); @@ -365,7 +366,12 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { // instantiates a fresh deployer. // The deployer is kept: entering again after a file deploy ended (via // Previous to the overrides) uploads the same files again and deploys. - onEnter: () => this._step3?.onEnter(this.pendingDeployer as any), + // Previous from here leads back to the overrides, whose Next then + // deploys again. + onEnter: () => { + this.deployButtonTextSignal.set('Deploy again'); + this._step3?.onEnter(this.pendingDeployer as any); + }, canClose: this.step3Closeable.asReadonly(), disablePrevious: this.step3DisablePrevious.asReadonly(), // step3.disablePrevious$ is true from entry until the deploy ends, From 8fa453bb1158bddd5b6805d2324a674c83d85bb6 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:37:52 -0700 Subject: [PATCH 092/121] fix(cf): drop dead registry release hooks The routes, orgs and users list config services are root-provided, so their DestroyRef release hooks never ran during a session, and each initialize() queued one more callback on the root injector. Remove them. Endpoint data entries are kept for the session on purpose; say so where the registry is defined. --- .../src/services/endpoint-data/endpoint-data.registry.ts | 4 +++- .../org/cf-orgs-signal-config.service.spec.ts | 2 +- .../org/cf-orgs-signal-config.service.ts | 6 +----- .../route/cf-routes-signal-config.service.spec.ts | 2 +- .../route/cf-routes-signal-config.service.ts | 6 +----- .../user/cf-users-signal-config.service.spec.ts | 1 - .../user/cf-users-signal-config.service.ts | 6 +----- 7 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.registry.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.registry.ts index 9e206c0cab..719a9da56d 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.registry.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.registry.ts @@ -12,6 +12,8 @@ interface RegistryEntry { refCount: number; } +// Entries are kept for the session (sticky) on purpose. refCount is +// diagnostics only (getSnapshot); nothing evicts on zero. @Injectable({ providedIn: 'root' }) export class EndpointDataRegistry implements OnDestroy { private readonly http = inject(HttpClient); @@ -143,7 +145,7 @@ export class EndpointDataRegistry implements OnDestroy { const entry = this.instances.get(guid); if (!entry) { return; } entry.refCount = Math.max(0, entry.refCount - 1); - // Instance stays in map with its data (sticky) — only removed on explicit evict + // Instance stays in the map with its data (sticky; see class comment). } ngOnDestroy(): void { diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.spec.ts index f3a3858640..eb51cf72d6 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.spec.ts @@ -23,7 +23,7 @@ function makeSvc(opts: { registerCleanup: vi.fn(), ...opts.controller, }; - const registry = { acquire: vi.fn(), peek: vi.fn(), release: vi.fn() } as unknown as EndpointDataRegistry; + const registry = { acquire: vi.fn(), peek: vi.fn() } as unknown as EndpointDataRegistry; TestBed.configureTestingModule({ providers: [ diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.ts index ec60381e83..2a9a1a2835 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/org/cf-orgs-signal-config.service.ts @@ -1,4 +1,4 @@ -import { DestroyRef, EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { ListStateStore } from '@stratosui/core'; @@ -21,7 +21,6 @@ import type { StOrg } from '../../../services/endpoint-data/stratos-types'; export class CfOrgsSignalConfigService { private readonly http = inject(HttpClient); private readonly registry = inject(EndpointDataRegistry); - private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly deleteController = inject(EntityDeleteController); @@ -143,9 +142,6 @@ export class CfOrgsSignalConfigService { } }); }); - this.destroyRef.onDestroy(() => { - this.registry.release(cnsiGuid); - }); } clearFilters(): void { diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.spec.ts index ebb9ea8ff8..dad828f637 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.spec.ts @@ -42,7 +42,7 @@ describe('CfRoutesSignalConfigService — route list fetch guard', () => { providers: [ provideZonelessChangeDetection(), { provide: HttpClient, useValue: http }, - { provide: EndpointDataRegistry, useValue: { acquire: vi.fn(() => eds), peek: vi.fn(() => eds), release: vi.fn() } }, + { provide: EndpointDataRegistry, useValue: { acquire: vi.fn(() => eds), peek: vi.fn(() => eds) } }, { provide: EntityDeleteController, useValue: {} }, CfRoutesSignalConfigService, ], diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.ts index c5d30da6a4..d723a925ea 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/route/cf-routes-signal-config.service.ts @@ -1,4 +1,4 @@ -import { DestroyRef, EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { SignalListDropdownOption } from '@stratosui/core'; @@ -50,7 +50,6 @@ export interface BulkResult { export class CfRoutesSignalConfigService { private readonly http = inject(HttpClient); private readonly registry = inject(EndpointDataRegistry); - private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly deleteController = inject(EntityDeleteController); @@ -161,9 +160,6 @@ export class CfRoutesSignalConfigService { } }); }); - this.destroyRef.onDestroy(() => { - this.registry.release(cnsiGuid); - }); } // Access to the endpoint-data service's apps signal so the component can diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.spec.ts index a52558616c..310529b75c 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.spec.ts @@ -46,7 +46,6 @@ function makeRegistryStub() { const endpointDataSvc = makeEndpointDataServiceStub(); return { acquire: vi.fn().mockReturnValue(endpointDataSvc), - release: vi.fn(), configure: vi.fn(), }; } diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.ts index d8af01da93..f2aad8eba9 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/user/cf-users-signal-config.service.ts @@ -1,4 +1,4 @@ -import { DestroyRef, EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { EffectRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import type { SignalListDropdownOption } from '@stratosui/core'; import { ListStateStore, naturalCompare } from '@stratosui/core'; @@ -32,7 +32,6 @@ import { CfUsersPagedDataService } from '../../data-services/cf-users-paged-data @Injectable({ providedIn: 'root' }) export class CfUsersSignalConfigService { private readonly registry = inject(EndpointDataRegistry); - private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly diag = inject(CfUserListDiagnosticsService); private readonly usersData = inject(CfUsersPagedDataService); @@ -277,9 +276,6 @@ export class CfUsersSignalConfigService { } }); }); - this.destroyRef.onDestroy(() => { - this.registry.release(cnsiGuid); - }); } // Per-space variant. Same machinery as initialize() — only difference is From 776c60398c23840a87fde99d755403543b41a348 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:38:36 -0700 Subject: [PATCH 093/121] fix(endpoints): derive the name filter once initialize() runs on every visit to the endpoints page and created a root-injector effect each time to rebuild the filter predicate. The root service kept every one of them. Derive the predicate with computed() instead, and drop the empty destroy hook. --- .../endpoints-signal-config.service.spec.ts | 59 +++++++++++++++++++ .../endpoints-signal-config.service.ts | 26 +++----- 2 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.spec.ts diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.spec.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.spec.ts new file mode 100644 index 0000000000..4f370ecae8 --- /dev/null +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.spec.ts @@ -0,0 +1,59 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EndpointModel, EndpointsDataService } from '@stratosui/store'; + +import { EndpointsSignalService } from '../../../core/signals/endpoints-signal.service'; +import { EndpointsSignalConfigService } from './endpoints-signal-config.service'; + +function ep(guid: string, name: string): EndpointModel { + return { guid, name } as EndpointModel; +} + +describe('EndpointsSignalConfigService', () => { + let svc: EndpointsSignalConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: EndpointsSignalService, + useValue: { endpoints: signal({ a: ep('a', 'Alpha'), b: ep('b', 'Beta'), c: ep('c', 'alphabet') }) }, + }, + { provide: EndpointsDataService, useValue: { loading: signal(false) } }, + ], + }); + svc = TestBed.inject(EndpointsSignalConfigService); + }); + + it('filters by name, case-insensitively', () => { + svc.initialize(); + expect(svc.view.filteredItems().length).toBe(3); + + svc.nameFilter.set(' ALPHA '); + TestBed.tick(); + expect(svc.view.filteredItems().map(e => e.guid).sort()).toEqual(['a', 'c']); + + svc.nameFilter.set(''); + TestBed.tick(); + expect(svc.view.filteredItems().length).toBe(3); + }); + + // The service is root-provided and initialize() runs on every visit to + // the endpoints page: anything it starts must not pile up per visit. + it('does not add a name-filter watcher per initialize()', () => { + const nameFilter = svc.nameFilter; + const reads = vi.spyOn(svc, 'nameFilter'); + for (let i = 0; i < 5; i++) { + svc.initialize(); + } + TestBed.tick(); + svc.view.filteredItems(); + reads.mockClear(); + + nameFilter.set('beta'); + TestBed.tick(); + expect(svc.view.filteredItems().map(e => e.guid)).toEqual(['b']); + expect(reads.mock.calls.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts index a33051a609..e01b08c744 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts @@ -1,4 +1,4 @@ -import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; import { ActionState, EndpointModel, @@ -124,8 +124,6 @@ export class ViewPipeline<T> { // visual / interaction language stays consistent across signal-list pages. @Injectable({ providedIn: 'root' }) export class EndpointsSignalConfigService { - private readonly destroyRef = inject(DestroyRef); - private readonly injector = inject(Injector); private readonly endpointsSignals = inject(EndpointsSignalService); private readonly endpointsData = inject(EndpointsDataService); @@ -142,11 +140,17 @@ export class EndpointsSignalConfigService { sort: [{ field: 'name', direction: 'asc' }, { field: 'name', direction: 'asc' }], }); - readonly filter: WritableSignal<(ep: EndpointModel) => boolean> = signal(() => true); readonly sort = this.state.sort as WritableSignal<SortSpec<EndpointModel>>; readonly pageSize = this.state.pageSize; readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal<string> = signal(''); + // Derived, not effect-driven: the service is root-provided and + // initialize() runs on every visit, so an effect there would add a + // root-lifetime watcher per visit. + readonly filter: Signal<(ep: EndpointModel) => boolean> = computed(() => { + const q = this.nameFilter().trim().toLowerCase(); + return (ep: EndpointModel) => !q || (ep.name ?? '').toLowerCase().includes(q); + }); readonly viewMode = this.state.viewMode; readonly loading: Signal<boolean> = this.endpointsData.loading; @@ -171,20 +175,6 @@ export class EndpointsSignalConfigService { this.pageIndex, this._sortExtractors.asReadonly(), ); - runInInjectionContext(this.injector, () => { - effect(() => { - const q = this.nameFilter().trim().toLowerCase(); - this.filter.set((ep: EndpointModel) => { - if (!q) return true; - return (ep.name ?? '').toLowerCase().includes(q); - }); - }); - }); - // No registry release pattern here — endpointEntitiesSelector is a pure - // store projection. The DestroyRef hook stays as a placeholder so future - // listeners (e.g. health-check pulse subscription) have a tidy attach - // point without restructuring the service. - this.destroyRef.onDestroy(() => undefined); } clearFilters(): void { From be8bc3351775c1704d27f05bef45e3a8534e014b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:38:36 -0700 Subject: [PATCH 094/121] fix(endpoints): end permission watch with page The Register permission check subscribed to long-lived permission streams and never unsubscribed, so a destroyed endpoints page stayed in memory. Build canRegisterEndpoint with toSignal() so the subscription ends with the page. --- .../endpoints-page.component.spec.ts | 21 +++++++- .../endpoints-page.component.ts | 48 ++++++++++--------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts index 1d3e5d6e2c..c04c03be4c 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts @@ -4,7 +4,7 @@ import { provideZonelessChangeDetection, signal } from '@angular/core'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { EntityCatalogHelper, EntityCatalogHelpers } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { CoreTestingModule } from "@test-framework/core-test.modules"; @@ -24,6 +24,7 @@ import { EndpointsPageComponent } from './endpoints-page.component'; describe('EndpointsPageComponent', () => { let component: EndpointsPageComponent; let fixture: ComponentFixture<EndpointsPageComponent>; + let perm$: Subject<boolean>; // Mock services const mockSessionService = { @@ -97,6 +98,8 @@ describe('EndpointsPageComponent', () => { }); beforeEach(async () => { + perm$ = new Subject<boolean>(); + vi.spyOn(TestBed.inject(CurrentUserPermissionsService), 'can').mockReturnValue(perm$); fixture = TestBed.createComponent(EndpointsPageComponent); component = fixture.componentInstance; await fixture.whenStable(); @@ -107,9 +110,25 @@ describe('EndpointsPageComponent', () => { }); it('offers Register Endpoint when no endpoints are registered', async () => { + perm$.next(true); await vi.waitFor(() => { const add = fixture.nativeElement.querySelector('[data-test="list-sub-nav-add"]'); expect(add?.textContent).toContain('Register Endpoint'); }); }); + + it('shows Register when a permission check passes', async () => { + expect(component.canRegisterEndpoint()).toBe(false); + perm$.next(true); + await fixture.whenStable(); + expect(component.canRegisterEndpoint()).toBe(true); + }); + + // The permission streams are long-lived: a subscription that outlives + // the page holds the destroyed component. + it('stops watching permissions when destroyed', () => { + expect(perm$.observed).toBe(true); + fixture.destroy(); + expect(perm$.observed).toBe(false); + }); }); 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 c98d82a0c4..2c5e7e29fe 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,13 +1,13 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, AfterViewInit, Component, ComponentRef, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef, inject, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { CustomTooltipDirective } from '../../../shared/components/custom-tooltip/custom-tooltip.directive'; import { Router, RouterModule } from '@angular/router'; import { EndpointsDataService, } from '@stratosui/store'; import { combineLatest, Observable, of, Subscription } from 'rxjs'; -import { take, delay, filter, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, take, delay, filter, map, switchMap, tap } from 'rxjs/operators'; import { CustomizationService, CustomizationsMetadata } from '../../../core/customizations.types'; import { EndpointsService } from '../../../core/endpoints.service'; @@ -60,6 +60,7 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit private endpointModalService = inject(EndpointModalService); private dashboardSignals = inject(DashboardSignalService); private endpointStatusSignals = inject(EndpointStatusSignalService); + private currentUserPermissionsService = inject(CurrentUserPermissionsService); // Bridge dashboard signal → observable in injection context (field init). private dashboardState$ = toObservable(this.dashboardSignals.dashboard); @@ -68,7 +69,27 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit // button would disappear after a modal open/close cycle. A signal reads cleanly // in the template with @if and always reflects the latest permission result, // regardless of surrounding component state churn. - public canRegisterEndpoint = signal<boolean>(false); + // + // Allowed when ANY of the permissions that allow endpoint registration + // passes — matching the previous directive-based behavior. toSignal + // ends the long-lived permission streams with the page. + public canRegisterEndpoint = toSignal( + this.sessionService.userEndpointsEnabled().pipe( + filter(enabled => enabled !== undefined && enabled !== null), + switchMap(enabled => { + const perms = enabled + ? [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT, StratosCurrentUserPermissions.EDIT_ENDPOINT] + : [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT]; + return combineLatest(perms.map(p => this.currentUserPermissionsService.can(p))); + }), + map(results => results.some(Boolean)), + catchError(err => { + console.error('Error resolving register-endpoint permission:', err); + return of(false); + }) + ), + { initialValue: false } + ); private healthCheckTimeout!: number; /** @@ -106,7 +127,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit constructor() { const endpointsService = this.endpointsService; const cs = inject(CustomizationService); - const currentUserPermissionsService = inject(CurrentUserPermissionsService); this.customizations = cs.get(); @@ -125,31 +145,13 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit error: (err) => console.error('Error checking persistence features:', err) }); - // Resolve the set of permissions that would allow endpoint registration, - // run each through the permission service, and fold into a single boolean. - // Allowed when ANY of the permission checks passes — matching the previous - // directive-based behavior. - this.sessionService.userEndpointsEnabled().pipe( - filter(enabled => enabled !== undefined && enabled !== null), - switchMap(enabled => { - const perms = enabled - ? [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT, StratosCurrentUserPermissions.EDIT_ENDPOINT] - : [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT]; - return combineLatest(perms.map(p => currentUserPermissionsService.can(p))); - }), - map(results => results.some(Boolean)) - ).subscribe({ - next: can => this.canRegisterEndpoint.set(can), - error: (err) => console.error('Error resolving register-endpoint permission:', err) - }); - // Is the backup/restore plugin available on the backend? // Defensive: Add catchError to handle session data access issues this.canBackupRestore$ = toObservable(this.authSignal.sessionData).pipe( filter(sessionData => !!sessionData), take(1), map(sessionData => sessionData?.plugins?.backup || false), - switchMap(enabled => enabled ? currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT) : of(false)) + switchMap(enabled => enabled ? this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT) : of(false)) ); // Create an observable to track when endpoints are loaded and ready From 64fa70d40a31ab62ba68c1d6f1ffb9bab3f3a742 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:39:00 -0700 Subject: [PATCH 095/121] fix(core): stop guards leaking root watchers authGuard and endpointsGuard called toObservable() on root signals on every run, and each call left a root effect that only the root injector could end, so every entry to the shell added more. The auth and endpoint status services now expose auth$ and status$, built once with observeSignal, whose watcher ends when the guard's take(1) unsubscribes. Guard behaviour is unchanged. --- .../core/src/core/auth-guard.service.spec.ts | 90 +++++++++++++++- .../core/src/core/auth-guard.service.ts | 6 +- .../core/src/core/endpoints.service.spec.ts | 102 +++++++++++++++++- .../core/src/core/endpoints.service.ts | 7 +- .../src/core/signals/auth-signal.service.ts | 12 ++- .../signals/endpoint-status-signal.service.ts | 11 +- 6 files changed, 212 insertions(+), 16 deletions(-) diff --git a/src/frontend/packages/core/src/core/auth-guard.service.spec.ts b/src/frontend/packages/core/src/core/auth-guard.service.spec.ts index ba297b3fa5..1964b7d939 100644 --- a/src/frontend/packages/core/src/core/auth-guard.service.spec.ts +++ b/src/frontend/packages/core/src/core/auth-guard.service.spec.ts @@ -1,8 +1,10 @@ import { TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; -import { Router } from '@angular/router'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import { Observable } from 'rxjs'; +import { AuthDataService, AuthState } from '@stratosui/store'; import { authGuard } from './auth-guard.service'; @@ -27,3 +29,85 @@ describe('authGuard', () => { expect(typeof authGuard).toBe('function'); }); }); + +describe('authGuard runs', () => { + const route = {} as ActivatedRouteSnapshot; + const state = { url: '/applications?x=1' } as RouterStateSnapshot; + const auth = signal<AuthState | undefined>(undefined); + // Every read of auth comes from a live watcher of the signal, so the + // count after a change is the number of watchers left behind. + let authReads = 0; + let navigateAndRememberRedirect: ReturnType<typeof vi.fn>; + + function authState(valid: boolean, verifying = false): AuthState { + return { loggedIn: valid, verifying, sessionData: { valid } } as AuthState; + } + + function runGuard(): boolean[] { + const results: boolean[] = []; + const guard$ = TestBed.runInInjectionContext(() => authGuard(route, state)) as Observable<boolean>; + guard$.subscribe(result => results.push(result)); + return results; + } + + beforeEach(() => { + authReads = 0; + auth.set(authState(true)); + navigateAndRememberRedirect = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { + provide: AuthDataService, + useValue: { + auth: () => { + authReads++; + return auth(); + }, + navigateAndRememberRedirect, + }, + }, + ], + }); + }); + + it('allows a valid session', () => { + const results = runGuard(); + TestBed.tick(); + expect(results).toEqual([true]); + expect(navigateAndRememberRedirect).not.toHaveBeenCalled(); + }); + + it('redirects to login and remembers the target when the session is invalid', () => { + auth.set(authState(false)); + const results = runGuard(); + TestBed.tick(); + expect(results).toEqual([false]); + expect(navigateAndRememberRedirect).toHaveBeenCalledWith(['/login'], { + path: '/applications', + queryParams: { x: '1' }, + }); + }); + + it('waits until verification ends', () => { + auth.set(authState(true, true)); + const results = runGuard(); + TestBed.tick(); + expect(results).toEqual([]); + + auth.set(authState(true)); + TestBed.tick(); + expect(results).toEqual([true]); + }); + + it('leaves no auth watcher behind after it resolves', () => { + const runs = [runGuard(), runGuard(), runGuard()]; + TestBed.tick(); + expect(runs).toEqual([[true], [true], [true]]); + + authReads = 0; + auth.set(authState(false)); + TestBed.tick(); + expect(authReads).toBe(0); + }); +}); diff --git a/src/frontend/packages/core/src/core/auth-guard.service.ts b/src/frontend/packages/core/src/core/auth-guard.service.ts index 95e80236ae..5a08ba55b9 100644 --- a/src/frontend/packages/core/src/core/auth-guard.service.ts +++ b/src/frontend/packages/core/src/core/auth-guard.service.ts @@ -1,5 +1,4 @@ import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; import { CanActivateFn, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; @@ -38,8 +37,9 @@ function snapshotToQueryParams(state: RouterStateSnapshot): { [key: string]: str export const authGuard: CanActivateFn = (_route, state): Observable<boolean> => { const authSignals = inject(AuthSignalService); - // Read the auth slice via the signal-native projection. - return toObservable(authSignals.auth).pipe( + // Read the auth slice via the signal-native projection. auth$ is built + // once; calling toObservable() here left a root watcher per guard run. + return authSignals.auth$.pipe( // Wait for auth state to stabilize before evaluation // This prevents redirecting to /login during transient state updates filter(authState => !!authState && !authState.verifying), diff --git a/src/frontend/packages/core/src/core/endpoints.service.spec.ts b/src/frontend/packages/core/src/core/endpoints.service.spec.ts index c5ab957118..bbd4e1270d 100644 --- a/src/frontend/packages/core/src/core/endpoints.service.spec.ts +++ b/src/frontend/packages/core/src/core/endpoints.service.spec.ts @@ -1,11 +1,15 @@ import { inject, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { defer, Observable, of } from 'rxjs'; +import { AuthDataService, AuthState, EndpointsDataService } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { CoreTestingModule } from '@test-framework/core-test.modules'; import { SessionService } from '../shared/services/session.service'; import { CoreModule } from './core.module'; -import { EndpointsService } from './endpoints.service'; +import { EndpointsService, endpointsGuard } from './endpoints.service'; +import { UserService } from './user.service'; import { UtilsService } from './utils.service'; describe('EndpointsService', () => { @@ -30,3 +34,95 @@ describe('EndpointsService', () => { expect(service).toBeTruthy(); })); }); + +describe('endpointsGuard runs', () => { + const route = {} as ActivatedRouteSnapshot; + const routeState = { url: '/home' } as RouterStateSnapshot; + const auth = signal<AuthState | undefined>(undefined); + const loading = signal(true); + // Every read comes from a live watcher, so the counts after a change are + // the watchers left behind. The endpoint status is a computed over + // loading, so any number of status watchers reads loading once. + let authReads = 0; + let loadingReads = 0; + let haveRegistered = true; + let navigate: ReturnType<typeof vi.fn>; + + function authState(valid: boolean): AuthState { + return { loggedIn: true, verifying: false, sessionData: { valid } } as AuthState; + } + + function runGuard(): boolean[] { + const results: boolean[] = []; + const guard$ = TestBed.runInInjectionContext(() => endpointsGuard(route, routeState)) as Observable<boolean>; + guard$.subscribe(result => results.push(result)); + return results; + } + + beforeEach(() => { + authReads = 0; + loadingReads = 0; + haveRegistered = true; + auth.set(authState(true)); + loading.set(false); + navigate = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: Router, useValue: { navigate } }, + { + provide: AuthDataService, + useValue: { auth: () => { authReads++; return auth(); } }, + }, + { + provide: EndpointsDataService, + useValue: { loading: () => { loadingReads++; return loading(); }, error: signal(null) }, + }, + { + provide: EndpointsService, + useValue: { + haveRegistered$: defer(() => of(haveRegistered)), + haveConnected$: of(true), + disablePersistenceFeatures$: of(false), + }, + }, + { provide: UserService, useValue: { isAdmin$: of(true), isEndpointAdmin$: of(false) } }, + { provide: SessionService, useValue: { userEndpointsEnabled: () => of(false) } }, + ], + }); + }); + + it('allows navigation when endpoints are registered and connected', () => { + const results = runGuard(); + TestBed.tick(); + expect(results).toEqual([true]); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('waits for endpoints to load, then redirects an admin with none registered', () => { + haveRegistered = false; + loading.set(true); + const results = runGuard(); + TestBed.tick(); + expect(results).toEqual([]); + + loading.set(false); + TestBed.tick(); + expect(results).toEqual([false]); + expect(navigate).toHaveBeenCalledWith(['/endpoints']); + }); + + it('leaves no auth or status watcher behind after it resolves', () => { + const runs = [runGuard(), runGuard(), runGuard()]; + TestBed.tick(); + expect(runs).toEqual([[true], [true], [true]]); + + authReads = 0; + loadingReads = 0; + auth.set(authState(false)); + loading.set(true); + TestBed.tick(); + expect(authReads).toBe(0); + expect(loadingReads).toBe(0); + }); +}); diff --git a/src/frontend/packages/core/src/core/endpoints.service.ts b/src/frontend/packages/core/src/core/endpoints.service.ts index e6abb75e05..5288b19d76 100644 --- a/src/frontend/packages/core/src/core/endpoints.service.ts +++ b/src/frontend/packages/core/src/core/endpoints.service.ts @@ -262,10 +262,11 @@ export const endpointsGuard: CanActivateFn = ( const endpointStatusSignals = inject(EndpointStatusSignalService); // Reroute user to endpoint/no endpoint screens if there are no connected or registered endpoints. - // Auth + endpoint loading state both sourced from signal services. + // Auth + endpoint loading state both sourced from signal services, via + // observables built once there (toObservable() here leaked per run). const guardLogic$ = observableCombineLatest( - toObservable(authSignals.auth), - toObservable(endpointStatusSignals.status) + authSignals.auth$, + endpointStatusSignals.status$ ).pipe( filter(([state, endpointState]) => { // Only proceed when logged in and endpoints are done loading diff --git a/src/frontend/packages/core/src/core/signals/auth-signal.service.ts b/src/frontend/packages/core/src/core/signals/auth-signal.service.ts index b995388b21..f828a6f92d 100644 --- a/src/frontend/packages/core/src/core/signals/auth-signal.service.ts +++ b/src/frontend/packages/core/src/core/signals/auth-signal.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Signal, inject } from '@angular/core'; -import { AuthDataService, AuthState, RouterRedirect, SessionData } from '@stratosui/store'; +import { Injectable, Injector, Signal, inject } from '@angular/core'; +import { AuthDataService, AuthState, observeSignal, RouterRedirect, SessionData } from '@stratosui/store'; +import { Observable } from 'rxjs'; /** * W36-C Wave 1 — thin signal-native facade over {@link AuthDataService}. @@ -20,6 +21,13 @@ export class AuthSignalService { /** Raw auth slice. `undefined` until the data service mirrors its first value. */ readonly auth: Signal<AuthState | undefined> = this.authData.auth; + /** + * `auth` as an observable, built once for the route guards. Each subscriber + * gets its own watcher, which ends when it unsubscribes. toObservable() + * called per guard run left a root watcher behind on every navigation. + */ + readonly auth$: Observable<AuthState | undefined> = observeSignal(this.auth, inject(Injector)); + readonly loggedIn: Signal<boolean> = this.authData.loggedIn; readonly loggingIn: Signal<boolean> = this.authData.loggingIn; readonly verifying: Signal<boolean> = this.authData.verifying; diff --git a/src/frontend/packages/core/src/core/signals/endpoint-status-signal.service.ts b/src/frontend/packages/core/src/core/signals/endpoint-status-signal.service.ts index 75ea6db370..136109be75 100644 --- a/src/frontend/packages/core/src/core/signals/endpoint-status-signal.service.ts +++ b/src/frontend/packages/core/src/core/signals/endpoint-status-signal.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Signal, computed, inject } from '@angular/core'; -import { EndpointsDataService } from '@stratosui/store'; +import { Injectable, Injector, Signal, computed, inject } from '@angular/core'; +import { EndpointsDataService, observeSignal } from '@stratosui/store'; +import { Observable } from 'rxjs'; export interface EndpointStatusState { loading: boolean; @@ -22,6 +23,12 @@ export class EndpointStatusSignalService { message: this.endpointsService.error() ?? '', })); + /** + * `status` as an observable, built once for the endpoints guard. Each + * subscriber gets its own watcher, which ends when it unsubscribes. + */ + readonly status$: Observable<EndpointStatusState> = observeSignal(this.status, inject(Injector)); + readonly loading: Signal<boolean> = computed(() => !!this.status().loading); readonly error: Signal<boolean> = computed(() => !!this.status().error); readonly message: Signal<string> = computed(() => this.status().message ?? ''); From 24260b99d4555df4eea0dbe29bd9be339a0ed381 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:39:32 -0700 Subject: [PATCH 096/121] fix(cf): drop stale quota bridges on re-emit The org and space services bridged the quota signal with toObservable inside a switchMap. toObservable's effect ends only with its injector, so every org or space re-emission (load, then patch after an edit) left one more effect running until the page closed. Use observeSignal, which ends the effect when switchMap unsubscribes. --- ...cloud-foundry-organization.service.spec.ts | 91 +++++++++++++++++++ .../cloud-foundry-organization.service.ts | 6 +- .../cloud-foundry-space.service.spec.ts | 88 ++++++++++++++++++ .../services/cloud-foundry-space.service.ts | 7 +- 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts create mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts new file mode 100644 index 0000000000..2690820476 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts @@ -0,0 +1,91 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Signal, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { CnsiUsersSnapshotService } from '../../../services/endpoint-data/cnsi-users-snapshot.service'; +import { OrgDataRegistry } from '../../../services/endpoint-data/org-data.registry'; +import { QuotaDataService } from '../../../services/endpoint-data/quota-data.service'; +import { ServiceCatalogDataService } from '../../../services/endpoint-data/service-catalog-data.service'; +import { StOrgDetail, StOrgQuota } from '../../../services/endpoint-data/stratos-types'; +import { + CloudFoundryUserProvidedServicesService, +} from '../../../shared/services/cloud-foundry-user-provided-services.service'; +import { ActiveRouteCfOrgSpace } from '../cf-page.types'; +import { CloudFoundryEndpointService } from './cloud-foundry-endpoint.service'; +import { CloudFoundryOrganizationService } from './cloud-foundry-organization.service'; + +describe('CloudFoundryOrganizationService — quotaDefinition$', () => { + let org: ReturnType<typeof signal<StOrgDetail | null>>; + let quota: ReturnType<typeof signal<StOrgQuota | null>>; + // Each live signal-to-observable bridge reads the quota value once per + // write, so the read count after one write is the number of live bridges. + let quotaReads: number; + + beforeEach(() => { + org = signal<StOrgDetail | null>(null); + quota = signal<StOrgQuota | null>(null); + quotaReads = 0; + const countedQuota = (() => { + quotaReads++; + return quota(); + }) as Signal<StOrgQuota | null>; + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: ActiveRouteCfOrgSpace, useValue: { cfGuid: 'cf-1', orgGuid: 'org-1' } }, + { + provide: OrgDataRegistry, + useValue: { acquire: () => ({ load: () => of(undefined), org, spaces: signal([]) }) }, + }, + { provide: QuotaDataService, useValue: { orgQuota: () => ({ value: countedQuota }) } }, + { provide: CnsiUsersSnapshotService, useValue: { users: () => signal(null) } }, + { provide: ServiceCatalogDataService, useValue: { serviceInstanceCount: () => ({ value: signal(0) }) } }, + { + provide: CloudFoundryUserProvidedServicesService, + useValue: { fetchUserProvidedServiceInstancesCount: () => of(0) }, + }, + { + provide: CloudFoundryEndpointService, + useValue: { + currentUser$: of({ guid: 'me' }), + apps$: of([]), + hasApps$: of(false), + appsLoading$: of(false), + fetchAppCount: () => of(0), + fetchRouteCount: () => of(0), + }, + }, + CloudFoundryOrganizationService, + ], + }); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('keeps one quota bridge alive however often the org re-emits', () => { + const svc = TestBed.inject(CloudFoundryOrganizationService); + const seen: StOrgQuota[] = []; + const sub = svc.quotaDefinition$.subscribe(q => seen.push(q)); + + // Each org write (load, then patch() after an edit) re-runs the + // switchMap, which builds a new bridge to the quota signal. + for (const name of ['a', 'b', 'c']) { + org.set({ guid: 'org-1', name, quotaGuid: 'quota-1' } as StOrgDetail); + TestBed.tick(); + } + + quotaReads = 0; + const q = { guid: 'quota-1', name: 'default' } as StOrgQuota; + quota.set(q); + TestBed.tick(); + + expect(quotaReads).toBe(1); + expect(seen).toEqual([q]); + sub.unsubscribe(); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts index 1158a968c2..e82f94a6a0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, Signal, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { Route } from '@angular/router'; +import { observeSignal } from '@stratosui/store'; import { combineLatest, EMPTY, Observable } from 'rxjs'; import { catchError, filter, map, shareReplay, switchMap } from 'rxjs/operators'; @@ -232,9 +233,12 @@ export class CloudFoundryOrganizationService { this.quotaDefinition$ = orgSnapshot$.pipe( map(o => o.quotaGuid), filter(quotaGuid => !!quotaGuid), + // observeSignal, not toObservable: toObservable's effect would outlive + // switchMap's unsubscribe, so each org re-emission (load, patch after + // an edit) would add one more effect for the life of the page. switchMap(quotaGuid => { const source = this.quotaData.orgQuota(this.cfGuid, quotaGuid); - return toObservable(source.value, { injector: this.injector }).pipe( + return observeSignal(source.value, this.injector).pipe( filter((q): q is StOrgQuota => !!q), ); }), diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts new file mode 100644 index 0000000000..c0066311ab --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts @@ -0,0 +1,88 @@ +import { Signal, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { CnsiUsersSnapshotService } from '../../../services/endpoint-data/cnsi-users-snapshot.service'; +import { QuotaDataService } from '../../../services/endpoint-data/quota-data.service'; +import { ServiceCatalogDataService } from '../../../services/endpoint-data/service-catalog-data.service'; +import { SpaceDataRegistry } from '../../../services/endpoint-data/space-data.registry'; +import { StSpace, StSpaceQuota } from '../../../services/endpoint-data/stratos-types'; +import { + CloudFoundryUserProvidedServicesService, +} from '../../../shared/services/cloud-foundry-user-provided-services.service'; +import { ActiveRouteCfOrgSpace } from '../cf-page.types'; +import { CloudFoundryEndpointService } from './cloud-foundry-endpoint.service'; +import { CloudFoundryOrganizationService } from './cloud-foundry-organization.service'; +import { CloudFoundrySpaceService } from './cloud-foundry-space.service'; + +describe('CloudFoundrySpaceService — spaceQuotaDefinition$', () => { + let space: ReturnType<typeof signal<StSpace | null>>; + let quota: ReturnType<typeof signal<StSpaceQuota | null>>; + // Each live signal-to-observable bridge reads the quota value once per + // write, so the read count after one write is the number of live bridges. + let quotaReads: number; + + beforeEach(() => { + space = signal<StSpace | null>(null); + quota = signal<StSpaceQuota | null>(null); + quotaReads = 0; + const countedQuota = (() => { + quotaReads++; + return quota(); + }) as Signal<StSpaceQuota | null>; + + TestBed.configureTestingModule({ + providers: [ + { provide: ActiveRouteCfOrgSpace, useValue: { cfGuid: 'cf-1', orgGuid: 'org-1', spaceGuid: 'space-1' } }, + { + provide: SpaceDataRegistry, + useValue: { acquire: () => ({ load: () => of(undefined), space }) }, + }, + { provide: QuotaDataService, useValue: { spaceQuota: () => ({ value: countedQuota }) } }, + { provide: CloudFoundryOrganizationService, useValue: { quotaDefinition$: of(null) } }, + { provide: CnsiUsersSnapshotService, useValue: { users: () => signal(null) } }, + { provide: ServiceCatalogDataService, useValue: { serviceInstanceCount: () => ({ value: signal(0) }) } }, + { + provide: CloudFoundryUserProvidedServicesService, + useValue: { fetchUserProvidedServiceInstancesCount: () => of(0) }, + }, + { + provide: CloudFoundryEndpointService, + useValue: { + currentUser$: of({ guid: 'me' }), + apps$: of([]), + hasApps$: of(false), + appsLoading$: of(false), + fetchAppCount: () => of(0), + }, + }, + CloudFoundrySpaceService, + ], + }); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('keeps one quota bridge alive however often the space re-emits', () => { + const svc = TestBed.inject(CloudFoundrySpaceService); + const seen: (StSpaceQuota | null)[] = []; + const sub = svc.spaceQuotaDefinition$.subscribe(q => seen.push(q)); + + // Each space write (load, then patch() after an edit) re-runs the + // switchMap, which builds a new bridge to the quota signal. + for (const name of ['a', 'b', 'c']) { + space.set({ guid: 'space-1', name, quotaGuid: 'quota-1' } as StSpace); + TestBed.tick(); + } + + quotaReads = 0; + const q = { guid: 'quota-1', name: 'small' } as StSpaceQuota; + quota.set(q); + TestBed.tick(); + + expect(quotaReads).toBe(1); + expect(seen.at(-1)).toEqual(q); + sub.unsubscribe(); + }); +}); 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 c061d32766..5508c5ccd5 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 @@ -1,5 +1,6 @@ import { Injectable, Injector, Signal, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; +import { observeSignal } from '@stratosui/store'; import { combineLatest, Observable, of } from 'rxjs'; import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; @@ -142,10 +143,14 @@ export class CloudFoundrySpaceService { // QuotaDataService returns a SignalSource; we bridge to Observable // for backwards-compat templates and chain into the org-quota // fallback below. + // + // observeSignal, not toObservable: toObservable's effect would outlive + // switchMap's unsubscribe, so each space re-emission (load, patch after + // an edit) would add one more effect for the life of the page. this.spaceQuotaDefinition$ = space$.pipe( map(s => s.quotaGuid || null), switchMap(quotaGuid => quotaGuid - ? toObservable(this.quotaData.spaceQuota(this.cfGuid, quotaGuid).value, { injector: this.injector }) + ? observeSignal(this.quotaData.spaceQuota(this.cfGuid, quotaGuid).value, this.injector) : of(null as StSpaceQuota | null)), shareReplay({ bufferSize: 1, refCount: false }), ); From 23c9c664cda9b75912f17ee4f82a0a15400bf576 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:40:22 -0700 Subject: [PATCH 097/121] fix(metrics): stop the window poll with its host The range selector manager is provided per host component, but only one of its two hosts ended its 10 s window poll on destroy. Each visit to a page with a parent range selector left a timer that ticked the whole app every 10 s. End the poll in the manager's ngOnDestroy so it stops with whichever host owns it. --- ...cs-parent-range-selector.component.spec.ts | 40 +++++++++++++++++-- .../metrics-range-selector-manager.service.ts | 11 ++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.spec.ts b/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.spec.ts index 2b99e9c6dd..671ddfe136 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.spec.ts @@ -1,9 +1,11 @@ import { ComponentFixture, 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 { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MetricQueryConfig, MetricQueryType } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@test-framework'; +import { MetricsRangeSelectorManagerService } from '../../services/metrics-range-selector-manager.service'; import { MetricsRangeSelectorService } from '../../services/metrics-range-selector.service'; import { MetricsParentRangeSelectorComponent } from './metrics-parent-range-selector.component'; @@ -26,13 +28,43 @@ describe('MetricsParentRangeSelectorComponent', () => { }).compileComponents(); }); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(MetricsParentRangeSelectorComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + }); + + afterEach(() => { + vi.useRealTimers(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('stops the window poll when the selector is destroyed', () => { + vi.useFakeTimers(); + const manager = fixture.debugElement.injector.get(MetricsRangeSelectorManagerService); + const tick = vi.spyOn(TestBed.inject(ApplicationRef), 'tick').mockImplementation(() => undefined); + const requests = vi.fn(); + manager.request$.subscribe(requests); + // A window range starts the 10 s poll that re-requests and ticks the app. + manager.init({ + endpointGuid: 'e1', + url: '/pp/v1/metrics/test', + query: new MetricQueryConfig('up'), + queryType: MetricQueryType.QUERY, + windowValue: '1:hour', + }); + vi.advanceTimersByTime(manager.pollInterval); + expect(tick).toHaveBeenCalledTimes(1); + + fixture.destroy(); + tick.mockClear(); + requests.mockClear(); + vi.advanceTimersByTime(manager.pollInterval * 3); + + expect(tick).not.toHaveBeenCalled(); + expect(requests).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 92d5234c6d..b0859d97c4 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, inject } from '@angular/core'; +import { ApplicationRef, Injectable, NgZone, OnDestroy, inject } from '@angular/core'; import { MetricsRequest, MetricQueryType } from '@stratosui/store'; import { Subject } from 'rxjs'; import { isValid, isEqual } from 'date-fns'; @@ -9,7 +9,7 @@ import { ITimeRange } from './metrics-range-selector.types'; @Injectable({ providedIn: 'root' }) -export class MetricsRangeSelectorManagerService { +export class MetricsRangeSelectorManagerService implements OnDestroy { metricRangeService = inject(MetricsRangeSelectorService); private ngZone = inject(NgZone); private appRef = inject(ApplicationRef); @@ -85,6 +85,13 @@ export class MetricsRangeSelectorManagerService { this.endWindowPoll(); } + // Each host provides its own manager, so it is destroyed with the host. + // Without this the window poll ticks the whole app every pollInterval + // for every visit that did not call destroy(). + ngOnDestroy() { + this.endWindowPoll(); + } + get selectedTimeRange() { return this.selectedTimeRangeValue; } From 96e4fcba02f2f9b762c3bd7c0ca73583ade1d6d9 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:40:22 -0700 Subject: [PATCH 098/121] fix(metrics): end the observe effect on stop observe() created a root effect and dropped its ref, so stop() only cleared the current timer: a later request change fetched again and re-armed polling. Keep the ref, clear the timer in the effect cleanup and destroy the effect in stop(). The kube node metric stream now uses observeSignal so its watch ends when the stats card unsubscribes. --- .../services/kubernetes-node.service.spec.ts | 39 ++++++++++- .../services/kubernetes-node.service.ts | 5 +- .../src/services/metrics-data.service.spec.ts | 69 +++++++++++++++++++ .../src/services/metrics-data.service.ts | 40 +++++------ 4 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 src/frontend/packages/store/src/services/metrics-data.service.spec.ts diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.spec.ts index 72313b4b9a..84a2fa4d43 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.spec.ts @@ -1,4 +1,4 @@ -import { Signal, provideZonelessChangeDetection, signal } from '@angular/core'; +import { Signal, computed, provideZonelessChangeDetection, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { firstValueFrom } from 'rxjs'; @@ -7,7 +7,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MetricsDataService } from '../../../../store/src/services/metrics-data.service'; import { KubeNodeDataService } from '../../services/domain-data/kube-node-data.service'; import { KubernetesEndpointService } from './kubernetes-endpoint.service'; -import { KubernetesNodeService } from './kubernetes-node.service'; +import { MetricStatistic } from '../store/kube.types'; +import { KubeNodeMetric, KubernetesNodeService } from './kubernetes-node.service'; describe('KubernetesNodeService', () => { const refresh = vi.fn().mockResolvedValue(undefined); @@ -17,17 +18,30 @@ describe('KubernetesNodeService', () => { nodeByName: (_g: string, _n: string) => nodeSig as Signal<unknown>, }; + // observe() stub: counts how often something still watches its metrics. + const metricsSrc = signal<unknown>(null); + let metricsReads = 0; + const stop = vi.fn(); + const metricsDataStub = { + observe: vi.fn(() => ({ + metrics: computed(() => { metricsReads++; return metricsSrc(); }), + stop, + })), + }; + let svc: KubernetesNodeService; beforeEach(() => { vi.clearAllMocks(); + metricsSrc.set(null); + metricsReads = 0; nodeSig.set({ metadata: { name: 'node-a' }, status: { conditions: [] } }); TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), { provide: KubeNodeDataService, useValue: nodeDataStub }, - { provide: MetricsDataService, useValue: {} }, + { provide: MetricsDataService, useValue: metricsDataStub }, { provide: KubernetesEndpointService, useValue: { kubeGuid: 'k1' } }, { provide: ActivatedRoute, useValue: { snapshot: { params: { nodeName: 'node-a' } } } }, KubernetesNodeService, @@ -44,4 +58,23 @@ describe('KubernetesNodeService', () => { const node = await firstValueFrom(svc.nodeEntity$); expect(node.metadata.name).toBe('node-a'); }); + + it('ends the metric observation and its watch when the caller lets go', () => { + const { entity$, pollerSub } = svc.setupMetricObservable(KubeNodeMetric.CPU, MetricStatistic.MAXIMUM); + const values: number[] = []; + const sub = entity$.subscribe(v => values.push(v)); + metricsSrc.set({ data: { result: [{ value: [0, '7'] }] } }); + TestBed.tick(); + expect(values).toEqual([7]); + + // What the stats card does on destroy (async pipe + pollerSub). + sub.unsubscribe(); + pollerSub.unsubscribe(); + expect(stop).toHaveBeenCalledTimes(1); + + const reads = metricsReads; + metricsSrc.set({ data: { result: [{ value: [0, '9'] }] } }); + TestBed.tick(); + expect(metricsReads).toBe(reads); + }); }); 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 bd7fe7a200..6520a514b5 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 @@ -6,6 +6,7 @@ import { filter, map, publishReplay, refCount, take } from 'rxjs/operators'; import { getIdFromRoute } from '../../../../core/src/core/utils.service'; import { MetricQueryConfig } from '../../../../store/src/actions/metrics.actions'; +import { observeSignal } from '../../../../store/src/helpers/signal-interop'; import { MetricsDataService, MetricsRequest } from '../../../../store/src/services/metrics-data.service'; import { EntityInfo } from '../../../../store/src/types/api.types'; import { MetricQueryType } from '../../../../store/src/types/metric.types'; @@ -75,7 +76,9 @@ export class KubernetesNodeService { }; const requestSignal = signal(request); const observation = this.metricsDataService.observe<any>(requestSignal, { pollIntervalMs: 30000 }); - const entity$ = toObservable(observation.metrics, { injector: this.injector }).pipe( + // observeSignal, not toObservable: its watch ends when the caller + // unsubscribes, instead of piling up one per Metrics tab visit. + const entity$ = observeSignal(observation.metrics, this.injector).pipe( filter(metrics => !!metrics), map(metrics => { const result = metrics?.data?.result; diff --git a/src/frontend/packages/store/src/services/metrics-data.service.spec.ts b/src/frontend/packages/store/src/services/metrics-data.service.spec.ts new file mode 100644 index 0000000000..b3c53a0a0d --- /dev/null +++ b/src/frontend/packages/store/src/services/metrics-data.service.spec.ts @@ -0,0 +1,69 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MetricQueryConfig } from '../actions/metrics.actions'; +import { MetricQueryType } from '../types/metric.types'; +import { MetricsDataService, MetricsRequest } from './metrics-data.service'; + +const request = (metric: string): MetricsRequest => ({ + endpointGuid: 'e1', + url: '/pp/v1/metrics/test', + query: new MetricQueryConfig(metric), + queryType: MetricQueryType.QUERY, + windowValue: null, +}); + +describe('MetricsDataService', () => { + let service: MetricsDataService; + let http: HttpTestingController; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + service = TestBed.inject(MetricsDataService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches when the request changes and on each poll', () => { + const req = signal<MetricsRequest | null>(request('a')); + const obs = service.observe(req, { pollIntervalMs: 1000 }); + TestBed.tick(); + expect(http.match(r => r.url.includes('query=a')).length).toBe(1); + + vi.advanceTimersByTime(1000); + expect(http.match(r => r.url.includes('query=a')).length).toBe(1); + + req.set(request('b')); + TestBed.tick(); + expect(http.match(r => r.url.includes('query=b')).length).toBe(1); + obs.stop(); + }); + + it('stop() ends the observation: no fetch on a later request, no poll', () => { + const req = signal<MetricsRequest | null>(request('a')); + const obs = service.observe(req, { pollIntervalMs: 1000 }); + TestBed.tick(); + http.match(() => true); + + obs.stop(); + req.set(request('b')); + TestBed.tick(); + vi.advanceTimersByTime(5000); + + http.expectNone(() => true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/frontend/packages/store/src/services/metrics-data.service.ts b/src/frontend/packages/store/src/services/metrics-data.service.ts index a47314b09d..39e0a1526d 100644 --- a/src/frontend/packages/store/src/services/metrics-data.service.ts +++ b/src/frontend/packages/store/src/services/metrics-data.service.ts @@ -73,17 +73,16 @@ export class MetricsDataService { // Signal-bound observation. Tracks `request()`; whenever the request // signal changes, a new fetch fires and the metrics/fetching/error // signals update. Optional poll interval re-fires on a timer. The - // returned `stop()` cancels the polling timer. + // returned `stop()` ends the observation: no more fetches or polls. // - // Caller owns the lifetime — pass a DestroyRef-bound effect cleanup - // (or call stop() explicitly) when the consumer component tears down. + // Caller owns the lifetime and must call stop() when it tears down: the + // effect is anchored to the root injector, so nothing else ends it. observe<T = any>( request: Signal<MetricsRequest | null>, options: { pollIntervalMs?: Signal<number> | number } = {}, ): MetricsObservation<T> { const state = signal<MetricsFetchState<T>>(EMPTY_STATE); let inFlightToken = 0; - let timerId: ReturnType<typeof setInterval> | null = null; const runFetch = async (req: MetricsRequest | null) => { if (!req) { @@ -109,22 +108,18 @@ export class MetricsDataService { // (e.g. ngOnInit), which is not itself an injection context. We // anchor effect() to the service's captured injector so the // signal -> fetch wire works regardless of caller context. - runInInjectionContext(this.injector, () => { - effect(() => { - const req = request(); - if (timerId !== null) { - clearInterval(timerId); - timerId = null; - } - untracked(() => { void runFetch(req); }); - const interval = typeof options.pollIntervalMs === 'number' - ? options.pollIntervalMs - : options.pollIntervalMs?.(); - if (interval && interval > 0 && req) { - timerId = setInterval(() => { void runFetch(request()); }, interval); - } - }); - }); + const ref = runInInjectionContext(this.injector, () => effect(onCleanup => { + const req = request(); + untracked(() => { void runFetch(req); }); + const interval = typeof options.pollIntervalMs === 'number' + ? options.pollIntervalMs + : options.pollIntervalMs?.(); + if (interval && interval > 0 && req) { + const timerId = setInterval(() => { void runFetch(request()); }, interval); + // Runs before the next request and on destroy. + onCleanup(() => clearInterval(timerId)); + } + })); return { metrics: computed(() => state().metrics), @@ -132,10 +127,7 @@ export class MetricsDataService { error: computed(() => state().error), refresh: () => runFetch(request()), stop: () => { - if (timerId !== null) { - clearInterval(timerId); - timerId = null; - } + ref.destroy(); inFlightToken++; }, }; From 48ca127a321487b00cf523930a6222e4df2cb3c2 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:42:35 -0700 Subject: [PATCH 099/121] fix(cf): fetch a quota again only on change The org and space services looked their quota up again on every emission of the org or space, so a rename, which patches the org or space but keeps its quota, fetched the same quota again. Skip repeated quota GUIDs. --- ...cloud-foundry-organization.service.spec.ts | 27 +++++++++++++++---- .../cloud-foundry-organization.service.ts | 4 ++- .../cloud-foundry-space.service.spec.ts | 27 +++++++++++++++---- .../services/cloud-foundry-space.service.ts | 4 ++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts index 2690820476..dd79e6fb8a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts @@ -23,11 +23,14 @@ describe('CloudFoundryOrganizationService — quotaDefinition$', () => { // Each live signal-to-observable bridge reads the quota value once per // write, so the read count after one write is the number of live bridges. let quotaReads: number; + // quota lookups made, one per quota the stream switched to + let lookups: number; beforeEach(() => { org = signal<StOrgDetail | null>(null); quota = signal<StOrgQuota | null>(null); quotaReads = 0; + lookups = 0; const countedQuota = (() => { quotaReads++; return quota(); @@ -42,7 +45,7 @@ describe('CloudFoundryOrganizationService — quotaDefinition$', () => { provide: OrgDataRegistry, useValue: { acquire: () => ({ load: () => of(undefined), org, spaces: signal([]) }) }, }, - { provide: QuotaDataService, useValue: { orgQuota: () => ({ value: countedQuota }) } }, + { provide: QuotaDataService, useValue: { orgQuota: () => { lookups++; return { value: countedQuota }; } } }, { provide: CnsiUsersSnapshotService, useValue: { users: () => signal(null) } }, { provide: ServiceCatalogDataService, useValue: { serviceInstanceCount: () => ({ value: signal(0) }) } }, { @@ -72,10 +75,10 @@ describe('CloudFoundryOrganizationService — quotaDefinition$', () => { const seen: StOrgQuota[] = []; const sub = svc.quotaDefinition$.subscribe(q => seen.push(q)); - // Each org write (load, then patch() after an edit) re-runs the - // switchMap, which builds a new bridge to the quota signal. - for (const name of ['a', 'b', 'c']) { - org.set({ guid: 'org-1', name, quotaGuid: 'quota-1' } as StOrgDetail); + // Each change of quota re-runs the switchMap, which builds a new + // bridge to the quota signal. + for (const quotaGuid of ['quota-a', 'quota-b', 'quota-1']) { + org.set({ guid: 'org-1', name: 'a', quotaGuid } as StOrgDetail); TestBed.tick(); } @@ -88,4 +91,18 @@ describe('CloudFoundryOrganizationService — quotaDefinition$', () => { expect(seen).toEqual([q]); sub.unsubscribe(); }); + + it('looks the quota up again only when the quota changes', () => { + const svc = TestBed.inject(CloudFoundryOrganizationService); + const sub = svc.quotaDefinition$.subscribe(); + + // A rename patches the org but keeps its quota + for (const name of ['a', 'b', 'c']) { + org.set({ guid: 'org-1', name, quotaGuid: 'quota-1' } as StOrgDetail); + TestBed.tick(); + } + + expect(lookups).toBe(1); + sub.unsubscribe(); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts index e82f94a6a0..f59a3d26f5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts @@ -4,7 +4,7 @@ import { toObservable } from '@angular/core/rxjs-interop'; import { Route } from '@angular/router'; import { observeSignal } from '@stratosui/store'; import { combineLatest, EMPTY, Observable } from 'rxjs'; -import { catchError, filter, map, shareReplay, switchMap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { CnsiUsersSnapshotService } from '../../../services/endpoint-data/cnsi-users-snapshot.service'; import { OrgDataRegistry } from '../../../services/endpoint-data/org-data.registry'; @@ -233,6 +233,8 @@ export class CloudFoundryOrganizationService { this.quotaDefinition$ = orgSnapshot$.pipe( map(o => o.quotaGuid), filter(quotaGuid => !!quotaGuid), + // A rename patches the org but keeps its quota + distinctUntilChanged(), // observeSignal, not toObservable: toObservable's effect would outlive // switchMap's unsubscribe, so each org re-emission (load, patch after // an edit) would add one more effect for the life of the page. diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts index c0066311ab..33794868d0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts @@ -22,11 +22,14 @@ describe('CloudFoundrySpaceService — spaceQuotaDefinition$', () => { // Each live signal-to-observable bridge reads the quota value once per // write, so the read count after one write is the number of live bridges. let quotaReads: number; + // quota lookups made, one per quota the stream switched to + let lookups: number; beforeEach(() => { space = signal<StSpace | null>(null); quota = signal<StSpaceQuota | null>(null); quotaReads = 0; + lookups = 0; const countedQuota = (() => { quotaReads++; return quota(); @@ -39,7 +42,7 @@ describe('CloudFoundrySpaceService — spaceQuotaDefinition$', () => { provide: SpaceDataRegistry, useValue: { acquire: () => ({ load: () => of(undefined), space }) }, }, - { provide: QuotaDataService, useValue: { spaceQuota: () => ({ value: countedQuota }) } }, + { provide: QuotaDataService, useValue: { spaceQuota: () => { lookups++; return { value: countedQuota }; } } }, { provide: CloudFoundryOrganizationService, useValue: { quotaDefinition$: of(null) } }, { provide: CnsiUsersSnapshotService, useValue: { users: () => signal(null) } }, { provide: ServiceCatalogDataService, useValue: { serviceInstanceCount: () => ({ value: signal(0) }) } }, @@ -69,10 +72,10 @@ describe('CloudFoundrySpaceService — spaceQuotaDefinition$', () => { const seen: (StSpaceQuota | null)[] = []; const sub = svc.spaceQuotaDefinition$.subscribe(q => seen.push(q)); - // Each space write (load, then patch() after an edit) re-runs the - // switchMap, which builds a new bridge to the quota signal. - for (const name of ['a', 'b', 'c']) { - space.set({ guid: 'space-1', name, quotaGuid: 'quota-1' } as StSpace); + // Each change of quota re-runs the switchMap, which builds a new + // bridge to the quota signal. + for (const quotaGuid of ['quota-a', 'quota-b', 'quota-1']) { + space.set({ guid: 'space-1', name: 'a', quotaGuid } as StSpace); TestBed.tick(); } @@ -85,4 +88,18 @@ describe('CloudFoundrySpaceService — spaceQuotaDefinition$', () => { expect(seen.at(-1)).toEqual(q); sub.unsubscribe(); }); + + it('looks the quota up again only when the quota changes', () => { + const svc = TestBed.inject(CloudFoundrySpaceService); + const sub = svc.spaceQuotaDefinition$.subscribe(); + + // A rename patches the space but keeps its quota + for (const name of ['a', 'b', 'c']) { + space.set({ guid: 'space-1', name, quotaGuid: 'quota-1' } as StSpace); + TestBed.tick(); + } + + expect(lookups).toBe(1); + sub.unsubscribe(); + }); }); 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 5508c5ccd5..b97f5a5632 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 @@ -2,7 +2,7 @@ import { Injectable, Injector, Signal, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { observeSignal } from '@stratosui/store'; import { combineLatest, Observable, of } from 'rxjs'; -import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, shareReplay, switchMap } from 'rxjs/operators'; import { CnsiUsersSnapshotService } from '../../../services/endpoint-data/cnsi-users-snapshot.service'; import { QuotaDataService } from '../../../services/endpoint-data/quota-data.service'; @@ -149,6 +149,8 @@ export class CloudFoundrySpaceService { // an edit) would add one more effect for the life of the page. this.spaceQuotaDefinition$ = space$.pipe( map(s => s.quotaGuid || null), + // A rename patches the space but keeps its quota + distinctUntilChanged(), switchMap(quotaGuid => quotaGuid ? observeSignal(this.quotaData.spaceQuota(this.cfGuid, quotaGuid).value, this.injector) : of(null as StSpaceQuota | null)), From 7b28342a60f0ad3498f8ad0e6d9ed5e4f93d4a33 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:47:49 -0700 Subject: [PATCH 100/121] fix(endpoints): render the custom empty component The no-endpoints customization never rendered. Since the strict-mode change the view query named a template ref that does not exist, so the container was undefined and the create call threw inside a try/catch. The template now renders the custom component with ngComponentOutlet in place of the default message, and the unused templates and subscription are gone. The persistence-flag check now also ends with the page, so a flag that never arrives no longer holds the destroyed component. --- .../endpoints-page.component.html | 12 ++--- .../endpoints-page.component.spec.ts | 47 ++++++++++++++++--- .../endpoints-page.component.ts | 42 ++--------------- 3 files changed, 49 insertions(+), 52 deletions(-) diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html index aae9e23b25..8494435f86 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html @@ -30,21 +30,15 @@ <h1>Endpoints</h1> <!-- The list (and its sub-nav) renders only with endpoints, so the first endpoint needs this sub-nav for its Register button. --> <app-list-sub-nav title="Total Endpoints" [count]="noEndpoints" [addAction]="registerEndpointAction"></app-list-sub-nav> - @if (!customizations.noEndpointsComponent) { + @if (customizations.noEndpointsComponent) { + <ng-container *ngComponentOutlet="customizations.noEndpointsComponent"></ng-container> + } @else { <app-endpoints-missing [showToolbarHint]="false" [showDirectToEndpointMessage]="false"></app-endpoints-missing> } } - <ng-template #noEndpointsContainer> - @if (!customizations.noEndpointsComponent) { - <app-endpoints-missing - [showDirectToEndpointMessage]="false"></app-endpoints-missing> - } @else { - } - </ng-template> } </div> -<ng-template #customNoEndpoints></ng-template> <!-- Register Endpoint Modal --> @if (showRegisterModal) { diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts index c04c03be4c..0943cc4e66 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.spec.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { Component, provideZonelessChangeDetection, signal } from '@angular/core'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; @@ -16,15 +16,20 @@ import { SidePanelService } from './../../../shared/services/side-panel.service' import { SessionService } from '../../../shared/services/session.service'; import { EndpointsService } from '../../../core/endpoints.service'; import { EndpointStatusSignalService } from '../../../core/signals/endpoint-status-signal.service'; -import { CustomizationService } from '../../../core/customizations.types'; +import { CustomizationService, CustomizationsMetadata } from '../../../core/customizations.types'; import { TailwindSnackBarService } from '../../../shared/services/tailwind-snackbar.service'; import { EndpointModalService } from '../endpoint-register-modal/endpoint-modal.service'; import { EndpointsPageComponent } from './endpoints-page.component'; +@Component({ selector: 'app-test-welcome', template: '<p class="test-welcome">Welcome</p>' }) +class TestWelcomeComponent {} + describe('EndpointsPageComponent', () => { let component: EndpointsPageComponent; let fixture: ComponentFixture<EndpointsPageComponent>; let perm$: Subject<boolean>; + let persistence$: Subject<boolean>; + let customizations: CustomizationsMetadata; // Mock services const mockSessionService = { @@ -34,16 +39,14 @@ describe('EndpointsPageComponent', () => { }; const mockEndpointsService = { - disablePersistenceFeatures$: of(false), + disablePersistenceFeatures$: new Subject<boolean>(), haveRegistered$: of(false), haveConnected$: of(false), checkAllEndpoints: vi.fn() }; const mockCustomizationService = { - get: () => ({ - noEndpointsComponent: null - }) + get: () => customizations }; const mockSnackBarService = { @@ -83,6 +86,8 @@ describe('EndpointsPageComponent', () => { { provide: EndpointStatusSignalService, useValue: mockEndpointStatusSignals }, { provide: SessionService, useValue: mockSessionService }, { provide: EndpointsService, useValue: mockEndpointsService }, + // The real service waits on an endpoints request the test never answers. + { provide: EndpointStatusSignalService, useValue: { initialised: signal(true) } }, { provide: CustomizationService, useValue: mockCustomizationService }, { provide: TailwindSnackBarService, useValue: mockSnackBarService }, { provide: EndpointModalService, useValue: mockEndpointModalService }, @@ -99,6 +104,9 @@ describe('EndpointsPageComponent', () => { beforeEach(async () => { perm$ = new Subject<boolean>(); + persistence$ = new Subject<boolean>(); + mockEndpointsService.disablePersistenceFeatures$ = persistence$; + customizations = { noEndpointsComponent: null }; vi.spyOn(TestBed.inject(CurrentUserPermissionsService), 'can').mockReturnValue(perm$); fixture = TestBed.createComponent(EndpointsPageComponent); component = fixture.componentInstance; @@ -131,4 +139,31 @@ describe('EndpointsPageComponent', () => { fixture.destroy(); expect(perm$.observed).toBe(false); }); + + it('stops waiting for the persistence flag when destroyed', () => { + expect(persistence$.observed).toBe(true); + fixture.destroy(); + expect(persistence$.observed).toBe(false); + }); + + // The content waits for isInitialised$, which is delayed by 500ms. + const renderEmptyPage = async () => { + const page = TestBed.createComponent(EndpointsPageComponent); + await page.whenStable(); + await new Promise(resolve => setTimeout(resolve, 600)); + await page.whenStable(); + return page.nativeElement as HTMLElement; + }; + + it('shows the default message with no endpoints', async () => { + const el = await renderEmptyPage(); + expect(el.querySelector('app-endpoints-missing')).not.toBeNull(); + }); + + it('shows a custom no-endpoints component in place of the message', async () => { + customizations = { noEndpointsComponent: TestWelcomeComponent }; + const el = await renderEmptyPage(); + expect(el.querySelector('.test-welcome')?.textContent).toBe('Welcome'); + expect(el.querySelector('app-endpoints-missing')).toBeNull(); + }); }); 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 2c5e7e29fe..162650e714 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,6 +1,6 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, AfterViewInit, Component, ComponentRef, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef, inject, signal } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, AfterViewInit, Component, NgZone, 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'; import { @@ -114,9 +114,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit public showRegisterModal = false; public isInitialised$: Observable<boolean>; - @ViewChild('customNoEndpointsContainer', { read: ViewContainerRef, static: true }) customNoEndpointsContainer!: ViewContainerRef; - customContentComponentRef!: ComponentRef<any>; - private snackBarText = { message: `There are no connected endpoints, connect with your personal credentials to get started.`, action: 'Got it' @@ -140,7 +137,9 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit this.router.navigate(['applications'], { replaceUrl: true }); } }), - take(1) + take(1), + // The flag may never arrive; do not hold the page after it closes. + takeUntilDestroyed() ).subscribe({ error: (err) => console.error('Error checking persistence features:', err) }); @@ -201,29 +200,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit console.error('Failed to load endpoints via EndpointsDataService:', err); }); - // Subscribe to haveRegistered$ to handle custom no-endpoints component - // Defensive: Add error handling to prevent subscription failures from breaking the page - this.subs.push( - this.endpointsService.haveRegistered$.pipe( - filter(haveRegistered => haveRegistered !== undefined && haveRegistered !== null) - ).subscribe({ - next: (haveRegistered) => { - // Use custom component if specified and no endpoints are registered - if (this.customNoEndpointsContainer) { - this.customNoEndpointsContainer.clear(); - } - if (!haveRegistered && this.customizations.noEndpointsComponent) { - try { - this.customContentComponentRef = this.customNoEndpointsContainer.createComponent(this.customizations.noEndpointsComponent); - } catch (error) { - console.error('Error creating custom no-endpoints component:', error); - } - } - }, - error: (err) => console.error('Error subscribing to haveRegistered$:', err) - }) - ); - // Trigger endpoint health checks // Defensive: Wrap in try-catch to prevent errors from breaking initialization try { @@ -282,14 +258,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit console.error('Error unsubscribing:', error); } - try { - if (this.customContentComponentRef) { - this.customContentComponentRef.destroy(); - } - } catch (error) { - console.error('Error destroying custom component:', error); - } - try { this.showSnackBar(false); } catch (error) { From 447235650c4e5b84967981f3c9b258f847a0d9f1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:48:36 -0700 Subject: [PATCH 101/121] fix(endpoints): end connect bridges on destroy Each connect lifted three endpoint signals into observables with toObservable(). Those effects end only with the injector they are given, which is the page or stepper that outlives every connect, so each connect (dialog, stepper entry, kube cluster import) left three effects running after destroy(). Keep the effect and replay subject for each bridge and end them in destroy(). Values and timing are unchanged: one effect per signal, created at construction, latest value replayed to later subscribers. --- .../endpoints/connect.service.spec.ts | 105 ++++++++++++++++++ .../src/features/endpoints/connect.service.ts | 44 +++++--- 2 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 src/frontend/packages/core/src/features/endpoints/connect.service.spec.ts diff --git a/src/frontend/packages/core/src/features/endpoints/connect.service.spec.ts b/src/frontend/packages/core/src/features/endpoints/connect.service.spec.ts new file mode 100644 index 0000000000..9a359d500e --- /dev/null +++ b/src/frontend/packages/core/src/features/endpoints/connect.service.spec.ts @@ -0,0 +1,105 @@ +import { ChangeDetectionStrategy, Component, computed, Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActionState, EndpointFetchingState, EndpointModel, EndpointsDataService } from '@stratosui/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { EndpointsService } from '../../core/endpoints.service'; +import { ConnectEndpointConfig, ConnectEndpointService } from './connect.service'; + +// Stands in for the page or stepper step whose injector each connect is +// given; it lives across many connects. +@Component({ + selector: 'app-connect-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class ConnectHostComponent {} + +const config: ConnectEndpointConfig = { + name: 'ep', + guid: 'ep-1', + type: 'cf', + subType: '', + ssoAllowed: false, +}; + +// Per-guid sources read through computeds that count their evaluations, so +// an effect still watching them shows up as extra reads after a write. +function fakeEndpointsData() { + const connecting = signal<ActionState>({ busy: false, error: false, message: '' }); + const fetching = signal<EndpointFetchingState>({ fetching: false }); + const endpoint = signal<EndpointModel | null>(null); + let reads = 0; + const counted = <T>(source: () => T) => computed(() => { + reads++; + return source(); + }); + const data = { + connectingState: () => counted(connecting), + fetchingState: () => counted(fetching), + endpointById: () => counted(endpoint), + } as unknown as EndpointsDataService; + return { data, connecting, fetching, endpoint, reads: () => reads }; +} + +describe('ConnectEndpointService', () => { + const endpointsService = { checkEndpoint: vi.fn() } as unknown as EndpointsService; + + async function setup() { + const fixture = TestBed.createComponent(ConnectHostComponent); + await fixture.whenStable(); + const injector = fixture.debugElement.injector.get(Injector); + return { fixture, injector, ...fakeEndpointsData() }; + } + + afterEach(() => vi.restoreAllMocks()); + + it('forwards source writes while the connect is live', async () => { + const { fixture, injector, data, connecting } = await setup(); + const service = new ConnectEndpointService(endpointsService, config, data, injector); + const busy: boolean[] = []; + const sub = service.isBusy$.subscribe(b => busy.push(b)); + await fixture.whenStable(); + + connecting.set({ busy: true, error: false, message: '' }); + await fixture.whenStable(); + + expect(busy).toContain(true); + sub.unsubscribe(); + service.destroy(); + }); + + it('stops reading the sources after destroy()', async () => { + const { fixture, injector, data, connecting, fetching, endpoint, reads } = await setup(); + const service = new ConnectEndpointService(endpointsService, config, data, injector); + await fixture.whenStable(); + expect(reads()).toBeGreaterThan(0); + + service.destroy(); + const readsAtDestroy = reads(); + connecting.set({ busy: true, error: false, message: '' }); + fetching.set({ fetching: true }); + endpoint.set({ guid: 'ep-1' } as EndpointModel); + await fixture.whenStable(); + + expect(reads() - readsAtDestroy).toBe(0); + }); + + it('does not keep bridges from earlier connects in the same injector', async () => { + const { fixture, injector, data, connecting, reads } = await setup(); + // A stepper re-entry or a kube-config import makes a connect per pass. + for (let i = 0; i < 5; i++) { + new ConnectEndpointService(endpointsService, config, data, injector).destroy(); + } + const live = new ConnectEndpointService(endpointsService, config, data, injector); + await fixture.whenStable(); + + const before = reads(); + connecting.set({ busy: true, error: false, message: '' }); + await fixture.whenStable(); + + // Only the live connect's connecting-state bridge re-reads. + expect(reads() - before).toBe(1); + live.destroy(); + }); +}); 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 3e0aec19ad..1dbf46ae9f 100644 --- a/src/frontend/packages/core/src/features/endpoints/connect.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/connect.service.ts @@ -1,5 +1,4 @@ -import { Injector } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { effect, Injector, Signal, untracked } from '@angular/core'; import { entityCatalog, EndpointModel, @@ -13,7 +12,7 @@ export interface ConnectAuthParams { password?: string; token?: string; } -import { combineLatest, defer, from, Observable, of, Subject, Subscription, timer } from 'rxjs'; +import { combineLatest, defer, from, Observable, of, ReplaySubject, Subject, Subscription, timer } from 'rxjs'; import { delay, distinctUntilChanged, filter, map, pairwise, startWith, switchMap, tap } from 'rxjs/operators'; import { EndpointsService } from '../../core/endpoints.service'; @@ -70,6 +69,8 @@ export class ConnectEndpointService { private update$!: Observable<ActionState>; private subs: Subscription[] = []; + // Ends the signal bridges made by observe(); see there. + private bridgeTeardowns: (() => void)[] = []; private hasAttemptedConnect!: boolean; private pData!: ConnectEndpointData; @@ -127,23 +128,14 @@ export class ConnectEndpointService { } private setupObservables() { - // W36-B Wave 3: replace ngrx EntityMonitor reads with toObservable() + // W36-B Wave 3: replace ngrx EntityMonitor reads with observe() // bridges over EndpointsDataService signals. The service already // exposes `connectingState(guid)` / `fetchingState(guid)` / // `endpointById(guid)` per the Wave 1 surface lock; we just lift // them into the Observable shape this rxjs-heavy class expects. - const connectingState$ = toObservable( - this.endpointsData.connectingState(this.config.guid), - { injector: this.injector }, - ); - const fetchingState$ = toObservable( - this.endpointsData.fetchingState(this.config.guid), - { injector: this.injector }, - ); - const endpoint$ = toObservable( - this.endpointsData.endpointById(this.config.guid), - { injector: this.injector }, - ); + const connectingState$ = this.observe(this.endpointsData.connectingState(this.config.guid)); + const fetchingState$ = this.observe(this.endpointsData.fetchingState(this.config.guid)); + const endpoint$ = this.observe(this.endpointsData.endpointById(this.config.guid)); // update$ is the per-guid connecting lifecycle (replaces legacy // `getUpdatingSection(ConnectEndpoint.UpdatingKey)`). Initial @@ -190,6 +182,24 @@ export class ConnectEndpointService { this.setupCombinedObservables(); } + // toObservable() with this connect's lifetime. toObservable() ends its + // effect only when the injector is destroyed, and the caller's injector + // (the page or stepper) outlives each connect, so every connect left + // three effects running. Same values and timing: one effect per signal, + // created now, latest value replayed to later subscribers. + private observe<T>(source: Signal<T>): Observable<T> { + const subject = new ReplaySubject<T>(1); + const ref = effect(() => { + const value = source(); + untracked(() => subject.next(value)); + }, { injector: this.injector, manualCleanup: true }); + this.bridgeTeardowns.push(() => { + ref.destroy(); + subject.complete(); + }); + return subject.asObservable(); + } + private setupCombinedObservables() { this.isBusy$ = combineLatest( this.connecting$.pipe(startWith(false)), @@ -256,5 +266,7 @@ export class ConnectEndpointService { public destroy() { safeUnsubscribe(...this.subs); + this.bridgeTeardowns.forEach(teardown => teardown()); + this.bridgeTeardowns = []; } } From ae3585f7d438973d06adc16e859266cc795c451a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:49:51 -0700 Subject: [PATCH 102/121] fix(endpoints): end the connect on step re-entry The connect step of the register stepper made a new connect each time it was entered and destroyed only the last one when the stepper closed. Going back with Previous and on with Next left the earlier connect's subscriptions and signal bridges running. Destroy the previous connect first. --- .../create-endpoint-connect.component.spec.ts | 14 +++++++++++++- .../create-endpoint-connect.component.ts | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.spec.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.spec.ts index 87e62a0599..bf107fb7e5 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.spec.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.spec.ts @@ -1,11 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { EntityCatalogTestModuleManualStore, generateStratosEntities, TEST_CATALOGUE_ENTITIES } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { SidePanelService } from '@stratosui/core'; import { ConnectEndpointComponent } from '../../connect-endpoint/connect-endpoint.component'; +import { ConnectEndpointService } from '../../connect.service'; import { CreateEndpointConnectComponent } from './create-endpoint-connect.component'; describe('CreateEndpointConnectComponent', () => { @@ -41,4 +42,15 @@ describe('CreateEndpointConnectComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + // Previous back to the register step and Next again enters this step again + it('ends the previous connect when the step is entered again', () => { + const destroy = vi.spyOn(ConnectEndpointService.prototype, 'destroy'); + const config = { name: 'ep', guid: 'ep-1', type: 'cf', subType: null, ssoAllowed: false }; + component.onEnter(config as any); + component.onEnter(config as any); + + expect(destroy).toHaveBeenCalledTimes(1); + destroy.mockRestore(); + }); }); diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.ts index a3a86b3de7..6f4194d0b2 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-connect/create-endpoint-connect.component.ts @@ -64,6 +64,8 @@ export class CreateEndpointConnectComponent implements OnDestroy, IStepperStep { } onEnter = (data: ConnectEndpointConfig) => { + // Entered again after Previous: end the earlier connect first + this.connectService?.destroy(); this.connectService = new ConnectEndpointService(this.endpointsService, data, this.endpointsData, this.injector); // OnPush change detection: setting a non-signal field doesn't notify // the template, so the success message renders without the endpoint From 851ae6d28461b739494a8fa4232f1858db2e071e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:50:38 -0700 Subject: [PATCH 103/121] fix(cf): drop unread refCount from cf registries CfInfoDataRegistry, OrgDataRegistry and SpaceDataRegistry counted acquire/release calls, but nothing read the count and nothing evicted at zero; entries live for the session and leave only through evict() when an org or space is deleted. Remove refCount, release() and the destroy hooks and effects that existed only to call it, and say in each registry that entries are sticky on purpose. --- .../features/cf/add-edit-space-step-base.ts | 4 --- .../create-space-step.component.ts | 1 - .../edit-space-step.component.ts | 1 - .../quota-definition-base.component.ts | 10 +------ .../invite-users-create.component.ts | 19 ++------------ .../cf-info-data.registry.spec.ts | 6 ----- .../endpoint-data/cf-info-data.registry.ts | 19 +++----------- .../endpoint-data/org-data.registry.spec.ts | 9 ------- .../endpoint-data/org-data.registry.ts | 26 +++++-------------- .../endpoint-data/space-data.registry.spec.ts | 6 ----- .../endpoint-data/space-data.registry.ts | 19 +++----------- .../add-service-instance.component.ts | 20 +++++--------- ...able-cell-service-broker.component.spec.ts | 2 -- .../table-cell-service-broker.component.ts | 13 ++-------- .../service-broker-card.component.spec.ts | 2 -- .../service-broker-card.component.ts | 20 ++++---------- ...users-space-roles-signal-config.service.ts | 8 ------ 17 files changed, 32 insertions(+), 153 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/add-edit-space-step-base.ts b/src/frontend/packages/cloud-foundry/src/features/cf/add-edit-space-step-base.ts index 06464f1c68..2945e0f6da 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/add-edit-space-step-base.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/add-edit-space-step-base.ts @@ -38,10 +38,6 @@ export class AddEditSpaceStepBase { this.hasSpaceQuotas = computed(() => this.quotaDefinitions().length > 0); } - destroy(): void { - this.orgRegistry.release(this.cfGuid, this.orgGuid); - } - spaceNameTakenValidator = (): ValidatorFn => { return (formField: AbstractControl): ValidationErrors | null => { const nameValid = this.isNameUnique(formField.value); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts index d7a32469cf..092934c9b8 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts @@ -180,6 +180,5 @@ export class CreateSpaceStepComponent extends AddEditSpaceStepBase implements On ngOnDestroy() { this.formStatusSub?.unsubscribe(); - this.destroy(); } } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/edit-space/edit-space-step/edit-space-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/edit-space/edit-space-step/edit-space-step.component.ts index c5ad2eebab..01ec74844f 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/edit-space/edit-space-step/edit-space-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/edit-space/edit-space-step/edit-space-step.component.ts @@ -272,7 +272,6 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn } ngOnDestroy() { - this.destroy(); this.spaceSubscription.unsubscribe(); this.formStatusSub?.unsubscribe(); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/quota-definition-base/quota-definition-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/quota-definition-base/quota-definition-base.component.ts index 0488ab530e..3ef99d3871 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/quota-definition-base/quota-definition-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/quota-definition-base/quota-definition-base.component.ts @@ -1,5 +1,5 @@ import { ActivatedRoute } from '@angular/router'; -import { Signal, computed, effect, inject } from '@angular/core'; +import { Signal, computed, inject } from '@angular/core'; import { IHeaderBreadcrumb } from '../../../../../core/src/shared/components/page-header/page-header.types'; import { EndpointModel } from '../../../../../store/src/types/endpoint.types'; @@ -55,14 +55,6 @@ export class QuotaDefinitionBaseComponent { if (!endpoint) return []; return this.getBreadcrumbs(endpoint, this.org(), this.space()); }); - - // Subscriptions are bookkeeping — refcounted-release tied to view destroy. - effect((onCleanup) => { - onCleanup(() => { - if (orgService) orgRegistry.release(this.cfGuid, this.orgGuid); - if (spaceService) spaceRegistry.release(this.cfGuid, this.spaceGuid); - }); - }); } protected getBreadcrumbs( diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/invite-users/invite-users-create/invite-users-create.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/invite-users/invite-users-create/invite-users-create.component.ts index 47b37fd3ee..f383f718a8 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/invite-users/invite-users-create/invite-users-create.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/invite-users/invite-users-create/invite-users-create.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, Input, OnDestroy, OnInit, Signal, signal, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, computed, Input, OnInit, Signal, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { toObservable } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; @@ -36,7 +36,7 @@ import { UserInviteSendSpaceRoles, UserInviteService } from '../../../user-invit StackedInputActionsComponent ] }) -export class InviteUsersCreateComponent implements OnInit, OnDestroy { +export class InviteUsersCreateComponent implements OnInit { private usersData = inject(CfUsersPagedDataService); private activeRouteCfOrgSpace = inject(ActiveRouteCfOrgSpace); private userInviteService = inject(UserInviteService); @@ -125,21 +125,6 @@ export class InviteUsersCreateComponent implements OnInit, OnDestroy { } } - ngOnDestroy() { - if (this.orgData) { - this.orgRegistry.release( - this.activeRouteCfOrgSpace.cfGuid, - this.activeRouteCfOrgSpace.orgGuid, - ); - } - if (this.spaceData) { - this.spaceRegistry.release( - this.activeRouteCfOrgSpace.cfGuid, - this.activeRouteCfOrgSpace.spaceGuid, - ); - } - } - /** * FWT-957 helper: runs the invite request and applies per-user state * updates / madeChanges side effects. Returns the invite response so diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.spec.ts index ac0cb9fd26..d725e17070 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.spec.ts @@ -28,12 +28,6 @@ describe('CfInfoDataRegistry', () => { expect(registry.acquire('cnsi-1')).not.toBe(registry.acquire('cnsi-2')); }); - it('retains instance after release (sticky)', () => { - const svc = registry.acquire('cnsi-1'); - registry.release('cnsi-1'); - expect(registry.acquire('cnsi-1')).toBe(svc); - }); - it('evict() removes the instance', () => { const svc = registry.acquire('cnsi-1'); registry.evict('cnsi-1'); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.ts index a46b43c100..1e107d41cb 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.registry.ts @@ -3,37 +3,26 @@ import { Injectable, inject } from '@angular/core'; import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; import { CfInfoDataService } from './cf-info-data.service'; -interface RegistryEntry { - service: CfInfoDataService; - refCount: number; -} - // Caches CfInfoDataService instances per cnsiGuid. Mirrors OrgDataRegistry / // SpaceDataRegistry — sticky across navigation, root-scope singleton so every // consumer in a CF endpoint subtree shares the same warm signal. +// Entries are kept for the session (sticky) on purpose; only evict() removes one. @Injectable({ providedIn: 'root' }) export class CfInfoDataRegistry { private readonly http = inject(HttpClient); private readonly diagnostics = inject(StratosDiagnostics); - private readonly instances = new Map<string, RegistryEntry>(); + private readonly instances = new Map<string, CfInfoDataService>(); acquire(cnsiGuid: string): CfInfoDataService { const existing = this.instances.get(cnsiGuid); if (existing) { - existing.refCount++; - return existing.service; + return existing; } const service = new CfInfoDataService(this.http, cnsiGuid, this.diagnostics); - this.instances.set(cnsiGuid, { service, refCount: 1 }); + this.instances.set(cnsiGuid, service); return service; } - release(cnsiGuid: string): void { - const entry = this.instances.get(cnsiGuid); - if (!entry) { return; } - entry.refCount = Math.max(0, entry.refCount - 1); - } - evict(cnsiGuid: string): void { this.instances.delete(cnsiGuid); } diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.spec.ts index b33cff9b7d..423268bceb 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.spec.ts @@ -24,8 +24,6 @@ describe('OrgDataRegistry', () => { const svc1 = registry.acquire('cnsi-1', 'org-a'); const svc2 = registry.acquire('cnsi-1', 'org-a'); expect(svc1).toBe(svc2); - registry.release('cnsi-1', 'org-a'); - registry.release('cnsi-1', 'org-a'); }); it('returns different instances for different orgs in same cnsi', () => { @@ -40,13 +38,6 @@ describe('OrgDataRegistry', () => { expect(a).not.toBe(b); }); - it('retains instance after full release (sticky data)', () => { - const svc = registry.acquire('cnsi-1', 'org-a'); - registry.release('cnsi-1', 'org-a'); - const svcAgain = registry.acquire('cnsi-1', 'org-a'); - expect(svc).toBe(svcAgain); - }); - it('evict() removes the instance', () => { const svc = registry.acquire('cnsi-1', 'org-a'); registry.evict('cnsi-1', 'org-a'); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.ts index c6a17ecb52..14eaca15c2 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.registry.ts @@ -3,47 +3,35 @@ import { Injectable, inject } from '@angular/core'; import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; import { OrgDataService } from './org-data.service'; -interface RegistryEntry { - service: OrgDataService; - refCount: number; -} - // Caches OrgDataService instances per (cnsiGuid, orgGuid). Mirrors // EndpointDataRegistry — keeps state warm across navigation away and back to // the same org-detail page, and gives every consumer in the org-detail tree a // shared instance (so in-flight dedup + cached signals actually take effect). +// Entries are kept for the session (sticky) on purpose; only evict() removes one. @Injectable({ providedIn: 'root' }) export class OrgDataRegistry { private readonly http = inject(HttpClient); private readonly diagnostics = inject(StratosDiagnostics); - private readonly instances = new Map<string, RegistryEntry>(); + private readonly instances = new Map<string, OrgDataService>(); acquire(cnsiGuid: string, orgGuid: string): OrgDataService { const key = this.key(cnsiGuid, orgGuid); const existing = this.instances.get(key); if (existing) { - existing.refCount++; - return existing.service; + return existing; } const service = new OrgDataService(this.http, cnsiGuid, orgGuid, this.diagnostics); - this.instances.set(key, { service, refCount: 1 }); + this.instances.set(key, service); return service; } - release(cnsiGuid: string, orgGuid: string): void { - const entry = this.instances.get(this.key(cnsiGuid, orgGuid)); - if (!entry) { return; } - entry.refCount = Math.max(0, entry.refCount - 1); - // Instance stays in map sticky — only removed on explicit evict. - } - evict(cnsiGuid: string, orgGuid: string): void { this.instances.delete(this.key(cnsiGuid, orgGuid)); } - /** Cached instance lookup without creating one or bumping refCount. */ + /** Cached instance lookup without creating one. */ peek(cnsiGuid: string, orgGuid: string): OrgDataService | undefined { - return this.instances.get(this.key(cnsiGuid, orgGuid))?.service; + return this.instances.get(this.key(cnsiGuid, orgGuid)); } // Every cached instance for an endpoint — mutation cleanups that only @@ -53,7 +41,7 @@ export class OrgDataRegistry { const prefix = `${cnsiGuid}:`; return [...this.instances.entries()] .filter(([key]) => key.startsWith(prefix)) - .map(([, entry]) => entry.service); + .map(([, service]) => service); } private key(cnsiGuid: string, orgGuid: string): string { diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.spec.ts index 9d84f8d57b..0082ec58e8 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.spec.ts @@ -38,12 +38,6 @@ describe('SpaceDataRegistry', () => { expect(a).not.toBe(b); }); - it('retains instance after release (sticky)', () => { - const svc = registry.acquire('cnsi-1', 'sp-1'); - registry.release('cnsi-1', 'sp-1'); - expect(registry.acquire('cnsi-1', 'sp-1')).toBe(svc); - }); - it('evict() removes the instance', () => { const svc = registry.acquire('cnsi-1', 'sp-1'); registry.evict('cnsi-1', 'sp-1'); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.ts index a5fd9c5540..0f474c7ab0 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.registry.ts @@ -3,38 +3,27 @@ import { Injectable, inject } from '@angular/core'; import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; import { SpaceDataService } from './space-data.service'; -interface RegistryEntry { - service: SpaceDataService; - refCount: number; -} - // Caches SpaceDataService instances per (cnsiGuid, spaceGuid). Mirrors // OrgDataRegistry — sticky across navigation away and back, so the // space-detail subtree shares one warm signal across all consumers. +// Entries are kept for the session (sticky) on purpose; only evict() removes one. @Injectable({ providedIn: 'root' }) export class SpaceDataRegistry { private readonly http = inject(HttpClient); private readonly diagnostics = inject(StratosDiagnostics); - private readonly instances = new Map<string, RegistryEntry>(); + private readonly instances = new Map<string, SpaceDataService>(); acquire(cnsiGuid: string, spaceGuid: string): SpaceDataService { const key = this.key(cnsiGuid, spaceGuid); const existing = this.instances.get(key); if (existing) { - existing.refCount++; - return existing.service; + return existing; } const service = new SpaceDataService(this.http, cnsiGuid, spaceGuid, this.diagnostics); - this.instances.set(key, { service, refCount: 1 }); + this.instances.set(key, service); return service; } - release(cnsiGuid: string, spaceGuid: string): void { - const entry = this.instances.get(this.key(cnsiGuid, spaceGuid)); - if (!entry) { return; } - entry.refCount = Math.max(0, entry.refCount - 1); - } - evict(cnsiGuid: string, spaceGuid: string): void { this.instances.delete(this.key(cnsiGuid, spaceGuid)); } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts index 7de13634c5..54b1c3aa99 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts @@ -525,22 +525,16 @@ export class AddServiceInstanceComponent implements OnInit, OnDestroy { }); } - // Promise wrapper for SpaceDataRegistry.acquire+load. Releases the - // registry refcount in `finally` so warm caches stay shared but the - // dispatcher's transient interest is correctly dropped. + // Promise wrapper for SpaceDataRegistry.acquire+load. private async awaitSpaceLoad(cfId: string, spaceGuid: string): Promise<StSpace> { const sd = this.spaceRegistry.acquire(cfId, spaceGuid); - try { - await firstValueFrom(sd.load()); - const space = sd.space(); - if (!space) { - const errs = sd.errors(); - throw new Error(errs[0]?.message ?? 'Failed to load space'); - } - return space; - } finally { - this.spaceRegistry.release(cfId, spaceGuid); + await firstValueFrom(sd.load()); + const space = sd.space(); + if (!space) { + const errs = sd.errors(); + throw new Error(errs[0]?.message ?? 'Failed to load space'); } + return space; } private failSetup(logMessage: string, userMessage: string): Promise<boolean> { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts index 55bce1c54e..62ef563dce 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts @@ -22,8 +22,6 @@ class SpaceDataRegistryStub { load: (): Observable<void> => this.loaded, }; } - - release(_cnsi: string, _spaceGuid: string): void { /* no-op */ } } describe('TableCellServiceBrokerComponent', () => { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts index b4c939946a..244fb26895 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, OnDestroy, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Injector, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { TableCellCustom } from '@stratosui/core'; @@ -35,7 +35,7 @@ interface SpaceLink { }) export class TableCellServiceBrokerComponent extends TableCellCustom<StServiceOffering, - TableCellServiceBrokerComponentConfig> implements OnInit, OnDestroy { + TableCellServiceBrokerComponentConfig> implements OnInit { private serviceCatalog = inject(ServiceCatalogDataService); private spaceRegistry = inject(SpaceDataRegistry); @@ -93,7 +93,6 @@ export class TableCellServiceBrokerComponent extends const curr = this._spaceKey; if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; if (curr) { - this.spaceRegistry.release(curr.cnsi, curr.guid); this._spaceData.set(null); this._spaceKey = null; } @@ -106,12 +105,4 @@ export class TableCellServiceBrokerComponent extends }); }); } - - ngOnDestroy(): void { - if (this._spaceKey) { - this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData.set(null); - this._spaceKey = null; - } - } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts index 40c1a8ec16..65dc968494 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts @@ -55,8 +55,6 @@ class SpaceDataRegistryStub { }; } - release(_cnsi: string, _spaceGuid: string): void { /* no-op */ } - land(space: StSpace): void { this.space.set(space); this.loaded.next(); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts index 1f98274c34..1591d30a62 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, Input, OnDestroy, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Injector, Input, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { @@ -50,7 +50,7 @@ interface BrokerSpaceLink { TristateValueComponent, ], }) -export class ServiceBrokerCardComponent implements OnInit, OnDestroy { +export class ServiceBrokerCardComponent implements OnInit { private serviceCatalog = inject(ServiceCatalogDataService); private spaceRegistry = inject(SpaceDataRegistry); private injector = inject(Injector); @@ -59,9 +59,8 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { private readonly _brokerGuid = signal(''); private readonly _brokerSource = signal<SignalSource<StServiceBroker | null> | null>(null); - // Acquired SpaceDataService (or null when no broker.space.guid yet). - // The registry refCounts per (cnsi, spaceGuid); we release on swap + - // destroy so warm caches stay shared with other consumers. A signal so + // Acquired SpaceDataService (or null when no broker.space.guid yet), + // shared per (cnsi, spaceGuid) through the registry. A signal so // `spaceLink` re-evaluates when the effect swaps it in. private readonly _spaceData = signal<SpaceDataService | null>(null); private _spaceKey: { cnsi: string, guid: string } | null = null; @@ -103,7 +102,7 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { } ngOnInit(): void { - // Acquire/release SpaceDataService when broker.space.guid changes. + // Acquire SpaceDataService when broker.space.guid changes. // Effect re-runs on broker swap (new SignalSource) and on broker // value flip (HTTP landed) — same shape regardless of which triggered // the change. @@ -116,7 +115,6 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { const curr = this._spaceKey; if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; if (curr) { - this.spaceRegistry.release(curr.cnsi, curr.guid); this._spaceData.set(null); this._spaceKey = null; } @@ -129,12 +127,4 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { }); }); } - - ngOnDestroy(): void { - if (this._spaceKey) { - this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData.set(null); - this._spaceKey = null; - } - } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/cf-users-org-space-roles/cf-users-space-roles-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/cf-users-org-space-roles/cf-users-space-roles-signal-config.service.ts index a7112cf1bf..e28b3ea197 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/cf-users-org-space-roles/cf-users-space-roles-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/cf-users-org-space-roles/cf-users-space-roles-signal-config.service.ts @@ -174,18 +174,10 @@ export class CfUsersSpaceRolesSignalConfigService { this.destroyRef.onDestroy(() => { this.permCheckSub?.unsubscribe(); - const og = this._orgGuid(); - if (og && this._orgDataService()) { - this.registry.release(this.activeRoute.cfGuid, og); - } }); } private bindOrg(orgGuid: string): void { - const prevOrg = this._orgGuid(); - if (this._orgDataService() && prevOrg) { - this.registry.release(this.activeRoute.cfGuid, prevOrg); - } this._orgGuid.set(orgGuid); const svc = this.registry.acquire(this.activeRoute.cfGuid, orgGuid); this._orgDataService.set(svc); From 4bf5f14969f1ba61ef760f86d35124eac946ade5 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:50:38 -0700 Subject: [PATCH 104/121] fix(cf): name the service broker card link The Broker Link row held only a launch icon, so screen readers announced the link as "launch". Show the broker URL as the link text, hide the icon from assistive tech, and say that the link opens in a new tab. Add rel="noopener noreferrer" to match the other new-tab links. --- .../service-broker-card.component.html | 9 ++++++--- .../service-broker-card.component.spec.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html index 87385e989e..73ead5b937 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.html @@ -12,9 +12,12 @@ <app-meta-card-item> <app-meta-card-key>Broker Link</app-meta-card-key> <app-meta-card-value> - <a [href]="$safeNavigationMigration(b?.url)" target="_blank" appClickStopPropagation> - <span class="material-icons">launch</span> - </a> + @if (b?.url; as url) { + <a [href]="url" target="_blank" rel="noopener noreferrer" appClickStopPropagation> + {{ url }}<span class="sr-only"> (opens in a new tab)</span> + <span class="material-icons align-middle" aria-hidden="true">launch</span> + </a> + } </app-meta-card-value> </app-meta-card-item> <app-meta-card-item> diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts index 65dc968494..471105b0b7 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts @@ -88,9 +88,7 @@ describe('ServiceBrokerCardComponent', () => { component = fixture.componentInstance; component.cfGuid = 'test-cf-guid'; component.brokerGuid = catalogStub.brokerResponse!.guid; - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); }; beforeEach(async () => setup()); @@ -112,6 +110,17 @@ describe('ServiceBrokerCardComponent', () => { expect(launch?.getAttribute('href')).toBe('https://app-autoscaler-broker.cf-dev.io'); }); + it('gives the Broker Link a name that says where it goes and that it opens a new tab', () => { + const link = (fixture.nativeElement as HTMLElement).querySelector('a[target="_blank"]')!; + // Accessible name: aria-label wins; otherwise the text minus aria-hidden parts. + const visible = link.cloneNode(true) as HTMLElement; + visible.querySelectorAll('[aria-hidden="true"]').forEach(el => el.remove()); + const name = (link.getAttribute('aria-label') ?? visible.textContent ?? '').replace(/\s+/g, ' ').trim(); + expect(name).toContain('https://app-autoscaler-broker.cf-dev.io'); + expect(name).toContain('(opens in a new tab)'); + expect(name).not.toContain('launch'); + }); + it('renders "Not Available" for authUsername when listed in _meta.unavailable', () => { const host: HTMLElement = fixture.nativeElement; const unavailable = host.querySelector('app-tristate-value .tristate-value--unavailable'); @@ -128,9 +137,7 @@ describe('ServiceBrokerCardComponent', () => { component = fixture.componentInstance; component.cfGuid = 'test-cf-guid'; component.brokerGuid = catalogStub.brokerResponse.guid; - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const host: HTMLElement = fixture.nativeElement; const known = host.querySelector('app-tristate-value .tristate-value--known'); From de36047a82d8a3893763395e384500b3eeafb585 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:51:14 -0700 Subject: [PATCH 105/121] fix(kubernetes): evict domain caches on disconnect When a kubernetes endpoint was disconnected or unregistered, only the per-endpoint registry entry was dropped. Pods, nodes, services, the generic resource caches (Secrets included), analysis reports and helm releases kept the data fetched with the old credentials for the rest of the session. Add a small root eviction bus. Each cache registers an evictor and the endpoint hook calls it on departure, so every cache drops the departed endpoint's entries and leaves other endpoints alone. Remove the namespace service's direct-fetch cache, which was written but never read. --- .../release/helm-release-data.service.ts | 15 ++ .../domain-data/kube-analysis-data.service.ts | 9 + .../kube-generic-resource-data.base.spec.ts | 12 +- .../kube-generic-resource-data.base.ts | 12 +- .../kube-namespace-data.service.ts | 17 +- .../domain-data/kube-node-data.service.ts | 5 + .../domain-data/kube-pod-data.service.ts | 13 ++ .../domain-data/kube-service-data.service.ts | 11 ++ .../endpoint-data/kube-endpoint-eviction.ts | 33 ++++ .../kube-endpoint-registry.hook.spec.ts | 170 +++++++++++++++++- .../kube-endpoint-registry.hook.ts | 11 +- .../endpoint-data/kube-helm-data.service.ts | 8 + 12 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts index 77a4b91958..4c91e23a44 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Signal, WritableSignal, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; +import { KubeEndpointEviction } from '../../../services/endpoint-data/kube-endpoint-eviction'; import { HelmRelease, HelmReleaseGraph, HelmReleaseResources, HelmReleaseRevision } from '../workload.types'; // Signal-native data surface for the single helm-release detail page. @@ -26,6 +27,20 @@ export class HelmReleaseDataService { private readonly _graph = new Map<string, WritableSignal<HelmReleaseGraph | undefined>>(); private readonly _resources = new Map<string, WritableSignal<HelmReleaseResources | undefined>>(); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => this.evictEndpoint(kubeGuid)); + } + + // Blank rather than delete, so a page holding these signals keeps a + // live identity and sees the reconnect's writes. + private evictEndpoint(kubeGuid: string): void { + for (const map of [this._detail, this._history, this._graph, this._resources]) { + map.forEach((s: WritableSignal<unknown>, key) => { + if (key.startsWith(`${kubeGuid}:`)) s.set(undefined); + }); + } + } + private guid(endpoint: string, namespace: string, name: string): string { return `${endpoint}:${namespace}:${name}`; } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts index 507fd2f743..d6fe469a36 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts @@ -5,6 +5,7 @@ import { catchError, finalize, map, shareReplay, tap } from 'rxjs/operators'; import { KubeScoreReportHelper } from '../../kubernetes/services/kubescore-report.helper'; import { PopeyeReportHelper } from '../../kubernetes/services/popeye-report.helper'; +import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { AnalysisReport, StratosError } from '../endpoint-data/kube-types'; // Per-endpoint cache TTL — 60s mirrors the design doc's K8s-rate-limit @@ -62,6 +63,14 @@ export class KubeAnalysisDataService { readonly errorsSignal: Signal<StratosError[]> = this._errors.asReadonly(); readonly unavailable: Signal<string[]> = this._unavailable.asReadonly(); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => { + evictKeys(this._byEndpoint, key => key === kubeGuid); + evictKeys(this._byId, key => key.startsWith(`${kubeGuid}:`)); + this._unavailable.update(curr => curr.includes(kubeGuid) ? curr.filter(g => g !== kubeGuid) : curr); + }); + } + // Signal projection of the report list for a single endpoint. Returns // an empty array until a fetch completes — call `refresh({ kubeGuid })` // or `loadReports(kubeGuid)` to trigger an HTTP fetch. diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts index fe2594f98e..064aaecfb0 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts @@ -1,4 +1,5 @@ import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { describe, expect, it } from 'vitest'; import { KubeGenericResourceConfig, KubeGenericResourceDataServiceBase } from './kube-generic-resource-data.base'; @@ -23,9 +24,12 @@ class DeletableTestService extends KubeGenericResourceDataServiceBase<{ metadata }; } +// The base registers with the root eviction bus, so build in DI context. +const build = <S>(ctor: new () => S): S => TestBed.runInInjectionContext(() => new ctor()); + describe('KubeGenericResourceDataServiceBase workload scope', () => { it('setWorkloadItems stores items readable via itemsInWorkload, keyed by release', () => { - const svc = new TestResourceService(); + const svc = build(TestResourceService); expect(svc.itemsInWorkload('cnsi-1', 'ns-a', 'rel-x')()).toEqual([]); svc.setWorkloadItems('cnsi-1', 'ns-a', 'rel-x', [{ metadata: { name: 'r1' } } as any]); const out = svc.itemsInWorkload('cnsi-1', 'ns-a', 'rel-x')(); @@ -38,7 +42,7 @@ describe('KubeGenericResourceDataServiceBase workload scope', () => { describe('KubeGenericResourceDataServiceBase delete', () => { it('DELETEs the namespaced resource URL with the cnsi header and drops the row from workload scope', async () => { - const svc = new DeletableTestService(); + const svc = build(DeletableTestService); svc.setWorkloadItems('cnsi-1', 'ns-a', 'rel-x', [ { metadata: { name: 'r1', namespace: 'ns-a' } } as any, { metadata: { name: 'r2', namespace: 'ns-a' } } as any, @@ -52,7 +56,7 @@ describe('KubeGenericResourceDataServiceBase delete', () => { }); it('drops the deleted row from cluster and namespace scopes too', async () => { - const svc = new DeletableTestService(); + const svc = build(DeletableTestService); (svc as any)['_clusterItems'].set(new Map([['cnsi-1', [{ metadata: { name: 'r1' }, kubeGuid: 'cnsi-1' }]]])); svc.setWorkloadItems('cnsi-1', 'ns-a', 'rel-x', [{ metadata: { name: 'r1', namespace: 'ns-a' } } as any]); await svc.delete('cnsi-1', 'r1', 'ns-a'); @@ -61,7 +65,7 @@ describe('KubeGenericResourceDataServiceBase delete', () => { }); it('uses the cluster URL form when no namespace is given', async () => { - const svc = new DeletableTestService(); + const svc = build(DeletableTestService); (svc as any).config = { apiPath: '/api/v1', resourceName: 'persistentvolumes', namespaced: false, title: 'pv' }; await svc.delete('cnsi-1', 'pv1'); expect(svc.lastDelete?.url).toBe('/pp/v1/proxy/api/v1/persistentvolumes/pv1'); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts index d04d2d7ac2..07648f174a 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts @@ -1,7 +1,8 @@ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; -import { Signal, computed, signal } from '@angular/core'; +import { Signal, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; +import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { StratosError } from '../endpoint-data/kube-types'; @@ -39,6 +40,15 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { private readonly _workloadItems = signal<Map<string, Array<T & { kubeGuid: string }>>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => { + const owned = (key: string) => key === kubeGuid || key.startsWith(`${kubeGuid}/`); + evictKeys(this._clusterItems, owned); + evictKeys(this._namespaceItems, owned); + evictKeys(this._workloadItems, owned); + }); + } + // ---- Read API ---------------------------------------------------------- itemsInCluster(kubeGuid: string): Signal<Array<T & { kubeGuid: string }>> { diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts index f5ace81028..f3e35eef16 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts @@ -21,10 +21,6 @@ export class KubeNamespaceDataService { private readonly http = inject(HttpClient); private readonly registry = inject(KubeEndpointDataRegistry); - // Per-endpoint independent fetch cache. Used for refresh() — the - // primary read path goes through registry.getService(kubeGuid) - // .namespaces() so multiple consumers on the same endpoint share data. - private readonly _directNamespaces = signal<Map<string, KubeNamespace[]>>(new Map()); private readonly _errors = signal<StratosError[]>([]); // Returns a signal projecting the namespaces cached on the per-endpoint @@ -74,8 +70,9 @@ export class KubeNamespaceDataService { // Direct fetch escape-hatch — bypasses the per-endpoint cache. Used // by ad-hoc consumers (e.g. dropdown population on a wizard) that // want a snapshot but don't need to share the result with the page- - // level signal. Returns the raw items; errors are accumulated on the - // service-level errors signal. + // level signal. Returns the raw items without caching them, so a + // disconnected endpoint leaves nothing behind; errors are accumulated + // on the service-level errors signal. async fetchDirect(kubeGuid: string): Promise<KubeNamespace[]> { const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); try { @@ -84,17 +81,11 @@ export class KubeNamespaceDataService { { headers }, )); const list = resp?.[kubeGuid]; - const items = (list?.items ?? []).map(ns => ({ + return (list?.items ?? []).map(ns => ({ ...ns, kubeGuid, metadata: { ...(ns.metadata ?? { name: '' }), kubeId: kubeGuid }, })); - this._directNamespaces.update(curr => { - const next = new Map(curr); - next.set(kubeGuid, items); - return next; - }); - return items; } catch (err) { const status = (err as HttpErrorResponse)?.status; const code: StratosError['code'] = status === 401 || status === 403 ? 'UNAUTHORIZED' : 'FETCH_ERROR'; diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts index 1649387764..29bd7a027a 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeNode, StratosError } from '../endpoint-data/kube-types'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; // Per-resource domain service for k8s nodes. Same shape as // KubeNamespaceDataService — caches per-endpoint node lists, exposes @@ -24,6 +25,10 @@ export class KubeNodeDataService { private readonly _nodes = signal<Map<string, KubeNode[]>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => evictKeys(this._nodes, key => key === kubeGuid)); + } + // Signal projecting the cached nodes for a given endpoint. Composed // via computed() so consumers re-render when the underlying map // changes. diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts index 9d1c910c12..2296af6239 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts @@ -5,6 +5,7 @@ import { catchError, map, shareReplay } from 'rxjs/operators'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { KubePod, KubePodContainerStatus, StratosError } from '../endpoint-data/kube-types'; // Per-resource domain service for k8s pods. Mirrors KubeNamespaceDataService @@ -52,6 +53,10 @@ export class KubePodDataService { private readonly _errors = signal<StratosError[]>([]); private readonly _unavailable = signal<Set<string>>(new Set()); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => this.evictEndpoint(kubeGuid)); + } + errors(): Signal<StratosError[]> { return this._errors.asReadonly(); } @@ -147,6 +152,14 @@ export class KubePodDataService { }); } + // Every key format has the kubeGuid as its second segment. + private evictEndpoint(kubeGuid: string): void { + const owned = (key: string) => key.split(':')[1] === kubeGuid; + evictKeys(this._byScope, owned); + [...POD_FETCH_DEDUPE.keys()].filter(owned).forEach(key => POD_FETCH_DEDUPE.delete(key)); + this._unavailable.update(curr => [...curr].some(owned) ? new Set([...curr].filter(key => !owned(key))) : curr); + } + private invalidate(key: string): void { this._byScope.update(curr => { const next = new Map(curr); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts index d3878b9d04..27f97aa4ae 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeEndpoint, KubeService, StratosError } from '../endpoint-data/kube-types'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; // Per-resource domain service for k8s Services AND Endpoints. Folds the // `kubernetes-endpoints` page's k8s Endpoints fetch in here because the @@ -35,6 +36,16 @@ export class KubeServiceDataService { private readonly _clusterEndpoints = signal<Map<string, KubeEndpoint[]>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => { + const owned = (key: string) => key === kubeGuid || key.startsWith(`${kubeGuid}/`); + evictKeys(this._clusterServices, owned); + evictKeys(this._namespaceServices, owned); + evictKeys(this._workloadServices, owned); + evictKeys(this._clusterEndpoints, owned); + }); + } + // ---- Read API ---------------------------------------------------------- servicesInCluster(kubeGuid: string): Signal<KubeService[]> { diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts new file mode 100644 index 0000000000..82c95181d6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts @@ -0,0 +1,33 @@ +import { Injectable, WritableSignal } from '@angular/core'; + +// Root fan-out for "this kubernetes endpoint is gone". The root domain +// caches (pods, nodes, services, generic resources, analysis, helm) keep +// data fetched with the endpoint's credentials for the whole session, so +// each one registers an evictor here and KubeEndpointRegistryHook calls +// evict(kubeGuid) on disconnect or unregister. Services register on +// construction; one never constructed holds nothing to evict. + +@Injectable({ providedIn: 'root' }) +export class KubeEndpointEviction { + private readonly evictors: Array<(kubeGuid: string) => void> = []; + + register(evictor: (kubeGuid: string) => void): void { + this.evictors.push(evictor); + } + + evict(kubeGuid: string): void { + this.evictors.forEach(evictor => evictor(kubeGuid)); + } +} + +// Drop the keys `owned` matches from a signal-held cache map. Leaves the +// signal untouched when nothing matches, so readers do not recompute. +export function evictKeys<V>(cache: WritableSignal<Map<string, V>>, owned: (key: string) => boolean): void { + cache.update(curr => { + const keys = [...curr.keys()].filter(owned); + if (!keys.length) return curr; + const next = new Map(curr); + keys.forEach(key => next.delete(key)); + return next; + }); +} diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts index dd086f2fa8..69d2324440 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts @@ -1,5 +1,5 @@ import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BehaviorSubject } from 'rxjs'; @@ -9,8 +9,17 @@ import { EndpointModel } from '@stratosui/store'; import { EndpointsService } from '../../../../core/src/core/endpoints.service'; import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes/kubernetes-entity-factory'; +import { HelmReleaseDataService } from '../../kubernetes/workloads/release/helm-release-data.service'; +import { HelmReleaseGraph } from '../../kubernetes/workloads/workload.types'; +import { KubeAnalysisDataService } from '../domain-data/kube-analysis-data.service'; +import { KubeSecretDataService } from '../domain-data/kube-generic-resource-data.services'; +import { KubeNodeDataService } from '../domain-data/kube-node-data.service'; +import { KubePodDataService } from '../domain-data/kube-pod-data.service'; +import { KubeServiceDataService } from '../domain-data/kube-service-data.service'; import { KubeEndpointDataRegistry } from './kube-endpoint-data.registry'; import { KubeEndpointRegistryHook } from './kube-endpoint-registry.hook'; +import { KubeHelmDataService } from './kube-helm-data.service'; +import { HelmRelease, KubeNode, KubePod, KubeSecret, KubeService } from './kube-types'; type EndpointMap = { [guid: string]: EndpointModel }; @@ -142,3 +151,162 @@ describe('KubeEndpointRegistryHook', () => { expect(guids).toEqual(['kube-b']); }); }); + +// A departed endpoint's data was fetched with credentials the user no +// longer holds, so every root domain cache must drop it. Each test seeds +// endpoints A and B, disconnects A, and checks A is gone and B is intact. +describe('KubeEndpointRegistryHook domain cache eviction', () => { + const A = 'kube-a'; + const B = 'kube-b'; + let endpoints$: BehaviorSubject<EndpointMap>; + let httpMock: HttpTestingController; + + beforeEach(() => { + endpoints$ = new BehaviorSubject<EndpointMap>({ [A]: kubeEp(A), [B]: kubeEp(B) }); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: EndpointsService, useValue: { endpoints$ } }, + ], + }); + httpMock = TestBed.inject(HttpTestingController); + TestBed.inject(KubeEndpointRegistryHook); + }); + + const disconnectA = () => endpoints$.next({ [A]: kubeEp(A, 'disconnected'), [B]: kubeEp(B) }); + + // Answer the pending list request for `guid` on `path` with one item. + function flushList(path: string, guid: string, item: unknown): void { + const reqs = httpMock.match(r => r.url.startsWith(path) && r.headers.get('x-cap-cnsi-list') === guid); + expect(reqs).toHaveLength(1); + reqs[0].flush({ [guid]: { items: [item] } }); + } + + it('drops pod scopes (cluster and workload) for the departed endpoint only', () => { + const pods = TestBed.inject(KubePodDataService); + const pod = (name: string) => ({ metadata: { name }, status: {} }) as unknown as KubePod; + const clusterA = pods.podsInCluster(A); + const clusterB = pods.podsInCluster(B); + flushList('/pp/v1/proxy/api/v1/pods', A, pod('pa')); + flushList('/pp/v1/proxy/api/v1/pods', B, pod('pb')); + pods.setWorkloadPods(A, 'ns', 'rel', [pod('wa')]); + pods.setWorkloadPods(B, 'ns', 'rel', [pod('wb')]); + expect(clusterA()).toHaveLength(1); + + disconnectA(); + + expect(clusterA()).toEqual([]); + expect(pods.podsInWorkload(A, 'ns', 'rel')()).toEqual([]); + expect(clusterB()).toHaveLength(1); + expect(pods.podsInWorkload(B, 'ns', 'rel')()).toHaveLength(1); + }); + + it('drops generic resource scopes (cluster, namespace, workload), e.g. Secrets', async () => { + const secrets = TestBed.inject(KubeSecretDataService); + const secret = (name: string) => ({ metadata: { name } }) as KubeSecret; + for (const guid of [A, B]) { + const cluster = secrets.refresh({ kubeGuid: guid }); + flushList('/pp/v1/proxy/api/v1/secrets', guid, secret(`c-${guid}`)); + await cluster; + const ns = secrets.refresh({ kubeGuid: guid, namespace: 'ns' }); + flushList('/pp/v1/proxy/api/v1/namespaces/ns/secrets', guid, secret(`n-${guid}`)); + await ns; + secrets.setWorkloadItems(guid, 'ns', 'rel', [secret(`w-${guid}`)]); + } + + disconnectA(); + + expect(secrets.itemsInCluster(A)()).toEqual([]); + expect(secrets.itemsInNamespace(A, 'ns')()).toEqual([]); + expect(secrets.itemsInWorkload(A, 'ns', 'rel')()).toEqual([]); + expect(secrets.itemsInCluster(B)()).toHaveLength(1); + expect(secrets.itemsInNamespace(B, 'ns')()).toHaveLength(1); + expect(secrets.itemsInWorkload(B, 'ns', 'rel')()).toHaveLength(1); + }); + + it('drops k8s services and endpoints', async () => { + const services = TestBed.inject(KubeServiceDataService); + for (const guid of [A, B]) { + const done = services.refresh({ kubeGuid: guid }); + flushList('/pp/v1/proxy/api/v1/services', guid, { metadata: { name: 's' } }); + flushList('/pp/v1/proxy/api/v1/endpoints', guid, { metadata: { name: 'e' } }); + await done; + services.setWorkloadServices(guid, 'ns', 'rel', [{ metadata: { name: 'w' } } as KubeService]); + } + + disconnectA(); + + expect(services.servicesInCluster(A)()).toEqual([]); + expect(services.endpointsInCluster(A)()).toEqual([]); + expect(services.servicesInWorkload(A, 'ns', 'rel')()).toEqual([]); + expect(services.servicesInCluster(B)()).toHaveLength(1); + expect(services.endpointsInCluster(B)()).toHaveLength(1); + expect(services.servicesInWorkload(B, 'ns', 'rel')()).toHaveLength(1); + }); + + it('drops cached nodes', async () => { + const nodes = TestBed.inject(KubeNodeDataService); + for (const guid of [A, B]) { + const done = nodes.refresh(guid); + flushList('/pp/v1/proxy/api/v1/nodes', guid, { metadata: { name: 'n' } } as KubeNode); + await done; + } + + disconnectA(); + + expect(nodes.nodesInCluster(A)()).toEqual([]); + expect(nodes.nodesInCluster(B)()).toHaveLength(1); + }); + + it('drops analysis report lists and fetched report details', async () => { + const analysis = TestBed.inject(KubeAnalysisDataService); + for (const guid of [A, B]) { + const list = analysis.refresh({ kubeGuid: guid }); + httpMock.expectOne(`/pp/v1/analysis/reports/${guid}`).flush([{ id: 'r1' }]); + await list; + analysis.reportById(guid, 'r1').subscribe(); + httpMock.expectOne(`/pp/v1/analysis/reports/${guid}/r1`).flush({ id: 'r1' }); + } + + disconnectA(); + + expect(analysis.reportsForEndpoint(A)()).toEqual([]); + expect(analysis.reportsForEndpoint(B)()).toHaveLength(1); + // A's detail is no longer cached, so a read goes back to the server; + // B's detail is still served from the cache. + analysis.reportById(A, 'r1').subscribe(); + analysis.reportById(B, 'r1').subscribe(); + httpMock.expectOne(`/pp/v1/analysis/reports/${A}/r1`); + httpMock.expectNone(`/pp/v1/analysis/reports/${B}/r1`); + }); + + it('drops helm releases of the departed endpoint from the cross-endpoint list', async () => { + const helm = TestBed.inject(KubeHelmDataService); + const done = helm.loadReleases(); + httpMock.expectOne('/pp/v1/helm/releases').flush({ + [A]: [{ name: 'ra', namespace: 'ns' } as HelmRelease], + [B]: [{ name: 'rb', namespace: 'ns' } as HelmRelease], + }); + await done; + + disconnectA(); + + expect(helm.allReleases()().map(r => r.endpointId)).toEqual([B]); + }); + + it('blanks helm release detail surfaces of the departed endpoint', () => { + const release = TestBed.inject(HelmReleaseDataService); + const graph = { nodes: [], links: [] } as unknown as HelmReleaseGraph; + release.setGraph(`${A}:ns:rel`, graph); + release.setGraph(`${B}:ns:rel`, graph); + const graphA = release.graph(`${A}:ns:rel`); + + disconnectA(); + + expect(graphA()).toBeUndefined(); + expect(release.graph(`${B}:ns:rel`)()).toBe(graph); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.ts index 2a7a2adcdc..68c76268c9 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.ts @@ -6,6 +6,7 @@ import { EndpointModel } from '@stratosui/store'; import { EndpointsService } from '../../../../core/src/core/endpoints.service'; import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes/kubernetes-entity-factory'; import { KubeEndpointDataRegistry } from './kube-endpoint-data.registry'; +import { KubeEndpointEviction } from './kube-endpoint-eviction'; // Signal/router-native lifecycle hook for the kubernetes endpoint // registry. Replaces the wave-2 `KubeEndpointLifecycleService` which @@ -23,9 +24,11 @@ import { KubeEndpointDataRegistry } from './kube-endpoint-data.registry'; // * Warm `registry.getService(guid)` for new connects (guids in // current set, not previous) so the first navigation already has // a cached service. -// * Evict `registry.unregister(guid)` for departures (guids in -// previous set, not current). A departure covers BOTH a status -// flip to disconnected AND the endpoint being removed entirely. +// * Evict `registry.unregister(guid)` and `eviction.evict(guid)` for +// departures (guids in previous set, not current). A departure +// covers BOTH a status flip to disconnected AND the endpoint being +// removed entirely. The second call clears the root domain caches, +// which still hold data fetched with the departed credentials. // // Diff key: `endpoint.cnsi_type === KUBERNETES_ENDPOINT_TYPE && // endpoint.connectionStatus === 'connected'`. Subscriber sees @@ -40,6 +43,7 @@ import { KubeEndpointDataRegistry } from './kube-endpoint-data.registry'; export class KubeEndpointRegistryHook implements OnDestroy { private readonly endpointsService = inject(EndpointsService); private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); private readonly subs = new Subscription(); private lastConnected: Set<string> = new Set(); @@ -72,6 +76,7 @@ export class KubeEndpointRegistryHook implements OnDestroy { if (!current.has(guid)) { this._disconnects++; this.registry.unregister(guid); + this.eviction.evict(guid); } }); diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts index 2108cedd64..dde974326b 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts @@ -4,6 +4,7 @@ import { firstValueFrom } from 'rxjs'; import { TailwindSnackBarService } from '../../../../core/src/shared/services/tailwind-snackbar.service'; import { EndpointModel } from '../../../../store/src/types/endpoint.types'; +import { KubeEndpointEviction } from './kube-endpoint-eviction'; import { HelmInstallPayload, HelmRelease, @@ -61,6 +62,13 @@ export class KubeHelmDataService { ...this._chartErrors(), ]); + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => + this._releases.update(curr => curr.some(r => r.endpointId === kubeGuid) + ? curr.filter(r => r.endpointId !== kubeGuid) + : curr)); + } + // --- Public read API --- allReleases(): Signal<HelmRelease[]> { From 3df38cd02394292777f51013f6ffc0d1cfaaeb86 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 06:51:14 -0700 Subject: [PATCH 106/121] fix(apps): stop app-stats poll with its page The apps list config is a root singleton, so the stats poll and the page-change effect it started kept running after the user left the page: one app-stats request per Cloud Foundry every 30 seconds until reload. Callers now pass their DestroyRef and the poll stops when that page is destroyed. A later visit starts a fresh poll, and a page that is already gone does not start one. The application wall also kept its create-permission subscription alive after it was destroyed; it now ends with the wall. --- .../application-wall.component.spec.ts | 38 ++++++- .../application-wall.component.ts | 12 ++- ...ndry-applications-signal.component.spec.ts | 10 ++ ...d-foundry-applications-signal.component.ts | 5 +- ...oundry-space-apps-signal.component.spec.ts | 10 ++ ...oud-foundry-space-apps-signal.component.ts | 5 +- .../app/cf-apps-signal-config.service.spec.ts | 98 ++++++++++++++++++- .../app/cf-apps-signal-config.service.ts | 64 ++++++------ 8 files changed, 198 insertions(+), 44 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.spec.ts index 01c2b40f74..bab74a3f99 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.spec.ts @@ -4,10 +4,10 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ActivatedRoute } from '@angular/router'; import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; +import { Observable, Subject, of } from 'rxjs'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TabNavService } from '@stratosui/core'; +import { CurrentUserPermissionsService, TabNavService } from '@stratosui/core'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { generateCfBaseTestModulesNoShared } from '@test-framework/cloud-foundry-endpoint-service.helper'; @@ -15,6 +15,7 @@ import { ApplicationWallComponent } from './application-wall.component'; import { countDuplicateUrlEndpoints } from '@stratosui/store'; import { CfAppsSignalConfigService } from '../../../shared/signal-list-configs/app/cf-apps-signal-config.service'; import { CloudFoundryService } from '../../../shared/data-services/cloud-foundry.service'; +import { CfCurrentUserPermissions } from '../../../user-permissions/cf-user-permissions-checkers'; function makeStubSignalConfigService() { const pageIndex = signal(0); @@ -79,6 +80,9 @@ describe('ApplicationWallComponent', () => { let component: ApplicationWallComponent; let fixture: ComponentFixture<ApplicationWallComponent>; let stubSignalConfig: ReturnType<typeof makeStubSignalConfigService>; + // Source for the wall's own create-application permission check, so a + // spec can emit on it after the wall is gone. + let canCreate$: Subject<boolean>; beforeEach(async () => { stubSignalConfig = makeStubSignalConfigService(); @@ -127,9 +131,17 @@ describe('ApplicationWallComponent', () => { ] }).compileComponents(); + canCreate$ = new Subject<boolean>(); + const perms = TestBed.inject(CurrentUserPermissionsService); + const realCan = perms.can.bind(perms); + vi.spyOn(perms, 'can').mockImplementation(((action: unknown, ...rest: unknown[]) => + action === CfCurrentUserPermissions.APPLICATION_CREATE + ? canCreate$ + : (realCan as (...a: unknown[]) => Observable<boolean>)(action, ...rest)) as typeof perms.can); + fixture = TestBed.createComponent(ApplicationWallComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should be created', () => { @@ -205,6 +217,26 @@ describe('ApplicationWallComponent', () => { expect(range!.valueType).toBe('date'); expect(range!.selected).toBe(stubSignalConfig.lastRefreshedRange); }); + + it('stops listening for the create permission once destroyed', () => { + canCreate$.next(true); + expect(component.canCreateApplication()).toBe(true); + + fixture.destroy(); + expect(canCreate$.observed).toBe(false); + canCreate$.next(false); + expect(component.canCreateApplication()).toBe(true); + }); + + it('scopes stats polling to the wall: passes its own DestroyRef', async () => { + stubSignalConfig.startStatsPolling.mockClear(); + await component.ngOnInit(); + const host = stubSignalConfig.startStatsPolling.mock.calls[0]?.[0] as { destroyed: boolean } | undefined; + expect(host?.destroyed).toBe(false); + + fixture.destroy(); + expect(host?.destroyed).toBe(true); + }); }); describe('countDuplicateUrlEndpoints (shared dup-URL helper)', () => { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts index 4f1a07ecc1..c8b2ab2b85 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts @@ -1,8 +1,8 @@ import { CommonModule, DatePipe } from '@angular/common'; import { - Component, OnInit, ChangeDetectionStrategy, Signal, computed, inject, signal, WritableSignal, Injector, effect, + Component, OnInit, ChangeDetectionStrategy, DestroyRef, Signal, computed, inject, signal, WritableSignal, Injector, effect, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Observable, firstValueFrom } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; @@ -66,6 +66,7 @@ export class ApplicationWallComponent implements OnInit { private confirmDialog = inject(ConfirmationDialogService); private snackBar = inject(TailwindSnackBarService); private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); // Row keys ({cnsiGuid}:{appGuid}) for apps the user has favorited. // Derived from UserFavoriteManager's combined (groups, entities) stream @@ -221,7 +222,10 @@ export class ApplicationWallComponent implements OnInit { // Resolve APPLICATION_CREATE for the current user; mirror the previous // directive call (no endpoint/org/space scope — true if any connected // CF allows it). Push the result into a signal the L5 button reads. - currentUserPermissionsService.can(CfCurrentUserPermissions.APPLICATION_CREATE).subscribe({ + // The check stays live, so end it with the wall. + currentUserPermissionsService.can(CfCurrentUserPermissions.APPLICATION_CREATE).pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe({ next: can => this.canCreateApplication.set(can), error: err => console.error('Error resolving create-application permission:', err), }); @@ -515,7 +519,7 @@ export class ApplicationWallComponent implements OnInit { // ticks every 30s so STARTING / CRASHED instances drift toward their // terminal state without user input; it ALSO re-fetches whenever // pagedItems changes so navigating pages fills the column quickly. - this.appsConfig.startStatsPolling(); + this.appsConfig.startStatsPolling(this.destroyRef); } static formatMb(mb: number | null | undefined): string { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.spec.ts index e5682de44f..c16979640d 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.spec.ts @@ -202,6 +202,16 @@ describe('CloudFoundryApplicationsSignalComponent (component)', () => { await fixture.whenStable(); }); + it('scopes stats polling to this tab: passes its own DestroyRef', async () => { + stubAppsConfig.startStatsPolling.mockClear(); + await component.ngOnInit(); + const host = stubAppsConfig.startStatsPolling.mock.calls[0]?.[0] as { destroyed: boolean } | undefined; + expect(host?.destroyed).toBe(false); + + fixture.destroy(); + expect(host?.destroyed).toBe(true); + }); + it('builds Org/Space filter dropdowns with no locked Cloud Foundry dropdown', async () => { await component.ngOnInit(); const cfg = component.listConfig(); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.ts index 5d9bfe4274..0df935b753 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-applications/cloud-foundry-applications-signal.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { - Component, ChangeDetectionStrategy, OnInit, Signal, WritableSignal, computed, inject, signal, Injector, effect, + Component, ChangeDetectionStrategy, DestroyRef, OnInit, Signal, WritableSignal, computed, inject, signal, Injector, effect, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; @@ -64,6 +64,7 @@ export class CloudFoundryApplicationsSignalComponent implements OnInit { private router = inject(Router); private permissionsService = inject(CurrentUserPermissionsService); private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); /** Total app count for the L5 sub-nav. */ public totalApplications!: Signal<number>; @@ -365,7 +366,7 @@ export class CloudFoundryApplicationsSignalComponent implements OnInit { this.appsConfig.pageSize.set(6); void this.appsConfig.loadAll(); - this.appsConfig.startStatsPolling(); + this.appsConfig.startStatsPolling(this.destroyRef); } private toggleAppFavorite(app: StApp): void { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.spec.ts index 66eb132fd1..38df358ec9 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.spec.ts @@ -310,6 +310,16 @@ describe('CloudFoundrySpaceAppsSignalComponent', () => { expect([...selected()]).toEqual(['cnsi-1:app-1']); }); + it('scopes stats polling to this tab: passes its own DestroyRef', async () => { + stubAppsConfig.startStatsPolling.mockClear(); + await component.ngOnInit(); + const host = stubAppsConfig.startStatsPolling.mock.calls[0]?.[0] as { destroyed: boolean } | undefined; + expect(host?.destroyed).toBe(false); + + fixture.destroy(); + expect(host?.destroyed).toBe(true); + }); + it('formatMb returns human-friendly units and ∞ for unlimited', () => { expect(CloudFoundrySpaceAppsSignalComponent.formatMb(512)).toBe('512 MB'); expect(CloudFoundrySpaceAppsSignalComponent.formatMb(2048)).toBe('2.0 GB'); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.ts index 191d166fca..bf168152f3 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-apps/cloud-foundry-space-apps-signal.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { - Component, ChangeDetectionStrategy, OnInit, Signal, WritableSignal, computed, inject, signal, Injector, effect, + Component, ChangeDetectionStrategy, DestroyRef, OnInit, Signal, WritableSignal, computed, inject, signal, Injector, effect, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; @@ -70,6 +70,7 @@ export class CloudFoundrySpaceAppsSignalComponent implements OnInit { private router = inject(Router); private permissionsService = inject(CurrentUserPermissionsService); private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); /** Total app count in this space for the L5 sub-nav. */ public totalApplications!: Signal<number>; @@ -331,7 +332,7 @@ export class CloudFoundrySpaceAppsSignalComponent implements OnInit { // Drive the Instances column's running / desired display the same way // the wall does — initial fetch + 30s polling + reactive re-fetch on // pagedItems change. - this.appsConfig.startStatsPolling(); + this.appsConfig.startStatsPolling(this.destroyRef); } private toggleAppFavorite(app: StApp): void { diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.spec.ts index 6c4f86eec7..364a9cdb95 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { of, Subject, throwError } from 'rxjs'; +import { DestroyRef, EnvironmentInjector, createEnvironmentInjector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CfAppsSignalConfigService } from './cf-apps-signal-config.service'; import { CloudFoundryService } from '../../data-services/cloud-foundry.service'; @@ -601,8 +602,9 @@ describe('CfAppsSignalConfigService', () => { TestBed.tick(); // Simulate two mounts of the app-wall (route re-entry). - svc.startStatsPolling(); - svc.startStatsPolling(); + const host = TestBed.inject(DestroyRef); + svc.startStatsPolling(host); + svc.startStatsPolling(host); TestBed.tick(); // Count only refreshes driven by the reactive effect on a pagedItems @@ -619,6 +621,98 @@ describe('CfAppsSignalConfigService', () => { expect(refresh).toHaveBeenCalledTimes(1); }); + describe('stats polling lifetime', () => { + // The service is a root singleton, so only the host that started the + // poll knows when its page is gone. Without a host-scoped stop, the + // first apps-list visit left one app-stats request per CF every 30s + // running until reload. + afterEach(() => vi.useRealTimers()); + + const appA = { + guid: 'a', name: 'a-app', state: 'STARTED', cnsiGuid: 'cnsi-1', + spaceGuid: 'sp-1', instances: 1, routes: [], createdAt: '', updatedAt: '', + } as StApp; + const appB = { ...appA, guid: 'b', name: 'b-app' } as StApp; + + function statsCalls(http: HttpClient): number { + return (http.get as ReturnType<typeof vi.fn>).mock.calls + .filter(([url]) => String(url).startsWith('/pp/v1/cf/app-stats/')).length; + } + + async function setup() { + vi.useFakeTimers(); + const http = makeHttp(); + const svc = makeSvc(http, makeStubCfService([{ guid: 'cnsi-1', name: 'Primary CF' }])); + svc.initialize(['cnsi-1']); + // Let the (empty) initial drains settle first, or they replace the + // seeded rows mid-test and the poll goes quiet for the wrong reason. + await drainUntil(() => false); + seedApps(svc, [appA]); + TestBed.tick(); + const newHost = () => createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); + return { http, svc, newHost }; + } + + it('stops the timer and the reactive effect when the host is destroyed', async () => { + const { http, svc, newHost } = await setup(); + const host = newHost(); + svc.startStatsPolling(host.get(DestroyRef)); + TestBed.tick(); + vi.advanceTimersByTime(30_000); + expect(statsCalls(http)).toBeGreaterThan(0); + + host.destroy(); + const before = statsCalls(http); + vi.advanceTimersByTime(95_000); + seedApps(svc, [appB]); + TestBed.tick(); + + expect(statsCalls(http)).toBe(before); + }); + + it('polls again when a later host starts after the first was destroyed', async () => { + const { http, svc, newHost } = await setup(); + const first = newHost(); + svc.startStatsPolling(first.get(DestroyRef)); + first.destroy(); + + const second = newHost(); + svc.startStatsPolling(second.get(DestroyRef)); + TestBed.tick(); + const before = statsCalls(http); + vi.advanceTimersByTime(30_000); + + expect(statsCalls(http)).toBe(before + 1); + }); + + it('a stale host destroyed after a re-entry does not stop the new poll', async () => { + const { http, svc, newHost } = await setup(); + const first = newHost(); + svc.startStatsPolling(first.get(DestroyRef)); + const second = newHost(); + svc.startStatsPolling(second.get(DestroyRef)); + TestBed.tick(); + first.destroy(); + + const before = statsCalls(http); + vi.advanceTimersByTime(30_000); + expect(statsCalls(http)).toBe(before + 1); + }); + + it('does not start for a host destroyed before the call', async () => { + const { http, svc, newHost } = await setup(); + const host = newHost(); + const hostRef = host.get(DestroyRef); + host.destroy(); + const before = statsCalls(http); + + svc.startStatsPolling(hostRef); + vi.advanceTimersByTime(60_000); + + expect(statsCalls(http)).toBe(before); + }); + }); + it('cnsiOptions picks up connected CF endpoints from CloudFoundryService', () => { const http = makeHttp(); const cf = makeStubCfService([ diff --git a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.ts index b507fb4edb..906ca77c16 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/signal-list-configs/app/cf-apps-signal-config.service.ts @@ -85,15 +85,14 @@ export class CfAppsSignalConfigService { signal(new Map()); readonly appStats: Signal<Map<string, { running: number; total: number }>> = computed(() => this._appStats()); - private statsTimer?: ReturnType<typeof setInterval>; - // Reactive stats refresh effect, captured so a re-entry (root singleton + - // per-mount startStatsPolling) destroys the prior one instead of stacking. - private statsEffect?: EffectRef; + // Stops the current stats poll (timer + reactive effect). Captured so a + // re-entry (root singleton + per-mount startStatsPolling) stops the prior + // one instead of stacking. + private stopStats?: () => void; // Tracks `${cnsiGuid}:${appGuid}` keys with an in-flight stats request so // burst signal updates during initial render don't issue 4× duplicate // calls per app — the original symptom on multi-CNSI walls with slow CFs. private readonly statsInFlight = new Set<string>(); - private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); // Lazy org/space catalog loading. loadNames() runs an org+per-org-spaces @@ -268,6 +267,10 @@ export class CfAppsSignalConfigService { private readonly http = inject(HttpClient); constructor() { + // Hosts stop their own polls; this covers an injector torn down while + // a poll is still live (each spec's TestBed reset). + inject(DestroyRef).onDestroy(() => this.stopStats?.()); + const cfService = inject(CloudFoundryService, { optional: true }); this.connectedEndpoints = cfService ? toSignal(cfService.connectedCFEndpoints$, { initialValue: [] as EndpointModel[] }) @@ -1079,44 +1082,43 @@ export class CfAppsSignalConfigService { } // Kick off an initial stats fetch for the currently visible page, plus - // an interval-based refresh. Safe to call more than once — the timer - // is reset each time. Call from the app-wall once the view pipeline is - // initialized; registerDestroy stops the timer on teardown. + // an interval-based refresh. Call from the list component once the view + // pipeline is initialized, passing the component's DestroyRef: this + // service is a root singleton, so only the host knows when its page is + // gone, and the poll stops with it. Safe to call more than once — each + // call stops the previous poll first, so a re-entry never stacks. // // Page / sort / filter changes happen more often than the poll tick, // so we ALSO re-fetch reactively whenever pagedItems changes — the // user navigating to page 2 sees stats fill in within a few hundred // ms rather than waiting up to intervalMs. - startStatsPolling(intervalMs: number = 30000): void { - if (this.statsTimer) { - clearInterval(this.statsTimer); + startStatsPolling(host: DestroyRef, intervalMs: number = 30000): void { + this.stopStats?.(); + // The callers start polling after an await in ngOnInit; a host that + // left in the meantime must not start a poll nobody will stop. + if (host.destroyed) { + return; } const runOnce = () => { const keys = this.view.pagedItems().map((a) => `${a.cnsiGuid}:${a.guid}`); this.refreshStatsForKeys(keys); }; runOnce(); - this.statsTimer = setInterval(runOnce, intervalMs); - // effect() requires an injection context; startStatsPolling is called - // from the component's ngOnInit which isn't one. Wrap it explicitly. - // - // Tear down any previous mount's effect first. This service is a root - // singleton, so a re-entry (app-wall remounted on navigation) would - // otherwise stack one live effect per visit, multiplying stats fetches - // across a session — same reasoning as the statsTimer reset above. - this.statsEffect?.destroy(); - runInInjectionContext(this.injector, () => { - this.statsEffect = effect(() => { - const keys = this.view.pagedItems().map((a) => `${a.cnsiGuid}:${a.guid}`); - this.refreshStatsForKeys(keys); - }); - }); - this.destroyRef.onDestroy(() => { - if (this.statsTimer) { - clearInterval(this.statsTimer); - this.statsTimer = undefined; + const timer = setInterval(runOnce, intervalMs); + // effect() requires an injection context; the callers' ngOnInit isn't + // one, hence the explicit injector. + const statsEffect = effect(runOnce, { injector: this.injector }); + // stop() only touches its own timer and effect, so a stale host + // destroyed after a newer start leaves the newer poll running. + const stop = () => { + clearInterval(timer); + statsEffect.destroy(); + if (this.stopStats === stop) { + this.stopStats = undefined; } - }); + }; + this.stopStats = stop; + host.onDestroy(stop); } // Fetches every route currently mapped to an app, via the Stratos-native From 9d5d9267756ad3e87fa84727f26bb813f2dbc4f3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:00:35 -0700 Subject: [PATCH 107/121] fix(endpoints): build the name cell once The endpoint name cell made a new signal-to-observable bridge each time its row changed, and each bridge kept watching the endpoints map until the cell was destroyed. Read the endpoint through one computed signal keyed on the row's id instead, and render it directly from the signal. The template-facing field endpoint$ becomes the signal endpoint, and an endpoint removed from the map now renders nothing rather than its last name. --- .../table-cell-endpoint-name.component.html | 2 +- ...table-cell-endpoint-name.component.spec.ts | 62 ++++++++++++++++--- .../table-cell-endpoint-name.component.ts | 40 ++++++------ 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.html b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.html index dfb096fd4b..c12182285f 100644 --- a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.html +++ b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.html @@ -1,4 +1,4 @@ -@if (endpoint$ | async; as endpoint) { +@if (endpoint(); as endpoint) { <div class="flex items-center"> @if (endpoint.canShowLink) { <a routerLink="{{ endpoint.link }}">{{ endpoint.name }}</a> diff --git a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.spec.ts b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.spec.ts index 56d9540c1d..f739ed6f5b 100644 --- a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.spec.ts @@ -1,14 +1,37 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; +import { SIGNAL, ReactiveNode } from '@angular/core/primitives/signals'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { describe, it, expect, beforeEach } from 'vitest'; -import { EndpointModel, entityCatalog, EntityCatalogHelper, EntityCatalogHelpers, generateStratosEntities } from '@stratosui/store'; -import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; +import { + EndpointModel, + EndpointsDataService, + entityCatalog, + EntityCatalogHelper, + EntityCatalogHelpers, + generateStratosEntities, +} from '@stratosui/store'; +import { seedEndpointsDataService, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; import { TableCellEndpointNameComponent } from './table-cell-endpoint-name.component'; +const endpointOne = { guid: 'ep-1', name: 'Endpoint One', connectionStatus: 'connected' } as EndpointModel; +const endpointTwo = { guid: 'ep-2', name: 'Endpoint Two', connectionStatus: 'disconnected' } as EndpointModel; + +// Live consumers of the endpoints map signal: each is something that re-runs +// when the map changes. Read from the signal node because nothing public +// exposes it. +function liveConsumerCount(): number { + const node = TestBed.inject(EndpointsDataService).endpoints[SIGNAL] as ReactiveNode; + let count = 0; + for (let link = node.consumers; link !== undefined; link = link.nextConsumer) { + count++; + } + return count; +} + describe('TableCellEndpointNameComponent', () => { let component: TableCellEndpointNameComponent; let fixture: ComponentFixture<TableCellEndpointNameComponent>; @@ -41,18 +64,43 @@ describe('TableCellEndpointNameComponent', () => { // Initialize EntityCatalogHelper so components using stratosEntityCatalog.<entity>.store work const helper = TestBed.inject(EntityCatalogHelper); EntityCatalogHelpers.SetEntityCatalogHelper(helper); + + seedEndpointsDataService([endpointOne, endpointTwo]); }); beforeEach(() => { fixture = TestBed.createComponent(TableCellEndpointNameComponent); component = fixture.componentInstance; - component.row = { - guid: '' - } as EndpointModel; - fixture.detectChanges(); }); - it('should create', () => { + it('should create', async () => { + fixture.componentRef.setInput('row', { guid: '' } as EndpointModel); + await fixture.whenStable(); expect(component).toBeTruthy(); }); + + it('renders the name of the endpoint the row points at', async () => { + fixture.componentRef.setInput('row', { endpointId: 'ep-1' }); + await fixture.whenStable(); + + const link = fixture.nativeElement.querySelector('a'); + expect(link?.textContent?.trim()).toBe('Endpoint One'); + }); + + it('follows a new row and watches only the current one', async () => { + fixture.componentRef.setInput('row', endpointOne); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('Endpoint One'); + + fixture.componentRef.setInput('row', endpointTwo); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('a')).toBeNull(); + expect(fixture.nativeElement.querySelector('span')?.textContent?.trim()).toBe('Endpoint Two'); + + // One watcher for the cell, not one per row it has ever shown. + expect(liveConsumerCount()).toBe(1); + + fixture.destroy(); + expect(liveConsumerCount()).toBe(0); + }); }); diff --git a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.ts b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.ts index ac0d38f93b..c3c3d55fc4 100644 --- a/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.ts +++ b/src/frontend/packages/core/src/shared/components/endpoint-list/table-cell-endpoint-name/table-cell-endpoint-name.component.ts @@ -1,11 +1,7 @@ -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, Input, inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, Input, computed, inject, signal } from '@angular/core'; import { CustomTooltipDirective } from '../../custom-tooltip/custom-tooltip.directive'; import { RouterModule } from '@angular/router'; import { entityCatalog, EndpointModel, EndpointsDataService } from '@stratosui/store'; -import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; import { EndpointsService } from '../../../../core/endpoints.service'; import { TableCellCustom } from '../../signal-list/cell-base'; @@ -20,7 +16,6 @@ export interface RowWithEndpointId { templateUrl: './table-cell-endpoint-name.component.html', standalone: true, imports: [ - CommonModule, RouterModule, CustomIconComponent, CustomTooltipDirective @@ -30,27 +25,28 @@ export interface RowWithEndpointId { export class TableCellEndpointNameComponent extends TableCellCustom<EndpointModel | RowWithEndpointId> { private endpointsData = inject(EndpointsDataService); - private injector = inject(Injector); + private endpointId = signal<string | undefined>(undefined); - public endpoint$!: Observable<any>; + // Built once and keyed on the row's id, so a new row swaps the id instead + // of adding another endpoint watcher that lives until the cell is destroyed. + public readonly endpoint = computed(() => { + const id = this.endpointId(); + const data = id ? this.endpointsData.endpoints().get(id) : undefined; + if (!data) { + return undefined; + } + const ep = data.cnsi_type ? entityCatalog.getEndpoint(data.cnsi_type, data.sub_type)?.definition : undefined; + return { + ...data, + canShowLink: data.connectionStatus === 'connected' || !!ep?.unConnectable, + link: EndpointsService.getLinkForEndpoint(data) + }; + }); @Input() set row(row: EndpointModel | RowWithEndpointId) { super.row = row; /* tslint:disable-next-line:no-string-literal */ - const id = (row as any)['endpointId'] || (row as any)['guid']; - // W36-B Wave 3: read endpoint via EndpointsDataService signal - // bridge instead of legacy EntityMonitor.entity$. - this.endpoint$ = toObservable(this.endpointsData.endpointById(id), { injector: this.injector }).pipe( - filter((data): data is EndpointModel => !!data), - map(data => { - const ep = data.cnsi_type ? entityCatalog.getEndpoint(data.cnsi_type, data.sub_type)?.definition : undefined; - return { - ...data, - canShowLink: data.connectionStatus === 'connected' || !!ep?.unConnectable, - link: EndpointsService.getLinkForEndpoint(data) - }; - }) - ); + this.endpointId.set((row as any)['endpointId'] || (row as any)['guid']); } } From 15e95fb07aed741b3f362dd599fce659a9b21ee2 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:01:11 -0700 Subject: [PATCH 108/121] fix(helm): clear release data on a failed load A failed reload of a helm release's detail or history left the data fetched before it in place. After a reconnect with narrower credentials the page went on showing release data the user can no longer read. Blank the detail and history signals on error, the way endpoint eviction does, so the page keeps its signals and shows the empty state. --- .../release/helm-release-data.service.spec.ts | 27 +++++++++++++++++++ .../release/helm-release-data.service.ts | 8 ++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.spec.ts index e39cb786e6..d6f6977bfc 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.spec.ts @@ -86,6 +86,33 @@ describe('HelmReleaseDataService', () => { expect(svc.isFetchingDetail(GUID)()).toBe(false); }); + it('a failed reload clears the detail loaded before it', async () => { + const detail = svc.releaseDetail(GUID); + const first = svc.loadReleaseDetail('cnsi-1', 'ns-a', 'rel-x'); + httpMock.expectOne(DETAIL_URL).flush({ name: 'rel-x', namespace: 'ns-a', chart: { metadata: {} } }); + await first; + expect(detail()?.name).toBe('rel-x'); + + const reload = svc.loadReleaseDetail('cnsi-1', 'ns-a', 'rel-x'); + httpMock.expectOne(DETAIL_URL).flush({ error: { message: 'forbidden' } }, { status: 403, statusText: 'Forbidden' }); + await reload; + expect(detail()).toBeUndefined(); + expect(svc.isFetchingDetail(GUID)()).toBe(false); + }); + + it('a failed reload clears the history loaded before it', async () => { + const history = svc.history(GUID); + const first = svc.loadHistory('cnsi-1', 'ns-a', 'rel-x'); + httpMock.expectOne(HISTORY_URL).flush({ revisions: [{ revision: 1, status: 'deployed' }] }); + await first; + expect(history()).toHaveLength(1); + + const reload = svc.loadHistory('cnsi-1', 'ns-a', 'rel-x'); + httpMock.expectOne(HISTORY_URL).flush({ error: { message: 'forbidden' } }, { status: 403, statusText: 'Forbidden' }); + await reload; + expect(history()).toBeUndefined(); + }); + it('returns stable signal identity per guid', () => { expect(svc.releaseDetail(GUID)).toBe(svc.releaseDetail(GUID)); expect(svc.graph(GUID)).toBe(svc.graph(GUID)); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts index 4c91e23a44..59fc524765 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-data.service.ts @@ -98,7 +98,10 @@ export class HelmReleaseDataService { ); this.sig(this._detail, key, undefined).set(resp); } catch { - // Leave the detail signal as-is; the page renders the tristate empty. + // Blank, not delete (see evictEndpoint): a failed reload, e.g. a 403 + // after reconnecting with narrower credentials, must not keep showing + // the release fetched before. The page renders the tristate empty. + this.sig(this._detail, key, undefined).set(undefined); } finally { loading.set(false); } @@ -114,7 +117,8 @@ export class HelmReleaseDataService { ); this.sig(this._history, key, undefined).set(resp?.revisions ?? []); } catch { - // Leave history undefined on error. + // Blank on error, as for the detail: never keep an earlier history. + this.sig(this._history, key, undefined).set(undefined); } } } From 03babc02a30f7aa0aab1ea43435d8d4d71a4577d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:01:29 -0700 Subject: [PATCH 109/121] fix(kubernetes): keep newest errors, evict on leave The kubernetes data caches capped their error lists with slice(0, 50), which keeps the oldest entries: once 50 errors existed, new ones were never recorded. Keep the newest 50. Errors recorded against an endpoint stayed after it was disconnected or unregistered. Each cache's eviction now also drops errors that affect only the departed endpoint, and the namespace cache registers an evictor for its errors. Pod errors were never cleared. A pod error now names its scope, and a later successful load of that scope clears it, as the analysis cache does for its endpoints. --- .../kube-analysis-data.service.spec.ts | 12 ++++++ .../domain-data/kube-analysis-data.service.ts | 5 ++- .../kube-generic-resource-data.base.ts | 5 ++- ...ube-generic-resource-data.services.spec.ts | 13 ++++++ .../kube-namespace-data.service.spec.ts | 12 ++++++ .../kube-namespace-data.service.ts | 9 ++++- .../kube-node-data.service.spec.ts | 12 ++++++ .../domain-data/kube-node-data.service.ts | 9 +++-- .../domain-data/kube-pod-data.service.spec.ts | 31 ++++++++++++++ .../domain-data/kube-pod-data.service.ts | 12 ++++-- .../kube-service-data.service.spec.ts | 12 ++++++ .../domain-data/kube-service-data.service.ts | 5 ++- .../kube-endpoint-data.service.spec.ts | 12 ++++++ .../kube-endpoint-data.service.ts | 2 +- .../endpoint-data/kube-endpoint-eviction.ts | 16 ++++++++ .../kube-endpoint-registry.hook.spec.ts | 40 +++++++++++++++++++ .../endpoint-data/kube-helm-data.service.ts | 2 +- 17 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts index baa3d957b1..79a0a82aad 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts @@ -128,4 +128,16 @@ describe('KubeAnalysisDataService', () => { expect(svc.allReports([KUBE_GUID, otherGuid])()).toHaveLength(2); }); + + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = svc.refresh({ kubeGuid: `kube-${i}` }); + httpMock.expectOne(`/pp/v1/analysis/reports/kube-${i}`).flush('boom', { status: 500, statusText: 'Server Error' }); + await refreshing; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts index d6fe469a36..f1086ffc36 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts @@ -5,7 +5,7 @@ import { catchError, finalize, map, shareReplay, tap } from 'rxjs/operators'; import { KubeScoreReportHelper } from '../../kubernetes/services/kubescore-report.helper'; import { PopeyeReportHelper } from '../../kubernetes/services/popeye-report.helper'; -import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; +import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { AnalysisReport, StratosError } from '../endpoint-data/kube-types'; // Per-endpoint cache TTL — 60s mirrors the design doc's K8s-rate-limit @@ -68,6 +68,7 @@ export class KubeAnalysisDataService { evictKeys(this._byEndpoint, key => key === kubeGuid); evictKeys(this._byId, key => key.startsWith(`${kubeGuid}:`)); this._unavailable.update(curr => curr.includes(kubeGuid) ? curr.filter(g => g !== kubeGuid) : curr); + evictErrors(this._errors, kubeGuid); }); } @@ -264,7 +265,7 @@ export class KubeAnalysisDataService { } private addError(err: StratosError): void { - this._errors.update(curr => [...curr, err].slice(0, 50)); + this._errors.update(curr => [...curr, err].slice(-50)); } private markUnavailable(kubeGuid: string, err: unknown, title: string): void { diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts index 07648f174a..06aa45c405 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http import { Signal, computed, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; +import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { StratosError } from '../endpoint-data/kube-types'; @@ -46,6 +46,7 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { evictKeys(this._clusterItems, owned); evictKeys(this._namespaceItems, owned); evictKeys(this._workloadItems, owned); + evictErrors(this._errors, kubeGuid); }); } @@ -180,7 +181,7 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 50)); + this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.services.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.services.spec.ts index b58179a804..a48d83033b 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.services.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.services.spec.ts @@ -90,6 +90,19 @@ describe('KubeConfigMapDataService', () => { const errs = svc.errors()(); expect(errs.some(e => e.code === 'FETCH_ERROR')).toBe(true); }); + + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = svc.refresh({ kubeGuid: `kube-${i}` }); + httpMock.expectOne('/pp/v1/proxy/api/v1/configmaps?limit=500') + .flush('boom', { status: 500, statusText: 'Server Error' }); + await refreshing; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); }); describe('KubeSecretDataService', () => { diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts index 64bda6b233..fa20ac37c1 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts @@ -132,4 +132,16 @@ describe('KubeNamespaceDataService', () => { expect(result).toHaveLength(1); expect(result[0].kubeGuid).toBe(KUBE_GUID); }); + + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const direct = svc.fetchDirect(`kube-${i}`); + httpMock.expectOne(NS_URL).flush('boom', { status: 500, statusText: 'Server Error' }); + await direct; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts index f3e35eef16..107a4c8152 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeNamespace, StratosError } from '../endpoint-data/kube-types'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction, evictErrors } from '../endpoint-data/kube-endpoint-eviction'; // Per-resource domain service for k8s namespaces. Composes over // KubeEndpointDataRegistry to share the cluster-scoped namespace cache @@ -23,6 +24,12 @@ export class KubeNamespaceDataService { private readonly _errors = signal<StratosError[]>([]); + // The namespace list itself lives on the per-endpoint service, which the + // registry drops; only fetchDirect's errors are held here. + constructor() { + inject(KubeEndpointEviction).register(kubeGuid => evictErrors(this._errors, kubeGuid)); + } + // Returns a signal projecting the namespaces cached on the per-endpoint // service. Composing with `computed` keeps the projection reactive — // when load()/refresh() updates the underlying signal the consumer @@ -96,7 +103,7 @@ export class KubeNamespaceDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 50)); + this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts index 2d2712f92b..d44f551d56 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts @@ -88,4 +88,16 @@ describe('KubeNodeDataService', () => { expect(svc.nodesInCluster('kube-1')()).toHaveLength(1); expect(svc.nodesInCluster('kube-2')()).toHaveLength(2); }); + + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = svc.refresh(`kube-${i}`); + httpMock.expectOne(NODES_URL).flush('boom', { status: 500, statusText: 'Server Error' }); + await refreshing; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts index 29bd7a027a..d5b9d4d8c4 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeNode, StratosError } from '../endpoint-data/kube-types'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; -import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; +import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; // Per-resource domain service for k8s nodes. Same shape as // KubeNamespaceDataService — caches per-endpoint node lists, exposes @@ -26,7 +26,10 @@ export class KubeNodeDataService { private readonly _errors = signal<StratosError[]>([]); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => evictKeys(this._nodes, key => key === kubeGuid)); + inject(KubeEndpointEviction).register(kubeGuid => { + evictKeys(this._nodes, key => key === kubeGuid); + evictErrors(this._errors, kubeGuid); + }); } // Signal projecting the cached nodes for a given endpoint. Composed @@ -96,7 +99,7 @@ export class KubeNodeDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 50)); + this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts index 1f53834954..1dd92df34f 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts @@ -150,6 +150,37 @@ describe('KubePodDataService', () => { expect(errs[0].code).toBe('UNAUTHORIZED'); }); + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = svc.refresh({ kubeGuid: `kube-${i}` }); + httpMock.expectOne(CLUSTER_PODS_URL).flush('boom', { status: 500, statusText: 'Server Error' }); + await refreshing; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); + + // A failed scope's error goes once that scope loads; another scope's + // error on the same endpoint (e.g. a cluster-wide 403 under namespace-only + // RBAC) stays. + it('clears a scope error when that scope reloads successfully', async () => { + const cluster = svc.refresh({ kubeGuid: KUBE_GUID }); + httpMock.expectOne(CLUSTER_PODS_URL).flush('forbidden', { status: 403, statusText: 'Forbidden' }); + await cluster; + const failing = svc.refresh({ kubeGuid: KUBE_GUID, namespace: 'default' }); + httpMock.expectOne(NS_PODS_URL('default')).flush('boom', { status: 500, statusText: 'Server Error' }); + await failing; + expect(svc.errors()().map(e => e.title)).toEqual(['kube-pods-cluster', 'kube-pods-namespace']); + + const ok = svc.refresh({ kubeGuid: KUBE_GUID, namespace: 'default' }); + httpMock.expectOne(NS_PODS_URL('default')).flush({ [KUBE_GUID]: { items: [] } }); + await ok; + + expect(svc.errors()().map(e => e.title)).toEqual(['kube-pods-cluster']); + }); + it('dedupes concurrent reads on the same scope', async () => { // Two reads on the same cluster scope back-to-back should produce // exactly one HTTP request (the second is satisfied by shareReplay diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts index 2296af6239..54fc3e70d6 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts @@ -5,7 +5,7 @@ import { catchError, map, shareReplay } from 'rxjs/operators'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; -import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; +import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { KubePod, KubePodContainerStatus, StratosError } from '../endpoint-data/kube-types'; // Per-resource domain service for k8s pods. Mirrors KubeNamespaceDataService @@ -31,6 +31,7 @@ import { KubePod, KubePodContainerStatus, StratosError } from '../endpoint-data/ // - 401 / 403 mark the affected scope unavailable and surface a // StratosError on errors(). // - Other errors push a FETCH_ERROR onto errors(). +// - A later successful load of that scope clears both. const POD_TTL_MS = 60_000; const POD_FETCH_DEDUPE = new Map<string, Observable<KubePod[]>>(); @@ -158,6 +159,7 @@ export class KubePodDataService { evictKeys(this._byScope, owned); [...POD_FETCH_DEDUPE.keys()].filter(owned).forEach(key => POD_FETCH_DEDUPE.delete(key)); this._unavailable.update(curr => [...curr].some(owned) ? new Set([...curr].filter(key => !owned(key))) : curr); + evictErrors(this._errors, kubeGuid, a => a === kubeGuid || owned(a)); } private invalidate(key: string): void { @@ -270,9 +272,10 @@ export class KubePodDataService { code, title, detail: (err as Error)?.message ?? String(err), - affected: [kubeGuid], + // The scope key lets a later successful load of this scope clear it. + affected: [kubeGuid, key], }; - this._errors.update(curr => [...curr, next].slice(0, 50)); + this._errors.update(curr => [...curr, next].slice(-50)); } private clearUnavailable(key: string): void { @@ -282,6 +285,9 @@ export class KubePodDataService { next.delete(key); return next; }); + this._errors.update(curr => curr.some(e => e.affected?.includes(key)) + ? curr.filter(e => !e.affected?.includes(key)) + : curr); } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts index b6bd9a9dfb..0f54980be5 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts @@ -78,6 +78,18 @@ describe('KubeServiceDataService', () => { expect(errs.some(e => e.code === 'UNAUTHORIZED' && e.title === 'kube-services')).toBe(true); }); + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = svc.refresh({ kubeGuid: `kube-${i}`, namespace: 'default' }); + httpMock.expectOne(NS_SERVICES_URL).flush('boom', { status: 500, statusText: 'Server Error' }); + await refreshing; + } + const errs = svc.errors()(); + expect(errs).toHaveLength(50); + expect(errs[0].affected?.[0]).toBe('kube-1'); + expect(errs[49].affected?.[0]).toBe('kube-50'); + }); + it('keeps namespace caches isolated across (kubeGuid, namespace) tuples', async () => { const r1 = svc.refresh({ kubeGuid: 'kube-1', namespace: 'default' }); httpMock.expectOne('/pp/v1/proxy/api/v1/namespaces/default/services?limit=500').flush({ diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts index 27f97aa4ae..7b1b9f9c6d 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; import { KubeEndpoint, KubeService, StratosError } from '../endpoint-data/kube-types'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; -import { KubeEndpointEviction, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; +import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; // Per-resource domain service for k8s Services AND Endpoints. Folds the // `kubernetes-endpoints` page's k8s Endpoints fetch in here because the @@ -43,6 +43,7 @@ export class KubeServiceDataService { evictKeys(this._namespaceServices, owned); evictKeys(this._workloadServices, owned); evictKeys(this._clusterEndpoints, owned); + evictErrors(this._errors, kubeGuid); }); } @@ -186,7 +187,7 @@ export class KubeServiceDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 50)); + this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.spec.ts index af6fe85bde..80416459e6 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.spec.ts @@ -157,4 +157,16 @@ describe('KubeEndpointDataService', () => { expect(service.kubeVersion()).toBe('1.29'); }); + + it('keeps the newest 50 errors and drops the oldest', async () => { + for (let i = 0; i <= 50; i++) { + const refreshing = service.refresh('version'); + httpMock.expectOne(VERSION_URL).flush('boom', { status: 500, statusText: `err-${i}` }); + await refreshing; + } + const errs = service.errors(); + expect(errs).toHaveLength(50); + expect(errs[0].detail).toMatch(/ err-1$/); + expect(errs[49].detail).toMatch(/ err-50$/); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.ts index c7f3cf4a38..2fcd46ed46 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-data.service.ts @@ -243,7 +243,7 @@ export class KubeEndpointDataService { } private addError(err: StratosError): void { - this._errors.update(curr => [...curr, err].slice(0, 50)); + this._errors.update(curr => [...curr, err].slice(-50)); } private markUnavailable(field: string, err: unknown, title: string): void { diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts index 82c95181d6..abfb683982 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts @@ -1,5 +1,7 @@ import { Injectable, WritableSignal } from '@angular/core'; +import { StratosError } from './kube-types'; + // Root fan-out for "this kubernetes endpoint is gone". The root domain // caches (pods, nodes, services, generic resources, analysis, helm) keep // data fetched with the endpoint's credentials for the whole session, so @@ -31,3 +33,17 @@ export function evictKeys<V>(cache: WritableSignal<Map<string, V>>, owned: (key: return next; }); } + +// Drop the errors whose `affected` entries all belong to the departed +// endpoint (an error naming another endpoint too stays). `owned` defaults +// to the guid itself; caches that also tag a scope key pass their own. +export function evictErrors( + errors: WritableSignal<StratosError[]>, + kubeGuid: string, + owned: (affected: string) => boolean = a => a === kubeGuid, +): void { + errors.update(curr => { + const next = curr.filter(e => !(e.affected?.length && e.affected.every(owned))); + return next.length === curr.length ? curr : next; + }); +} diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts index 69d2324440..1341413568 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-registry.hook.spec.ts @@ -13,6 +13,7 @@ import { HelmReleaseDataService } from '../../kubernetes/workloads/release/helm- import { HelmReleaseGraph } from '../../kubernetes/workloads/workload.types'; import { KubeAnalysisDataService } from '../domain-data/kube-analysis-data.service'; import { KubeSecretDataService } from '../domain-data/kube-generic-resource-data.services'; +import { KubeNamespaceDataService } from '../domain-data/kube-namespace-data.service'; import { KubeNodeDataService } from '../domain-data/kube-node-data.service'; import { KubePodDataService } from '../domain-data/kube-pod-data.service'; import { KubeServiceDataService } from '../domain-data/kube-service-data.service'; @@ -297,6 +298,45 @@ describe('KubeEndpointRegistryHook domain cache eviction', () => { expect(helm.allReleases()().map(r => r.endpointId)).toEqual([B]); }); + it('drops errors recorded against the departed endpoint only', async () => { + const pods = TestBed.inject(KubePodDataService); + const nodes = TestBed.inject(KubeNodeDataService); + const services = TestBed.inject(KubeServiceDataService); + const secrets = TestBed.inject(KubeSecretDataService); + const analysis = TestBed.inject(KubeAnalysisDataService); + const namespaces = TestBed.inject(KubeNamespaceDataService); + const fail = (path: string, guid: string) => { + const reqs = httpMock.match(r => r.url.startsWith(path) && r.headers.get('x-cap-cnsi-list') === guid); + expect(reqs).toHaveLength(1); + reqs[0].flush('boom', { status: 500, statusText: 'Server Error' }); + }; + for (const guid of [A, B]) { + pods.podsInCluster(guid); + fail('/pp/v1/proxy/api/v1/pods', guid); + const node = nodes.refresh(guid); + fail('/pp/v1/proxy/api/v1/nodes', guid); + await node; + const service = services.refresh({ kubeGuid: guid, namespace: 'ns' }); + fail('/pp/v1/proxy/api/v1/namespaces/ns/services', guid); + await service; + const secret = secrets.refresh({ kubeGuid: guid }); + fail('/pp/v1/proxy/api/v1/secrets', guid); + await secret; + const report = analysis.refresh({ kubeGuid: guid }); + httpMock.expectOne(`/pp/v1/analysis/reports/${guid}`).flush('boom', { status: 500, statusText: 'Server Error' }); + await report; + const direct = namespaces.fetchDirect(guid); + fail('/pp/v1/proxy/api/v1/namespaces?', guid); + await direct; + } + + disconnectA(); + + for (const cache of [pods, nodes, services, secrets, analysis, namespaces]) { + expect(cache.errors()().map(e => e.affected?.[0])).toEqual([B]); + } + }); + it('blanks helm release detail surfaces of the departed endpoint', () => { const release = TestBed.inject(HelmReleaseDataService); const graph = { nodes: [], links: [] } as unknown as HelmReleaseGraph; diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts index dde974326b..439d566f40 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts @@ -295,7 +295,7 @@ export class KubeHelmDataService { title, detail: this.unwrapErrorMessage(err), }; - target.update(curr => [...curr, next].slice(0, 50)); + target.update(curr => [...curr, next].slice(-50)); } private errorMessage(err: unknown, fallback: string): string { From dbc25d38f886f3f25aec618ef84b795d9b92a423 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:01:29 -0700 Subject: [PATCH 110/121] fix(endpoints): keep recent apps in step with apps A full apps drain (loadApps/refreshApps) replaced the app list but left the recent apps list as load() fetched it, so the home card kept deleted or renamed apps and old states after a refresh. Rebuild it from the drain with the backend's rule: newest updatedAt first, capped at ten. removeApp only filtered the recent list, so it shrank below ten after a delete. Refill the freed slot from the full list when one is loaded. --- .../endpoint-data.service.spec.ts | 34 +++++++++++++++++++ .../endpoint-data/endpoint-data.service.ts | 22 ++++++++---- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts index 0a05e4c6d3..11a6da55cc 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts @@ -760,6 +760,40 @@ describe('EndpointDataService', () => { service.updateApp('app-unknown', { state: 'STOPPED' }); expect(service.recentApps().map(a => a.guid)).toEqual(['app-new']); }); + + // The full drain is the newer truth: app-old was deleted, app-new was + // renamed and stopped, app-newest was created elsewhere. + const newest = { guid: 'app-newest', name: 'newest', state: 'STARTED', updatedAt: '2026-04-01T00:00:00Z' } as any; + const changed = [{ ...newer, name: 'renamed', state: 'STOPPED' }, newest]; + const recentOf = () => service.recentApps().map(a => `${a.guid}:${a.name}:${a.state}`); + + it('refreshApps rebuilds recentApps from the new app list', async () => { + await warmRecent([newer, older]); + service.refreshApps().subscribe(); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: changed, pagination: { totalResults: 2, totalPages: 1 } }); + await Promise.resolve(); + expect(recentOf()).toEqual(['app-newest:newest:STARTED', 'app-new:renamed:STOPPED']); + }); + + it('loadApps rebuilds recentApps from the new app list', async () => { + await warmRecent([newer, older]); + service.loadApps().subscribe(); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: changed, pagination: { totalResults: 2, totalPages: 1 } }); + await Promise.resolve(); + expect(recentOf()).toEqual(['app-newest:newest:STARTED', 'app-new:renamed:STOPPED']); + }); + + it('removeApp refills recentApps from the full list', async () => { + const eleven = Array.from({ length: 11 }, (_, i) => + ({ guid: `app-${i}`, name: `${i}`, state: 'STARTED', updatedAt: `2026-01-${String(20 - i).padStart(2, '0')}T00:00:00Z` })); + await warmRecent(eleven.slice(0, 10)); + service.refreshApps().subscribe(); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: eleven, pagination: { totalResults: 11, totalPages: 1 } }); + await Promise.resolve(); + service.removeApp('app-0'); + expect(service.recentApps().map(a => a.guid)) + .toEqual(eleven.slice(1).map(a => a.guid)); + }); }); it('removeServiceInstance / addServiceInstance / updateServiceInstance work + count tracked', () => { diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts index 8a1fc59b5b..364cc719d8 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts @@ -23,6 +23,12 @@ import { extractHttpErrorMessage } from '@stratosui/store'; // Matches the per_page of the backend's ?return=recent apps call. const RECENT_APPS_COUNT = 10; +// Mirrors the backend's ?return=recent: newest updatedAt first, capped. +function newestApps(apps: StApp[]): StApp[] { + const updated = (a: StApp) => Date.parse(a.updatedAt) || 0; + return [...apps].sort((a, b) => updated(b) - updated(a)).slice(0, RECENT_APPS_COUNT); +} + export class EndpointDataService { // Counts + recent apps populated by load() (home card fast path). private readonly _orgCount = signal<number>(0); @@ -351,6 +357,8 @@ export class EndpointDataService { // Stamp on success only — see loadOrgs. tap(resp => { this._apps.set(resp.resources); + // The full list is newer than the recent list load() fetched. + this._recentApps.set(newestApps(resp.resources)); this._appCount.set(resp.totalResults); this._appsLastFetched.set(new Date()); this._appsStale.set(false); @@ -458,7 +466,11 @@ export class EndpointDataService { // recent list shows a create/update/delete without another load(). removeApp(guid: string): void { this._apps.update(curr => curr.filter(a => (a as { guid?: string }).guid !== guid)); - this._recentApps.update(curr => curr.filter(a => a.guid !== guid)); + // Refill from the full list (when loaded) so the next most recent app + // takes the freed slot; entries already shown keep their own record. + const recent = this._recentApps().filter(a => a.guid !== guid); + const shown = new Set(recent.map(a => a.guid)); + this._recentApps.set(newestApps([...recent, ...this._apps().filter(a => !shown.has(a.guid))])); this._appCount.update(n => Math.max(0, n - 1)); } addApp(app: StApp): void { @@ -482,12 +494,8 @@ export class EndpointDataService { } } - // Mirrors the backend's ?return=recent: newest updatedAt first, capped. private upsertRecentApp(app: StApp): void { - const updated = (a: StApp) => Date.parse(a.updatedAt) || 0; - this._recentApps.update(curr => [app, ...curr.filter(a => a.guid !== app.guid)] - .sort((a, b) => updated(b) - updated(a)) - .slice(0, RECENT_APPS_COUNT)); + this._recentApps.update(curr => newestApps([app, ...curr.filter(a => a.guid !== app.guid)])); } removeServiceInstance(guid: string): void { @@ -563,6 +571,8 @@ export class EndpointDataService { // Stamp on success only — see loadOrgs. tap(resp => { this._apps.set(resp.resources); + // The full list is newer than the recent list load() fetched. + this._recentApps.set(newestApps(resp.resources)); this._appCount.set(resp.totalResults); this._appsLastFetched.set(new Date()); this._appsStale.set(false); From f9e8ad3fe12fab8d4b8eca710ddca1d3d1eaa571 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:02:30 -0700 Subject: [PATCH 111/121] refactor(cf): drop the recent apps self-load path The recent apps card could source its own apps from the endpoint data registry when no allApps$ was given. Every caller passes allApps$, so that branch never ran; it acquired registry data it never released and bridged signals on the component injector. Remove it. --- .../card-cf-recent-apps.component.ts | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) 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 6c3d342020..1daa1fd4e1 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 @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Injector, OnInit, Output, Signal, ChangeDetectionStrategy, computed, inject, input } from '@angular/core'; +import { Component, EventEmitter, OnInit, Output, ChangeDetectionStrategy, computed, input } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { AsyncPipe, CommonModule } from '@angular/common'; import { combineLatest, Observable, of } from 'rxjs'; @@ -6,8 +6,6 @@ import { filter, map, startWith } from 'rxjs/operators'; import { APIResource } from '@stratosui/store'; import { IApp } from '../../../cf-api.types'; -import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; -import { stAppToAPIResource } from '../../../services/endpoint-data/st-app-adapter'; import { appDataSort } from '../../cf/services/cloud-foundry-endpoint.service'; import { PollingIndicatorComponent, @@ -65,13 +63,9 @@ export class CardCfRecentAppsComponent implements OnInit { hasEntities$!: Observable<boolean>; show$!: Observable<boolean>; - // The loading$ input, or the endpoint data's loading signal when the - // card sources its own apps. + // The loading$ input; not loading when none is given. appsLoading$!: Observable<boolean>; - private readonly registry = inject(EndpointDataRegistry); - private readonly injector = inject(Injector); - private readonly maxRows$ = toObservable(this.maxRows); ngOnInit() { @@ -81,28 +75,10 @@ export class CardCfRecentAppsComponent implements OnInit { return; } this.canRefresh = this.refresh.observers.length > 0; - let allApps$ = this.allApps$(); + // Every live caller passes allApps$ (home, CF, org and space summaries) + const allApps$ = this.allApps$() ?? of([]); this.appsLoading$ = this.loading$() ?? of(false); - if (!allApps$) { - // Signal-native source — EndpointDataService.apps is populated by the - // registry-managed loadDetails() call. The home card path warms via - // load() (recent apps fast path) and then loadDetails() chains; the - // CF/org/space summary acquire path goes straight to loadDetails(). - // Either way we read the same signal — no separate ngrx fetch. - // strict: a card without allApps$ is always given its endpoint. - const endpointData = this.registry.acquire(this.endpoint()!); - const appResources: Signal<APIResource<IApp>[]> = computed( - () => endpointData.apps().map(stAppToAPIResource), - ); - allApps$ = toObservable(appResources, { injector: this.injector }); - this.appsLoading$ = toObservable(endpointData.isLoadingDetails, { injector: this.injector }); - this.hasEntities$ = toObservable( - computed(() => endpointData.apps().length > 0), - { injector: this.injector }, - ); - } else { - this.hasEntities$ = of(true); - } + this.hasEntities$ = of(true); // Stats are loaded by each row (CompactAppCardComponent), once per // started app; the registry shares the request between rows. From 60027eef9c8de68d9b3748883a8e49dae1c38993 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:02:47 -0700 Subject: [PATCH 112/121] docs(cf): correct the endpoint data load() note load() never started loadDetails(); detail views load the full lists themselves. Say so. --- .../src/services/endpoint-data/endpoint-data.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts index 364cc719d8..9cc443251a 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts @@ -181,8 +181,8 @@ export class EndpointDataService { ) {} // load() populates counts + the 10 most-recent apps via fast per_page=1/10 - // backend calls. Fires loadDetails() in the background on completion so - // the full orgs/apps/spaces arrays are populated for detail views. + // backend calls. It does not load the full orgs/apps/spaces arrays; + // detail views call loadDetails() (or a loadX()) for those. load(): Observable<void> { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'load' }); // Warm-cache short-circuit: signals already populated, no network needed. From c74b7c98e505cd1efcea65d5a3ba0799daaa3904 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:03:00 -0700 Subject: [PATCH 113/121] fix(cf): end broker space loads on swap/destroy The service broker card and the broker table cell started a space load in an effect and never ended it, so the subscription stayed open after the broker's space changed or the component was destroyed. The effect now keys on the space by value, ends the load in its cleanup, and runs the acquire untracked so the load's own signals do not re-run it. --- ...able-cell-service-broker.component.spec.ts | 64 +++++++++++++++++-- .../table-cell-service-broker.component.ts | 39 ++++++----- .../service-broker-card.component.spec.ts | 42 +++++++++++- .../service-broker-card.component.ts | 43 +++++++------ 4 files changed, 141 insertions(+), 47 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts index 62ef563dce..b9d2e1d323 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection, signal } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { provideRouter } from '@angular/router'; -import { Observable, Subject } from 'rxjs'; +import { Observable, Subject, of } from 'rxjs'; import { describe, it, expect, beforeEach } from 'vitest'; import { TableCellServiceBrokerComponent, TableCellServiceBrokerComponentMode } from './table-cell-service-broker.component'; @@ -15,11 +15,23 @@ import { StServiceBroker, StServiceOffering, StSpace } from '../../../../../serv class SpaceDataRegistryStub { readonly space = signal<StSpace | null>(null); readonly loaded = new Subject<void>(); + // One entry per load() subscription; `closed` flips when it ends. + loads: { key: string, closed: boolean }[] = []; - acquire(_cnsi: string, _spaceGuid: string) { + acquire(cnsi: string, spaceGuid: string) { + const key = `${cnsi}/${spaceGuid}`; return { space: this.space.asReadonly(), - load: (): Observable<void> => this.loaded, + // Reads `space` the way the real load() checks its cache. + load: (): Observable<void> => this.space() ? of(undefined) : new Observable<void>(subscriber => { + const load = { key, closed: false }; + this.loads.push(load); + const inner = this.loaded.subscribe(subscriber); + return () => { + load.closed = true; + inner.unsubscribe(); + }; + }), }; } } @@ -71,12 +83,12 @@ describe('TableCellServiceBrokerComponent', () => { component = fixture.componentInstance; }); - it('should create', () => { - fixture.detectChanges(); + it('should create', async () => { + await fixture.whenStable(); expect(component).toBeTruthy(); }); - it('resolves broker via ServiceCatalogDataService when row is assigned', () => { + it('resolves broker via ServiceCatalogDataService when row is assigned', async () => { component.config = { mode: TableCellServiceBrokerComponentMode.NAME }; const offering: StServiceOffering = { guid: 'svc-1', @@ -86,7 +98,7 @@ describe('TableCellServiceBrokerComponent', () => { createdAt: '2026-01-01T00:00:00Z', }; component.row = offering; - fixture.detectChanges(); + await fixture.whenStable(); expect(lastBrokerLookup).toEqual({ cnsiGuid: 'cnsi-1', brokerGuid: 'broker-7' }); expect(component.broker()?.name).toBe('global-broker'); @@ -127,8 +139,46 @@ describe('TableCellServiceBrokerComponent', () => { spaceStub.loaded.next(); await fixture.whenStable(); + // The space landing must not start a second load. + expect(spaceStub.loads).toEqual([{ key: 'cnsi-1/space-1', closed: false }]); const link = host.querySelector('a'); expect(link?.textContent).toBe('broker-space'); expect(link?.getAttribute('href')).toBe('/cloud-foundry/cnsi-1/organizations/org-1/spaces/space-1/summary'); }); + + it('ends the previous space load when the space swaps, and the current one on destroy', async () => { + const inSpace = (guid: string): StServiceBroker => ({ + guid: 'broker-7', + name: 'space-broker', + url: 'https://b.example', + space: { guid }, + labels: {}, + annotations: {}, + cnsiGuid: 'cnsi-1', + createdAt: '', + updatedAt: '', + }); + lateBroker = signal<StServiceBroker | null>(inSpace('space-a')); + component.config = { mode: TableCellServiceBrokerComponentMode.SCOPE }; + component.row = { + guid: 'svc-1', + cnsiGuid: 'cnsi-1', + name: 'svc', + broker: { guid: 'broker-7' }, + createdAt: '2026-01-01T00:00:00Z', + }; + await fixture.whenStable(); + expect(spaceStub.loads).toEqual([{ key: 'cnsi-1/space-a', closed: false }]); + + // Swap while the first load is still pending. + lateBroker.set(inSpace('space-b')); + await fixture.whenStable(); + expect(spaceStub.loads).toEqual([ + { key: 'cnsi-1/space-a', closed: true }, + { key: 'cnsi-1/space-b', closed: false }, + ]); + + fixture.destroy(); + expect(spaceStub.loads.every(load => load.closed)).toBe(true); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts index 244fb26895..287fb8b2e9 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/cf-service-card/table-cell-service-broker/table-cell-service-broker.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Injector, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal, untracked } from '@angular/core'; import { RouterModule } from '@angular/router'; import { TableCellCustom } from '@stratosui/core'; @@ -44,12 +44,19 @@ export class TableCellServiceBrokerComponent extends private readonly _brokerSource = signal<SignalSource<StServiceBroker | null> | null>(null); // A signal so `spaceLink` re-evaluates when the effect swaps it in. private readonly _spaceData = signal<SpaceDataService | null>(null); - private _spaceKey: { cnsi: string, guid: string } | null = null; readonly broker: Signal<StServiceBroker | null> = computed( () => this._brokerSource()?.value() ?? null, ); + // Compared by value so a broker re-fetch with the same space does not + // re-run the acquire effect. + private readonly _spaceKey = computed(() => { + const broker = this.broker(); + const guid = broker?.space?.guid; + return guid && broker ? { cnsi: broker.cnsiGuid, guid } : null; + }, { equal: (a, b) => a?.cnsi === b?.cnsi && a?.guid === b?.guid }); + readonly spaceLink: Signal<SpaceLink | null> = computed(() => { const space = this._spaceData()?.space(); const broker = this.broker(); @@ -83,25 +90,23 @@ export class TableCellServiceBrokerComponent extends } ngOnInit(): void { + // The cleanup ends the space load when the space swaps and when the + // cell is destroyed. runInInjectionContext(this.injector, () => { - effect(() => { - const broker = this.broker(); - const targetGuid = broker?.space?.guid ?? null; - const targetKey = targetGuid && broker - ? { cnsi: broker.cnsiGuid, guid: targetGuid } - : null; - const curr = this._spaceKey; - if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; - if (curr) { + effect(onCleanup => { + const key = this._spaceKey(); + if (!key) { this._spaceData.set(null); - this._spaceKey = null; + return; } - if (targetKey) { - const spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - spaceData.load().subscribe(); + // load() reads the service's own signals; untracked so the space + // landing does not re-run this effect and end the load. + untracked(() => { + const spaceData = this.spaceRegistry.acquire(key.cnsi, key.guid); + const load = spaceData.load().subscribe(); + onCleanup(() => load.unsubscribe()); this._spaceData.set(spaceData); - this._spaceKey = targetKey; - } + }); }); }); } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts index 471105b0b7..3794124ec8 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.spec.ts @@ -3,7 +3,7 @@ import { importProvidersFrom, provideZonelessChangeDetection, signal } from '@an import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; -import { Observable, Subject } from 'rxjs'; +import { Observable, Subject, of } from 'rxjs'; import { describe, it, expect, beforeEach } from 'vitest'; import { STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; @@ -46,12 +46,24 @@ class SpaceDataRegistryStub { readonly space = signal<StSpace | null>(null); readonly loaded = new Subject<void>(); acquired: string[] = []; + // One entry per load() subscription; `closed` flips when it ends. + loads: { key: string, closed: boolean }[] = []; acquire(cnsi: string, spaceGuid: string) { - this.acquired.push(`${cnsi}/${spaceGuid}`); + const key = `${cnsi}/${spaceGuid}`; + this.acquired.push(key); return { space: this.space.asReadonly(), - load: (): Observable<void> => this.loaded, + // Reads `space` the way the real load() checks its cache. + load: (): Observable<void> => this.space() ? of(undefined) : new Observable<void>(subscriber => { + const load = { key, closed: false }; + this.loads.push(load); + const inner = this.loaded.subscribe(subscriber); + return () => { + load.closed = true; + inner.unsubscribe(); + }; + }), }; } @@ -174,10 +186,34 @@ describe('ServiceBrokerCardComponent', () => { } as StSpace); await fixture.whenStable(); + // The space landing must not start a second load. + expect(spaceStub.loads).toEqual([{ key: 'test-cf-guid/space-guid', closed: false }]); const link = (fixture.nativeElement as HTMLElement).querySelector('a[href*="/spaces/"]'); expect(link?.textContent).toBe('broker-space'); expect(link?.getAttribute('href')).toBe( '/cloud-foundry/test-cf-guid/organizations/org-guid/spaces/space-guid/summary', ); }); + + it('ends the previous space load when the space swaps, and the current one on destroy', async () => { + const inSpace = (guid: string): StServiceBroker => ({ ...catalogStub.brokerResponse!, space: { guid } }); + catalogStub.lateBroker = signal<StServiceBroker | null>(inSpace('space-a')); + fixture = TestBed.createComponent(ServiceBrokerCardComponent); + component = fixture.componentInstance; + component.cfGuid = 'test-cf-guid'; + component.brokerGuid = catalogStub.brokerResponse!.guid; + await fixture.whenStable(); + expect(spaceStub.loads).toEqual([{ key: 'test-cf-guid/space-a', closed: false }]); + + // Swap while the first load is still pending. + catalogStub.lateBroker.set(inSpace('space-b')); + await fixture.whenStable(); + expect(spaceStub.loads).toEqual([ + { key: 'test-cf-guid/space-a', closed: true }, + { key: 'test-cf-guid/space-b', closed: false }, + ]); + + fixture.destroy(); + expect(spaceStub.loads.every(load => load.closed)).toBe(true); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts index 1591d30a62..f4e1bb4c45 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/service-broker-card/service-broker-card.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Injector, Input, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Injector, Input, OnInit, Signal, computed, effect, inject, runInInjectionContext, signal, untracked } from '@angular/core'; import { RouterModule } from '@angular/router'; import { @@ -63,12 +63,19 @@ export class ServiceBrokerCardComponent implements OnInit { // shared per (cnsi, spaceGuid) through the registry. A signal so // `spaceLink` re-evaluates when the effect swaps it in. private readonly _spaceData = signal<SpaceDataService | null>(null); - private _spaceKey: { cnsi: string, guid: string } | null = null; readonly broker: Signal<StServiceBroker | null> = computed( () => this._brokerSource()?.value() ?? null, ); + // Compared by value so a broker re-fetch with the same space does not + // re-run the acquire effect. + private readonly _spaceKey = computed(() => { + const guid = this.broker()?.space?.guid; + const cnsi = this._cfGuid(); + return guid && cnsi ? { cnsi, guid } : null; + }, { equal: (a, b) => a?.cnsi === b?.cnsi && a?.guid === b?.guid }); + readonly spaceLink: Signal<BrokerSpaceLink | null> = computed(() => { const cfGuid = this._cfGuid(); const space = this._spaceData()?.space(); @@ -102,28 +109,24 @@ export class ServiceBrokerCardComponent implements OnInit { } ngOnInit(): void { - // Acquire SpaceDataService when broker.space.guid changes. - // Effect re-runs on broker swap (new SignalSource) and on broker - // value flip (HTTP landed) — same shape regardless of which triggered - // the change. + // Acquire SpaceDataService when broker.space.guid changes. The + // cleanup ends the load when the space swaps and when the card is + // destroyed. runInInjectionContext(this.injector, () => { - effect(() => { - const broker = this.broker(); - const cfGuid = this._cfGuid(); - const targetGuid = broker?.space?.guid ?? null; - const targetKey = targetGuid && cfGuid ? { cnsi: cfGuid, guid: targetGuid } : null; - const curr = this._spaceKey; - if (curr?.cnsi === targetKey?.cnsi && curr?.guid === targetKey?.guid) return; - if (curr) { + effect(onCleanup => { + const key = this._spaceKey(); + if (!key) { this._spaceData.set(null); - this._spaceKey = null; + return; } - if (targetKey) { - const spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - spaceData.load().subscribe(); + // load() reads the service's own signals; untracked so the space + // landing does not re-run this effect and end the load. + untracked(() => { + const spaceData = this.spaceRegistry.acquire(key.cnsi, key.guid); + const load = spaceData.load().subscribe(); + onCleanup(() => load.unsubscribe()); this._spaceData.set(spaceData); - this._spaceKey = targetKey; - } + }); }); }); } From 611472ff161bb90329dc72ef35a0d14ab3a09c7f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:08:42 -0700 Subject: [PATCH 114/121] fix(kubernetes): drop responses after eviction Eviction cleared the root kube caches but not the requests already on the wire, so a response landing after a disconnect wrote the old credentials' data (or error) back. The eviction bus now keeps a per-endpoint epoch; pods, analysis, node, service and generic resource loads check it before writing and drop a late response. namespacesForEndpoint held the per-endpoint service it got at call time; after the endpoint left and came back, a page kept reading the orphaned instance. The projection now looks the service up again after each eviction. --- .../kube-analysis-data.service.spec.ts | 48 ++++++++++++++ .../domain-data/kube-analysis-data.service.ts | 34 +++++++--- .../kube-generic-resource-data.base.spec.ts | 65 ++++++++++++++++++- .../kube-generic-resource-data.base.ts | 11 +++- .../kube-namespace-data.service.spec.ts | 17 +++++ .../kube-namespace-data.service.ts | 16 +++-- .../kube-node-data.service.spec.ts | 37 +++++++++++ .../domain-data/kube-node-data.service.ts | 12 +++- .../domain-data/kube-pod-data.service.spec.ts | 34 ++++++++++ .../domain-data/kube-pod-data.service.ts | 54 ++++++--------- .../kube-service-data.service.spec.ts | 39 +++++++++++ .../domain-data/kube-service-data.service.ts | 13 +++- .../endpoint-data/kube-endpoint-eviction.ts | 21 +++++- 13 files changed, 342 insertions(+), 59 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts index 79a0a82aad..0f7f1bbcde 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { firstValueFrom } from 'rxjs'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubeAnalysisDataService } from './kube-analysis-data.service'; const KUBE_GUID = 'kube-1'; @@ -140,4 +141,51 @@ describe('KubeAnalysisDataService', () => { expect(errs[0].affected?.[0]).toBe('kube-1'); expect(errs[49].affected?.[0]).toBe('kube-50'); }); + + // A response already on the wire when its endpoint departs carries the + // old credentials' data; it must not land after the eviction. + describe('eviction while a load is in flight', () => { + const OTHER = 'kube-2'; + const report = (id: string) => ({ id, name: id, type: 'popeye', status: 'completed' }); + + it('drops the departed endpoint\'s late list and keeps the other\'s', async () => { + const a = firstValueFrom(svc.loadReports(KUBE_GUID)); + const b = firstValueFrom(svc.loadReports(OTHER)); + const reqA = httpMock.expectOne(REPORTS_URL); + const reqB = httpMock.expectOne(`/pp/v1/analysis/reports/${OTHER}`); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush([report('r1')]); + reqB.flush([report('r2')]); + await Promise.all([a, b]); + + expect(svc.reportsForEndpoint(KUBE_GUID)()).toEqual([]); + expect(svc.reportsForEndpoint(OTHER)()).toHaveLength(1); + }); + + it('drops the departed endpoint\'s late list failure', async () => { + const a = firstValueFrom(svc.loadReports(KUBE_GUID)); + const reqA = httpMock.expectOne(REPORTS_URL); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush('boom', { status: 500, statusText: 'Server Error' }); + await a; + + expect(svc.unavailable()).toEqual([]); + expect(svc.errors()()).toEqual([]); + }); + + it('drops the departed endpoint\'s late report detail', async () => { + const a = firstValueFrom(svc.reportById(KUBE_GUID, 'r1')); + const reqA = httpMock.expectOne(`${REPORTS_URL}/r1`); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush(report('r1')); + await a; + + // A cache hit would answer without a request; a cold cache asks again. + svc.reportById(KUBE_GUID, 'r1').subscribe(); + httpMock.expectOne(`${REPORTS_URL}/r1`).flush(report('r1')); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts index f1086ffc36..3955a4c8c2 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts @@ -49,6 +49,7 @@ interface ReportByIdCache { @Injectable({ providedIn: 'root' }) export class KubeAnalysisDataService { private readonly http = inject(HttpClient); + private readonly eviction = inject(KubeEndpointEviction); // Per-endpoint list cache, keyed by kubeGuid. Stored as a Map inside a // signal so consumers projecting `reportsForEndpoint(...)` recompute on @@ -64,7 +65,7 @@ export class KubeAnalysisDataService { readonly unavailable: Signal<string[]> = this._unavailable.asReadonly(); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => { + this.eviction.register(kubeGuid => { evictKeys(this._byEndpoint, key => key === kubeGuid); evictKeys(this._byId, key => key.startsWith(`${kubeGuid}:`)); this._unavailable.update(curr => curr.includes(kubeGuid) ? curr.filter(g => g !== kubeGuid) : curr); @@ -138,9 +139,13 @@ export class KubeAnalysisDataService { private fetchReports(kubeGuid: string): Observable<AnalysisReport[]> { const url = `/pp/v1/analysis/reports/${kubeGuid}`; + // A response that lands after the endpoint was evicted carries the + // old credentials' data, so it is dropped, success or failure. + const live = this.eviction.current(kubeGuid); const req = defer(() => this.http.get<AnalysisReport[]>(url)).pipe( map(items => Array.isArray(items) ? items : []), tap(items => { + if (!live()) return; this._byEndpoint.update(curr => { const next = new Map(curr); next.set(kubeGuid, { reports: items, fetchedAt: Date.now() }); @@ -149,6 +154,7 @@ export class KubeAnalysisDataService { this.clearUnavailable(kubeGuid); }), catchError((err: HttpErrorResponse | unknown) => { + if (!live()) return of([] as AnalysisReport[]); this.markUnavailable(kubeGuid, err, 'analysis-reports'); // Cache the empty result so consumers don't loop on errors; // the next refresh() call clears the cache. @@ -162,7 +168,9 @@ export class KubeAnalysisDataService { finalize(() => { // Drop the in-flight handle once settled so a future stale-cache // request triggers a fresh fetch rather than re-subscribing the - // completed observable. + // completed observable. After an eviction the entry, if any, is + // a newer load's. + if (!live()) return; this._byEndpoint.update(curr => { const entry = curr.get(kubeGuid); if (!entry?.inFlight) return curr; @@ -187,12 +195,15 @@ export class KubeAnalysisDataService { private fetchReportById(kubeGuid: string, id: string): Observable<AnalysisReport> { const key = `${kubeGuid}:${id}`; const url = `/pp/v1/analysis/reports/${kubeGuid}/${id}`; + // Same eviction guard as fetchReports. + const live = this.eviction.current(kubeGuid); const req = defer(() => this.http.get<AnalysisReport>(url)).pipe( map(report => { this.processReport(report); return report; }), tap(report => { + if (!live()) return; this._byId.update(curr => { const next = new Map(curr); next.set(key, { report, fetchedAt: Date.now() }); @@ -202,20 +213,23 @@ export class KubeAnalysisDataService { catchError((err: HttpErrorResponse | unknown) => { const status = (err as HttpErrorResponse)?.status; const code: StratosError['code'] = status === 401 || status === 403 ? 'UNAUTHORIZED' : 'FETCH_ERROR'; - this.addError({ - scope: 'envelope', - code, - title: 'analysis-report-detail', - detail: (err as Error)?.message ?? String(err), - guid: id, - affected: [kubeGuid], - }); + if (live()) { + this.addError({ + scope: 'envelope', + code, + title: 'analysis-report-detail', + detail: (err as Error)?.message ?? String(err), + guid: id, + affected: [kubeGuid], + }); + } // Re-throw so the consumer's catchError path runs (parity with // the legacy `getByID` which surfaced fetch errors to the // detail component). throw err; }), finalize(() => { + if (!live()) return; this._byId.update(curr => { const entry = curr.get(key); if (!entry?.inFlight) return curr; diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts index 064aaecfb0..9759e2e87f 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts @@ -1,7 +1,10 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { inject, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubeGenericResourceConfig, KubeGenericResourceDataServiceBase } from './kube-generic-resource-data.base'; class TestResourceService extends KubeGenericResourceDataServiceBase<{ metadata: { name: string } }> { @@ -71,3 +74,61 @@ describe('KubeGenericResourceDataServiceBase delete', () => { expect(svc.lastDelete?.url).toBe('/pp/v1/proxy/api/v1/persistentvolumes/pv1'); }); }); + +class HttpTestService extends KubeGenericResourceDataServiceBase<{ metadata: { name: string } }> { + protected readonly http = inject(HttpClient); + protected readonly config: KubeGenericResourceConfig = { + apiPath: '/api/v1', resourceName: 'tests', namespaced: true, title: 'tests', + }; +} + +// A response already on the wire when its endpoint departs carries the +// old credentials' data; it must not land after the eviction. +describe('KubeGenericResourceDataServiceBase eviction while a load is in flight', () => { + const A = 'kube-a'; + const B = 'kube-b'; + const CLUSTER_URL = '/pp/v1/proxy/api/v1/tests?limit=500'; + const NS_URL = '/pp/v1/proxy/api/v1/namespaces/ns/tests?limit=500'; + let httpMock: HttpTestingController; + let svc: HttpTestService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection(), provideHttpClient(), provideHttpClientTesting()], + }); + httpMock = TestBed.inject(HttpTestingController); + svc = build(HttpTestService); + }); + + afterEach(() => httpMock.verify()); + + const pending = (url: string, guid: string) => + httpMock.expectOne(r => r.url === url && r.headers.get('x-cap-cnsi-list') === guid); + const listOf = (guid: string) => ({ [guid]: { items: [{ metadata: { name: 'x' } }] } }); + + it('drops the departed endpoint\'s late cluster and namespace lists and keeps the other\'s', async () => { + const loads = [A, B].flatMap(g => [svc.refresh({ kubeGuid: g }), svc.refresh({ kubeGuid: g, namespace: 'ns' })]); + const reqs = [A, B].flatMap(g => [pending(CLUSTER_URL, g), pending(NS_URL, g)]); + + TestBed.inject(KubeEndpointEviction).evict(A); + reqs.forEach(r => r.flush(listOf(r.request.headers.get('x-cap-cnsi-list') as string))); + await Promise.all(loads); + + expect(svc.itemsInCluster(A)()).toEqual([]); + expect(svc.itemsInNamespace(A, 'ns')()).toEqual([]); + expect(svc.itemsInCluster(B)()).toHaveLength(1); + expect(svc.itemsInNamespace(B, 'ns')()).toHaveLength(1); + }); + + it('drops the departed endpoint\'s late failure', async () => { + const load = svc.refresh({ kubeGuid: A }); + const req = pending(CLUSTER_URL, A); + + TestBed.inject(KubeEndpointEviction).evict(A); + req.flush('boom', { status: 500, statusText: 'Server Error' }); + await load; + + expect(svc.errors()()).toEqual([]); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts index 06aa45c405..8fbd776505 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.ts @@ -39,9 +39,10 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { private readonly _namespaceItems = signal<Map<string, Array<T & { kubeGuid: string }>>>(new Map()); private readonly _workloadItems = signal<Map<string, Array<T & { kubeGuid: string }>>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + private readonly eviction = inject(KubeEndpointEviction); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => { + this.eviction.register(kubeGuid => { const owned = (key: string) => key === kubeGuid || key.startsWith(`${kubeGuid}/`); evictKeys(this._clusterItems, owned); evictKeys(this._namespaceItems, owned); @@ -86,9 +87,13 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { // Refresh dispatches based on scope. Without a namespace it refreshes // the cluster-wide list. Cluster-only resources (PV, StorageClass, // ClusterRole) ignore the `namespace` field and always fetch cluster. + // A response that lands after the endpoint was evicted carries the old + // credentials' data, so it is dropped (fetchList drops a late failure). async refresh(scope: { kubeGuid: string; namespace?: string }): Promise<void> { + const live = this.eviction.current(scope.kubeGuid); if (scope.namespace && this.config.namespaced) { const items = await this.fetchNamespaced(scope.kubeGuid, scope.namespace); + if (!live()) return; this._namespaceItems.update(curr => { const next = new Map(curr); next.set(nsKey(scope.kubeGuid, scope.namespace as string), items); @@ -97,6 +102,7 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { return; } const items = await this.fetchCluster(scope.kubeGuid); + if (!live()) return; this._clusterItems.update(curr => { const next = new Map(curr); next.set(scope.kubeGuid, items); @@ -160,6 +166,7 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { // Stamps `kubeGuid` + `metadata.kubeId` on each row. private async fetchList(url: string, kubeGuid: string): Promise<Array<T & { kubeGuid: string }>> { const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); + const live = this.eviction.current(kubeGuid); try { const resp = await firstValueFrom(this.http.get<{ [cnsi: string]: KubeListResponse<T> }>( url, @@ -181,7 +188,7 @@ export abstract class KubeGenericResourceDataServiceBase<T extends { metadata: { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(-50)); + if (live()) this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts index fa20ac37c1..eac627215f 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubeNamespaceDataService } from './kube-namespace-data.service'; const KUBE_GUID = 'kube-1'; @@ -144,4 +145,20 @@ describe('KubeNamespaceDataService', () => { expect(errs[0].affected?.[0]).toBe('kube-1'); expect(errs[49].affected?.[0]).toBe('kube-50'); }); + // A page keeps the projection it got at first render. After the endpoint + // leaves and comes back, the registry vends a new per-endpoint service; + // the held projection must follow it, not the orphaned one. + it('a held projection follows the endpoint service across unregister and reconnect', async () => { + const held = svc.namespacesForEndpoint(KUBE_GUID); + expect(held()).toEqual([]); + + // The departure as KubeEndpointRegistryHook performs it, then the reconnect. + registry.unregister(KUBE_GUID); + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + const loading = registry.getService(KUBE_GUID).refresh('namespaces'); + httpMock.expectOne(NS_URL).flush({ [KUBE_GUID]: { items: [{ metadata: { name: 'after' } }] } }); + await loading; + + expect(held().map(ns => ns.metadata.name)).toEqual(['after']); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts index 107a4c8152..b5a0623114 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-namespace-data.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; -import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { Injectable, Signal, computed, inject, signal, untracked } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; @@ -21,22 +21,28 @@ import { KubeEndpointEviction, evictErrors } from '../endpoint-data/kube-endpoin export class KubeNamespaceDataService { private readonly http = inject(HttpClient); private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); private readonly _errors = signal<StratosError[]>([]); // The namespace list itself lives on the per-endpoint service, which the // registry drops; only fetchDirect's errors are held here. constructor() { - inject(KubeEndpointEviction).register(kubeGuid => evictErrors(this._errors, kubeGuid)); + this.eviction.register(kubeGuid => evictErrors(this._errors, kubeGuid)); } // Returns a signal projecting the namespaces cached on the per-endpoint // service. Composing with `computed` keeps the projection reactive — // when load()/refresh() updates the underlying signal the consumer - // recomputes. + // recomputes. The registry drops that service when the endpoint leaves + // (the hook evicts in the same step), so the projection re-resolves it + // on each eviction rather than holding the orphaned instance. Untracked: + // the lookup may construct the service, which writes its signals. namespacesForEndpoint(kubeGuid: string): Signal<KubeNamespace[]> { - const svc = this.registry.getService(kubeGuid); - return computed(() => svc.namespaces()); + return computed(() => { + this.eviction.epoch(kubeGuid); + return untracked(() => this.registry.getService(kubeGuid)).namespaces(); + }); } // Single-namespace projection by name — derived from the per-endpoint diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts index d44f551d56..d294e150c7 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubeNodeDataService } from './kube-node-data.service'; const KUBE_GUID = 'kube-1'; @@ -100,4 +101,40 @@ describe('KubeNodeDataService', () => { expect(errs[0].affected?.[0]).toBe('kube-1'); expect(errs[49].affected?.[0]).toBe('kube-50'); }); + // A response already on the wire when its endpoint departs carries the + // old credentials' data; it must not land after the eviction. + describe('eviction while a load is in flight', () => { + const OTHER = 'kube-2'; + const pending = (guid: string) => + httpMock.expectOne(r => r.url === NODES_URL && r.headers.get('x-cap-cnsi-list') === guid); + + it('drops the departed endpoint\'s late response and keeps the other\'s', async () => { + const a = svc.refresh(KUBE_GUID); + const b = svc.refresh(OTHER); + const reqA = pending(KUBE_GUID); + const reqB = pending(OTHER); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush({ [KUBE_GUID]: { items: [{ metadata: { name: 'a' } }] } }); + reqB.flush({ [OTHER]: { items: [{ metadata: { name: 'b' } }] } }); + await Promise.all([a, b]); + + expect(svc.nodesInCluster(KUBE_GUID)()).toEqual([]); + expect(svc.nodesInCluster(OTHER)()).toHaveLength(1); + // The node-count sync must not rebuild a registry entry for it either. + const guids = TestBed.inject(KubeEndpointDataRegistry).getSnapshot().instances.map(i => i.kubeGuid); + expect(guids).not.toContain(KUBE_GUID); + }); + + it('drops the departed endpoint\'s late failure', async () => { + const a = svc.refresh(KUBE_GUID); + const reqA = pending(KUBE_GUID); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush('boom', { status: 500, statusText: 'Server Error' }); + await a; + + expect(svc.errors()()).toEqual([]); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts index d5b9d4d8c4..cbc056496d 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-node-data.service.ts @@ -17,6 +17,7 @@ import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/k export class KubeNodeDataService { private readonly http = inject(HttpClient); private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); // Per-endpoint node cache. Independent of the endpoint-service's // node *count* signal (which only tracks the count for the cluster @@ -26,7 +27,7 @@ export class KubeNodeDataService { private readonly _errors = signal<StratosError[]>([]); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => { + this.eviction.register(kubeGuid => { evictKeys(this._nodes, key => key === kubeGuid); evictErrors(this._errors, kubeGuid); }); @@ -49,9 +50,13 @@ export class KubeNodeDataService { // Force a fresh fetch. Pushes the result into the per-endpoint cache // so signal projections light up. Updates the endpoint-service's node // *count* signal as a side-effect so the cluster summary card stays - // coherent. + // coherent. A response that lands after the endpoint was evicted + // carries the old credentials' data, so it is dropped; skipping the + // count sync also keeps the registry from rebuilding the departed entry. async refresh(kubeGuid: string): Promise<void> { + const live = this.eviction.current(kubeGuid); const items = await this.fetchNodes(kubeGuid); + if (!live()) return; this._nodes.update(curr => { const next = new Map(curr); next.set(kubeGuid, items); @@ -78,6 +83,7 @@ export class KubeNodeDataService { private async fetchNodes(kubeGuid: string): Promise<KubeNode[]> { const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); + const live = this.eviction.current(kubeGuid); try { const resp = await firstValueFrom(this.http.get<{ [cnsi: string]: KubeListResponse<KubeNode> }>( `/pp/v1/proxy/api/v1/nodes?limit=${KUBE_LIST_DEFAULT_LIMIT}`, @@ -99,7 +105,7 @@ export class KubeNodeDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(-50)); + if (live()) this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts index 1dd92df34f..efe78f6d1c 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubePodDataService } from './kube-pod-data.service'; const KUBE_GUID = 'kube-1'; @@ -233,4 +234,37 @@ describe('KubePodDataService', () => { expect(svc.podsInWorkload('cnsi-1', 'ns-a', 'rel-x')().map(p => p.metadata.name)).toEqual(['p2']); }); }); + + // A response already on the wire when its endpoint departs carries the + // old credentials' data; it must not land after the eviction. + describe('eviction while a load is in flight', () => { + const OTHER = 'kube-2'; + const pending = (guid: string) => + httpMock.expectOne(r => r.url === CLUSTER_PODS_URL && r.headers.get('x-cap-cnsi-list') === guid); + const listOf = (guid: string) => ({ [guid]: { items: [{ metadata: { name: 'p' }, status: {} }] } }); + + it('drops the departed endpoint\'s late response and keeps the other\'s', () => { + const a = svc.podsInCluster(KUBE_GUID); + const b = svc.podsInCluster(OTHER); + const reqA = pending(KUBE_GUID); + const reqB = pending(OTHER); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush(listOf(KUBE_GUID)); + reqB.flush(listOf(OTHER)); + + expect(a()).toEqual([]); + expect(b()).toHaveLength(1); + }); + + it('drops the departed endpoint\'s late failure', () => { + svc.podsInCluster(KUBE_GUID); + const reqA = pending(KUBE_GUID); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqA.flush('nope', { status: 403, statusText: 'Forbidden' }); + + expect(svc.errors()()).toEqual([]); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts index 54fc3e70d6..8d5a624b89 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts @@ -45,6 +45,7 @@ interface CacheEntry { export class KubePodDataService { private readonly http = inject(HttpClient); private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); // Per-scope-key cache of pod lists. Key formats: // cluster:{kubeGuid} @@ -55,7 +56,7 @@ export class KubePodDataService { private readonly _unavailable = signal<Set<string>>(new Set()); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => this.evictEndpoint(kubeGuid)); + this.eviction.register(kubeGuid => this.evictEndpoint(kubeGuid)); } errors(): Signal<StratosError[]> { @@ -172,46 +173,33 @@ export class KubePodDataService { } private fetchClusterPods(kubeGuid: string): Observable<KubePod[]> { - const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); - return this.http.get<{ [cnsi: string]: KubeListResponse<KubePod> }>( - `/pp/v1/proxy/api/v1/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}`, - { headers }, - ).pipe( - map(resp => this.absorbResponse(clusterKey(kubeGuid), kubeGuid, resp?.[kubeGuid])), - catchError((err: HttpErrorResponse | unknown) => { - this.markUnavailable(clusterKey(kubeGuid), err, 'kube-pods-cluster', kubeGuid); - this.storeItems(clusterKey(kubeGuid), []); - return of([] as KubePod[]); - }), - ); + return this.fetchPods(kubeGuid, clusterKey(kubeGuid), 'kube-pods-cluster', + `/pp/v1/proxy/api/v1/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}`); } private fetchNamespacePods(kubeGuid: string, namespace: string): Observable<KubePod[]> { - const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); - return this.http.get<{ [cnsi: string]: KubeListResponse<KubePod> }>( - `/pp/v1/proxy/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}`, - { headers }, - ).pipe( - map(resp => this.absorbResponse(nsKey(kubeGuid, namespace), kubeGuid, resp?.[kubeGuid])), - catchError((err: HttpErrorResponse | unknown) => { - this.markUnavailable(nsKey(kubeGuid, namespace), err, 'kube-pods-namespace', kubeGuid); - this.storeItems(nsKey(kubeGuid, namespace), []); - return of([] as KubePod[]); - }), - ); + return this.fetchPods(kubeGuid, nsKey(kubeGuid, namespace), 'kube-pods-namespace', + `/pp/v1/proxy/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}`); } private fetchNodePods(kubeGuid: string, nodeName: string): Observable<KubePod[]> { - const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); const fieldSelector = encodeURIComponent(`spec.nodeName=${nodeName}`); - return this.http.get<{ [cnsi: string]: KubeListResponse<KubePod> }>( - `/pp/v1/proxy/api/v1/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}&fieldSelector=${fieldSelector}`, - { headers }, - ).pipe( - map(resp => this.absorbResponse(nodeKey(kubeGuid, nodeName), kubeGuid, resp?.[kubeGuid])), + return this.fetchPods(kubeGuid, nodeKey(kubeGuid, nodeName), 'kube-pods-node', + `/pp/v1/proxy/api/v1/pods?limit=${KUBE_LIST_DEFAULT_LIMIT}&fieldSelector=${fieldSelector}`); + } + + // A response that lands after the endpoint was evicted carries the old + // credentials' data, so it is dropped, success or failure. + private fetchPods(kubeGuid: string, key: string, title: string, url: string): Observable<KubePod[]> { + const live = this.eviction.current(kubeGuid); + const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); + return this.http.get<{ [cnsi: string]: KubeListResponse<KubePod> }>(url, { headers }).pipe( + map(resp => live() ? this.absorbResponse(key, kubeGuid, resp?.[kubeGuid]) : []), catchError((err: HttpErrorResponse | unknown) => { - this.markUnavailable(nodeKey(kubeGuid, nodeName), err, 'kube-pods-node', kubeGuid); - this.storeItems(nodeKey(kubeGuid, nodeName), []); + if (live()) { + this.markUnavailable(key, err, title, kubeGuid); + this.storeItems(key, []); + } return of([] as KubePod[]); }), ); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts index 0f54980be5..9b2ab99e10 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; +import { KubeEndpointEviction } from '../endpoint-data/kube-endpoint-eviction'; import { KubeServiceDataService } from './kube-service-data.service'; const KUBE_GUID = 'kube-1'; @@ -135,4 +136,42 @@ describe('KubeServiceDataService', () => { expect(svc.servicesInWorkload('cnsi-1', 'ns-a', 'rel-x')().map(s => s.metadata.name)).toEqual(['s2']); }); }); + // A response already on the wire when its endpoint departs carries the + // old credentials' data; it must not land after the eviction. + describe('eviction while a load is in flight', () => { + const OTHER = 'kube-2'; + const pending = (url: string, guid: string) => + httpMock.expectOne(r => r.url === url && r.headers.get('x-cap-cnsi-list') === guid); + const listOf = (guid: string) => ({ [guid]: { items: [{ metadata: { name: 'x' } }] } }); + + it('drops the departed endpoint\'s late cluster lists and keeps the other\'s', async () => { + const a = svc.refresh({ kubeGuid: KUBE_GUID }); + const b = svc.refresh({ kubeGuid: OTHER }); + const reqs = [KUBE_GUID, OTHER].flatMap(g => [pending(SERVICES_URL, g), pending(ENDPOINTS_URL, g)]); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + reqs.forEach(r => r.flush(listOf(r.request.headers.get('x-cap-cnsi-list') as string))); + await Promise.all([a, b]); + + expect(svc.servicesInCluster(KUBE_GUID)()).toEqual([]); + expect(svc.endpointsInCluster(KUBE_GUID)()).toEqual([]); + expect(svc.servicesInCluster(OTHER)()).toHaveLength(1); + expect(svc.endpointsInCluster(OTHER)()).toHaveLength(1); + }); + + it('drops the departed endpoint\'s late namespaced list and failure', async () => { + const ok = svc.refresh({ kubeGuid: KUBE_GUID, namespace: 'default' }); + const okReq = pending(NS_SERVICES_URL, KUBE_GUID); + const failing = svc.refresh({ kubeGuid: KUBE_GUID, namespace: 'other' }); + const failReq = pending('/pp/v1/proxy/api/v1/namespaces/other/services?limit=500', KUBE_GUID); + + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + okReq.flush(listOf(KUBE_GUID)); + failReq.flush('boom', { status: 500, statusText: 'Server Error' }); + await Promise.all([ok, failing]); + + expect(svc.servicesInNamespace(KUBE_GUID, 'default')()).toEqual([]); + expect(svc.errors()()).toEqual([]); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts index 7b1b9f9c6d..a1256d35f1 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-service-data.service.ts @@ -29,6 +29,7 @@ export class KubeServiceDataService { // mirrors KubeNamespaceDataService and gives wave-3 a hook to attach // service-count to the cluster summary card without re-injecting. private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); private readonly _clusterServices = signal<Map<string, KubeService[]>>(new Map()); private readonly _namespaceServices = signal<Map<string, KubeService[]>>(new Map()); @@ -37,7 +38,7 @@ export class KubeServiceDataService { private readonly _errors = signal<StratosError[]>([]); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => { + this.eviction.register(kubeGuid => { const owned = (key: string) => key === kubeGuid || key.startsWith(`${kubeGuid}/`); evictKeys(this._clusterServices, owned); evictKeys(this._namespaceServices, owned); @@ -113,11 +114,15 @@ export class KubeServiceDataService { // Refresh dispatches based on scope. Without a namespace it refreshes // both the cluster-wide services AND the cluster-wide endpoints in // parallel — both consumers (services list, endpoints list) typically - // sit on the same route family. + // sit on the same route family. A response that lands after the + // endpoint was evicted carries the old credentials' data, so it is + // dropped (fetchList drops a late failure). async refresh(scope: { kubeGuid: string; namespace?: string }): Promise<void> { + const live = this.eviction.current(scope.kubeGuid); if (scope.namespace) { const namespace = scope.namespace; const items = await this.fetchNamespacedServices(scope.kubeGuid, namespace); + if (!live()) return; this._namespaceServices.update(curr => { const next = new Map(curr); next.set(nsKey(scope.kubeGuid, namespace), items); @@ -129,6 +134,7 @@ export class KubeServiceDataService { this.fetchClusterServices(scope.kubeGuid), this.fetchClusterEndpoints(scope.kubeGuid), ]); + if (!live()) return; this._clusterServices.update(curr => { const next = new Map(curr); next.set(scope.kubeGuid, services); @@ -166,6 +172,7 @@ export class KubeServiceDataService { title: string, ): Promise<Array<T & { kubeGuid: string }>> { const headers = new HttpHeaders({ 'x-cap-cnsi-list': kubeGuid }); + const live = this.eviction.current(kubeGuid); try { const resp = await firstValueFrom(this.http.get<{ [cnsi: string]: KubeListResponse<T> }>( url, @@ -187,7 +194,7 @@ export class KubeServiceDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(-50)); + if (live()) this._errors.update(curr => [...curr, next].slice(-50)); return []; } } diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts index abfb683982..98c0fb2ccb 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts @@ -1,4 +1,4 @@ -import { Injectable, WritableSignal } from '@angular/core'; +import { Injectable, WritableSignal, signal, untracked } from '@angular/core'; import { StratosError } from './kube-types'; @@ -8,18 +8,37 @@ import { StratosError } from './kube-types'; // each one registers an evictor here and KubeEndpointRegistryHook calls // evict(kubeGuid) on disconnect or unregister. Services register on // construction; one never constructed holds nothing to evict. +// +// Eviction cannot recall a request already on the wire, so each evict +// also bumps the guid's epoch. A load takes current(kubeGuid) before it +// sends and writes its response only while that check still holds. @Injectable({ providedIn: 'root' }) export class KubeEndpointEviction { private readonly evictors: Array<(kubeGuid: string) => void> = []; + private readonly epochs = signal<ReadonlyMap<string, number>>(new Map()); register(evictor: (kubeGuid: string) => void): void { this.evictors.push(evictor); } evict(kubeGuid: string): void { + this.epochs.update(curr => new Map(curr).set(kubeGuid, (curr.get(kubeGuid) ?? 0) + 1)); this.evictors.forEach(evictor => evictor(kubeGuid)); } + + // Tracked read: a computed that calls this re-runs after each evict of + // the guid (used to re-resolve per-endpoint instances). + epoch(kubeGuid: string): number { + return this.epochs().get(kubeGuid) ?? 0; + } + + // Check that stays true until the next evict(kubeGuid). Untracked, so a + // load started from inside a computed adds no dependency. + current(kubeGuid: string): () => boolean { + const at = untracked(() => this.epoch(kubeGuid)); + return () => untracked(() => this.epoch(kubeGuid)) === at; + } } // Drop the keys `owned` matches from a signal-held cache map. Leaves the From a9f3fec495700b8f582aecbb980cd2cb172cfdea Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 07:14:34 -0700 Subject: [PATCH 115/121] fix(kubernetes): close eviction and refresh races A releases request in flight across an eviction wrote the departed endpoint's releases back; the load now snapshots every endpoint's eviction epoch when it starts and drops the evicted ones. A pod request finishing after an eviction or refresh deleted the newer request's dedupe entry, so the next read sent a duplicate. It now removes only its own entry. Pod reads no longer touch the endpoint registry: the connect hook creates the entry, and a read for a departed endpoint re-created it. An analysis list request replaced by refresh() still wrote its result and dropped the newer request's handle. A response now writes only while the cache entry still holds its request. --- .../kube-analysis-data.service.spec.ts | 36 +++++++++++++++++++ .../domain-data/kube-analysis-data.service.ts | 21 +++++------ .../domain-data/kube-pod-data.service.spec.ts | 31 ++++++++++++++++ .../domain-data/kube-pod-data.service.ts | 19 +++++----- .../endpoint-data/kube-endpoint-eviction.ts | 7 ++++ .../kube-helm-data.service.spec.ts | 17 +++++++++ .../endpoint-data/kube-helm-data.service.ts | 10 ++++-- 7 files changed, 118 insertions(+), 23 deletions(-) diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts index 0f7f1bbcde..b7f2f505d0 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.spec.ts @@ -188,4 +188,40 @@ describe('KubeAnalysisDataService', () => { httpMock.expectOne(`${REPORTS_URL}/r1`).flush(report('r1')); }); }); + + // refresh() replaces the in-flight request; the older one, still on the + // wire, must not displace it or overwrite its result. + describe('refresh while a list load is in flight', () => { + const report = (id: string) => ({ id, name: id, type: 'popeye', status: 'completed' }); + + it('a read after the older load lands joins the refresh', async () => { + const old = firstValueFrom(svc.loadReports(KUBE_GUID)); + const reqOld = httpMock.expectOne(REPORTS_URL); + const refreshing = svc.refresh({ kubeGuid: KUBE_GUID }); + const reqNew = httpMock.expectOne(REPORTS_URL); + + reqOld.flush([report('r1')]); + await old; + const again = firstValueFrom(svc.loadReports(KUBE_GUID)); + httpMock.expectNone(REPORTS_URL); + reqNew.flush([report('r2')]); + await refreshing; + + expect((await again).map(r => r.id)).toEqual(['r2']); + }); + + it('an older load landing last does not overwrite the refresh', async () => { + const old = firstValueFrom(svc.loadReports(KUBE_GUID)); + const reqOld = httpMock.expectOne(REPORTS_URL); + const refreshing = svc.refresh({ kubeGuid: KUBE_GUID }); + const reqNew = httpMock.expectOne(REPORTS_URL); + + reqNew.flush([report('r2')]); + await refreshing; + reqOld.flush([report('r1')]); + await old; + + expect(svc.reportsForEndpoint(KUBE_GUID)().map(r => r.id)).toEqual(['r2']); + }); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts index 3955a4c8c2..46cd1a4965 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-analysis-data.service.ts @@ -139,13 +139,15 @@ export class KubeAnalysisDataService { private fetchReports(kubeGuid: string): Observable<AnalysisReport[]> { const url = `/pp/v1/analysis/reports/${kubeGuid}`; - // A response that lands after the endpoint was evicted carries the - // old credentials' data, so it is dropped, success or failure. - const live = this.eviction.current(kubeGuid); - const req = defer(() => this.http.get<AnalysisReport[]>(url)).pipe( + // A response writes only while the entry still holds this request. + // An eviction drops the entry (the response carries the old + // credentials' data) and refresh() swaps in a newer request; either + // way this response is stale and is dropped, success or failure. + const owns = () => this._byEndpoint().get(kubeGuid)?.inFlight === req; + const req: Observable<AnalysisReport[]> = defer(() => this.http.get<AnalysisReport[]>(url)).pipe( map(items => Array.isArray(items) ? items : []), tap(items => { - if (!live()) return; + if (!owns()) return; this._byEndpoint.update(curr => { const next = new Map(curr); next.set(kubeGuid, { reports: items, fetchedAt: Date.now() }); @@ -154,7 +156,7 @@ export class KubeAnalysisDataService { this.clearUnavailable(kubeGuid); }), catchError((err: HttpErrorResponse | unknown) => { - if (!live()) return of([] as AnalysisReport[]); + if (!owns()) return of([] as AnalysisReport[]); this.markUnavailable(kubeGuid, err, 'analysis-reports'); // Cache the empty result so consumers don't loop on errors; // the next refresh() call clears the cache. @@ -168,12 +170,11 @@ export class KubeAnalysisDataService { finalize(() => { // Drop the in-flight handle once settled so a future stale-cache // request triggers a fresh fetch rather than re-subscribing the - // completed observable. After an eviction the entry, if any, is - // a newer load's. - if (!live()) return; + // completed observable. A newer request's handle stays. + if (!owns()) return; this._byEndpoint.update(curr => { const entry = curr.get(kubeGuid); - if (!entry?.inFlight) return curr; + if (!entry) return curr; const next = new Map(curr); next.set(kubeGuid, { reports: entry.reports, fetchedAt: entry.fetchedAt }); return next; diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts index efe78f6d1c..f6b4aecccb 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.spec.ts @@ -266,5 +266,36 @@ describe('KubePodDataService', () => { expect(svc.errors()()).toEqual([]); }); + + it('a late completion leaves the newer load\'s dedupe entry in place', () => { + svc.podsInCluster(KUBE_GUID); + const old = pending(KUBE_GUID); + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + svc.podsInCluster(KUBE_GUID); + const fresh = pending(KUBE_GUID); + + old.flush(listOf(KUBE_GUID)); + // Still in flight: this read must join the fresh request. + svc.podsInCluster(KUBE_GUID); + const extra = httpMock.match(r => r.url === CLUSTER_PODS_URL); + extra.forEach(r => r.flush(listOf(KUBE_GUID))); + fresh.flush(listOf(KUBE_GUID)); + + expect(extra).toHaveLength(0); + }); + + // A page still open after the disconnect re-reads when the eviction + // empties its scope; that read must not bring the registry entry back. + it('a read for a departed endpoint does not re-create its registry entry', () => { + const registry = TestBed.inject(KubeEndpointDataRegistry); + registry.getService(KUBE_GUID); + registry.unregister(KUBE_GUID); + TestBed.inject(KubeEndpointEviction).evict(KUBE_GUID); + + svc.podsInCluster(KUBE_GUID); + pending(KUBE_GUID).flush(listOf(KUBE_GUID)); + + expect(registry.getSnapshot().instances.map(i => i.kubeGuid)).toEqual([]); + }); }); }); diff --git a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts index 8d5a624b89..15f5478a2d 100644 --- a/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/domain-data/kube-pod-data.service.ts @@ -4,7 +4,6 @@ import { Observable, firstValueFrom, of } from 'rxjs'; import { catchError, map, shareReplay } from 'rxjs/operators'; import { KUBE_LIST_DEFAULT_LIMIT, KubeListResponse } from '../endpoint-data/kube-paged-response'; -import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.registry'; import { KubeEndpointEviction, evictErrors, evictKeys } from '../endpoint-data/kube-endpoint-eviction'; import { KubePod, KubePodContainerStatus, StratosError } from '../endpoint-data/kube-types'; @@ -44,7 +43,6 @@ interface CacheEntry { @Injectable({ providedIn: 'root' }) export class KubePodDataService { private readonly http = inject(HttpClient); - private readonly registry = inject(KubeEndpointDataRegistry); private readonly eviction = inject(KubeEndpointEviction); // Per-scope-key cache of pod lists. Key formats: @@ -142,16 +140,17 @@ export class KubePodDataService { if (POD_FETCH_DEDUPE.has(key)) { return; } - // Touch the registry — guarantees the per-endpoint service exists so - // cluster-wide diagnostics know about this kubeGuid even if the pods - // load races ahead of the namespace/version load. - this.registry.getService(kubeGuid); + // No registry touch here: KubeEndpointRegistryHook creates the + // per-endpoint service on connect, and a read for a departed guid + // (a page still open after the disconnect) must not re-create it. const inflight = factory().pipe(shareReplay(1)); POD_FETCH_DEDUPE.set(key, inflight); - inflight.subscribe({ - complete: () => POD_FETCH_DEDUPE.delete(key), - error: () => POD_FETCH_DEDUPE.delete(key), - }); + // An eviction or refresh may have replaced this entry with a newer + // request; only remove our own. + const settle = () => { + if (POD_FETCH_DEDUPE.get(key) === inflight) POD_FETCH_DEDUPE.delete(key); + }; + inflight.subscribe({ complete: settle, error: settle }); } // Every key format has the kubeGuid as its second segment. diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts index 98c0fb2ccb..6979a56aaa 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts @@ -39,6 +39,13 @@ export class KubeEndpointEviction { const at = untracked(() => this.epoch(kubeGuid)); return () => untracked(() => this.epoch(kubeGuid)) === at; } + + // current() for a load that learns its guids only from the response: + // the check holds for each guid not evicted since this call. + currentAny(): (kubeGuid: string) => boolean { + const at = untracked(this.epochs); + return kubeGuid => (untracked(this.epochs).get(kubeGuid) ?? 0) === (at.get(kubeGuid) ?? 0); + } } // Drop the keys `owned` matches from a signal-held cache map. Leaves the diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.spec.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.spec.ts index 2440ae035e..09d6bf0081 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.spec.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.spec.ts @@ -5,6 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { TailwindSnackBarService } from '../../../../core/src/shared/services/tailwind-snackbar.service'; +import { KubeEndpointEviction } from './kube-endpoint-eviction'; import { KubeHelmDataService } from './kube-helm-data.service'; import { HelmInstallPayload, HelmUpgradePayload } from './kube-types'; @@ -180,4 +181,20 @@ describe('KubeHelmDataService', () => { expect(svc.releasesLastFetched()()).not.toBeNull(); expect(svc.chartsLastFetched()()).not.toBeNull(); }); + + // The releases request spans every endpoint, so an eviction while it is + // on the wire must still drop the departed endpoint's share of it. + it('drops a departed endpoint\'s releases from a late response', async () => { + const promise = svc.loadReleases(); + const req = httpMock.expectOne(RELEASES_URL); + + TestBed.inject(KubeEndpointEviction).evict('kube-1'); + req.flush({ + 'kube-1': [{ name: 'rel-a', namespace: 'ns1', info: { status: 'deployed' }, chart: { metadata: {} } }], + 'kube-2': [{ name: 'rel-b', namespace: 'ns2', info: { status: 'deployed' }, chart: { metadata: {} } }], + }); + await promise; + + expect(svc.allReleases()().map(r => r.guid)).toEqual(['kube-2:ns2:rel-b']); + }); }); diff --git a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts index 439d566f40..2b2a16cc91 100644 --- a/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-helm-data.service.ts @@ -41,6 +41,7 @@ import { export class KubeHelmDataService { private readonly http = inject(HttpClient); private readonly snackBar = inject(TailwindSnackBarService); + private readonly eviction = inject(KubeEndpointEviction); // --- Release state --- private readonly _releases = signal<HelmRelease[]>([]); @@ -63,7 +64,7 @@ export class KubeHelmDataService { ]); constructor() { - inject(KubeEndpointEviction).register(kubeGuid => + this.eviction.register(kubeGuid => this._releases.update(curr => curr.some(r => r.endpointId === kubeGuid) ? curr.filter(r => r.endpointId !== kubeGuid) : curr)); @@ -108,11 +109,14 @@ export class KubeHelmDataService { // Fetch releases across ALL connected helm-bearing endpoints. The // legacy effect calls /pp/v1/helm/releases without a CNSI header — // Jetstream fans the request out to every connected k8s endpoint and - // returns a dict keyed by endpoint guid. + // returns a dict keyed by endpoint guid. An endpoint evicted while the + // request was on the wire is dropped from the response: its releases + // were read with the old credentials. async loadReleases(): Promise<void> { if (this._releasesLoading()) return; this._releasesLoading.set(true); this._releaseErrors.set([]); + const live = this.eviction.currentAny(); try { const resp = await firstValueFrom( this.http.get<Record<string, HelmRelease[] | null>>('/pp/v1/helm/releases'), @@ -120,7 +124,7 @@ export class KubeHelmDataService { const all: HelmRelease[] = []; Object.keys(resp ?? {}).forEach(endpointId => { const list = resp[endpointId]; - if (!Array.isArray(list)) return; + if (!Array.isArray(list) || !live(endpointId)) return; list.forEach(raw => { all.push(this.normalizeRelease(raw, endpointId)); }); From c995a7d64b3565d5e9f2ff99b120536da021475d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 09:19:35 -0700 Subject: [PATCH 116/121] fix(core): track activity again after a blip A short invalid session (a failed keepalive near expiry) stops the user activity listener. The listener was re-created only when it had never existed, and a closed Subscription still counts as existing, so it never came back. The user then looked idle and was warned and logged out while working. Re-create it when it is closed, as the session poll already does. --- .../core/src/logged-in.service.spec.ts | 45 ++++++++++++++++++- .../packages/core/src/logged-in.service.ts | 2 +- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/core/src/logged-in.service.spec.ts b/src/frontend/packages/core/src/logged-in.service.spec.ts index c909c8ba9d..d1e453532f 100644 --- a/src/frontend/packages/core/src/logged-in.service.spec.ts +++ b/src/frontend/packages/core/src/logged-in.service.spec.ts @@ -1,12 +1,16 @@ import { inject, TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { AuthState } from '@stratosui/store'; import { createBasicStoreModule, STORE_TEST_PROVIDERS } from '@stratosui/store/testing'; +import { of } from 'rxjs'; import { CoreTestingModule } from '@test-framework/core-test.modules'; import { CoreModule } from './core/core.module'; import { CurrentUserPermissionsService } from './core/permissions/current-user-permissions.service'; +import { AuthSignalService } from './core/signals/auth-signal.service'; +import { DashboardSignalService } from './core/signals/dashboard-signal.service'; import { LoggedInService } from './logged-in.service'; describe('LoggedInService', () => { @@ -32,3 +36,42 @@ describe('LoggedInService', () => { expect(service).toBeTruthy(); })); }); + +describe('LoggedInService activity tracking', () => { + const validAuth = { loggedIn: true, sessionData: { valid: true }, error: false } as unknown as AuthState; + const auth = signal<AuthState | undefined>(undefined); + let service: LoggedInService; + + beforeEach(() => { + auth.set(undefined); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: AuthSignalService, useValue: { auth } }, + { provide: DashboardSignalService, useValue: { dashboard: signal({}) } }, + { provide: CurrentUserPermissionsService, useValue: { can: () => of(false) } }, + ], + }); + service = TestBed.inject(LoggedInService); + }); + + afterEach(() => service.destroy()); + + // A keepalive blip makes the session briefly invalid, which stops the + // activity listener. Once the session is valid again the listener must + // come back, or an active user is warned and logged out as idle. + it('tracks user activity again after the session recovers', () => { + service.init(); + auth.set(validAuth); + TestBed.tick(); + auth.set({ ...validAuth, error: true }); + TestBed.tick(); + auth.set(validAuth); + TestBed.tick(); + + (service as any).lastUserInteraction = 0; + document.dispatchEvent(new KeyboardEvent('keydown')); + + expect((service as any).lastUserInteraction).toBeGreaterThan(0); + }); +}); diff --git a/src/frontend/packages/core/src/logged-in.service.ts b/src/frontend/packages/core/src/logged-in.service.ts index b58b993c59..8ad5acba4d 100644 --- a/src/frontend/packages/core/src/logged-in.service.ts +++ b/src/frontend/packages/core/src/logged-in.service.ts @@ -75,7 +75,7 @@ export class LoggedInService { if (!this.sessionChecker || this.sessionChecker.closed) { this.openSessionCheckerPoll(); } - if (!this.userInteractionChecker) { + if (!this.userInteractionChecker || this.userInteractionChecker.closed) { this.userInteractionChecker = merge(...eventStreams).subscribe(() => { this.lastUserInteraction = Date.now(); }); From 5d8dd8a596b855dbd93f5d94aec5cdf3f682cefa Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 09:21:27 -0700 Subject: [PATCH 117/121] fix(metrics): subscribe to range with DestroyRef A chart with a projected time range selector subscribed to it in ngAfterContentInit with takeUntilDestroyed() and no DestroyRef. That hook is outside the injection context, so it threw NG0203 and the chart never rendered. Pass a DestroyRef injected as a field. --- .../application-instance-chart.component.spec.ts | 12 ++++++++++++ .../metrics-chart/metrics-chart.component.ts | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.spec.ts index f99385c170..6cc8419743 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.spec.ts @@ -36,4 +36,16 @@ describe('ApplicationInstanceChartComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + // The chart projects a time range selector into app-metrics-chart, which + // subscribes to it after content init, outside the injection context. + it('renders with its time range selector', () => { + fixture.componentRef.setInput('appGuid', 'app-guid'); + fixture.componentRef.setInput('endpointGuid', 'cf-guid'); + fixture.componentRef.setInput('yAxisLabel', 'CPU'); + fixture.componentRef.setInput('queryString', 'cpu'); + fixture.componentRef.setInput('title', 'CPU usage'); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); }); diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts index 26d042dd34..3b59348b7a 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, AfterContentInit, Component, ContentChild, Input, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, AfterContentInit, Component, ContentChild, DestroyRef, Input, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ChartConfiguration } from 'chart.js'; @@ -48,6 +48,7 @@ export interface MetricsConfig<T = any> { }) export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentInit { private metricsDataService = inject(MetricsDataService); + private destroyRef = inject(DestroyRef); @Input() public metricsConfig!: MetricsConfig; @@ -165,7 +166,7 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni ngAfterContentInit() { if (this.timeRangeSelector) { this.timeRangeSelector.baseRequest = this.metricsConfig.request; - this.timeRangeSelector.request.pipe(takeUntilDestroyed()).subscribe((req: MetricsRequest) => { + this.timeRangeSelector.request.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((req: MetricsRequest) => { this.requestSignal.set(req); }); } From 4f6fe728c6a895a78d4b09efd0a6427ceee3b5a3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 09:23:43 -0700 Subject: [PATCH 118/121] fix(auth): settle verifying when auth resets The keepalive verify runs near session expiry. When it fails, auth resets to the default state, which since 2025-11 starts with verifying set for app init. Nothing verifies again, and the auth guard waits for verifying to clear, so every later navigation hung until a reload. Reset with verifying cleared: the next navigation goes to the login page, which verifies and returns the user if the session is still valid. --- .../src/services/auth-data.service.spec.ts | 17 +++++++++++++++++ .../store/src/services/auth-data.service.ts | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/frontend/packages/store/src/services/auth-data.service.spec.ts b/src/frontend/packages/store/src/services/auth-data.service.spec.ts index 504b4dcbba..312b6270c8 100644 --- a/src/frontend/packages/store/src/services/auth-data.service.spec.ts +++ b/src/frontend/packages/store/src/services/auth-data.service.spec.ts @@ -124,6 +124,23 @@ describe('AuthDataService', () => { expect(svc.sessionData()).toBeNull(); }); + // The keepalive verify runs near session expiry. When it fails, nothing + // verifies again, so a reset that leaves verifying set holds the auth + // guard (which waits for !verifying) and every later navigation hangs. + it('a failed keepalive verify leaves the session settled, not verifying', async () => { + const svc = TestBed.inject(AuthDataService); + const login = svc.verifySession(true, true); + httpMock.expectOne(VERIFY_URL).flush(okEnvelope(), { headers: { 'x-cap-session-expires-on': '5' } }); + await login; + + const keepalive = svc.verifySession(false, false); + httpMock.expectOne(VERIFY_URL).flush('fail', { status: 502, statusText: 'Bad Gateway' }); + await keepalive; + + expect(svc.loggedIn()).toBe(false); + expect(svc.verifying()).toBe(false); + }); + it('login posts the credentials then runs a verify cycle', async () => { const svc = TestBed.inject(AuthDataService); const p = svc.login('alice', 's3cret'); diff --git a/src/frontend/packages/store/src/services/auth-data.service.ts b/src/frontend/packages/store/src/services/auth-data.service.ts index 3997670b44..fe11d40fa3 100644 --- a/src/frontend/packages/store/src/services/auth-data.service.ts +++ b/src/frontend/packages/store/src/services/auth-data.service.ts @@ -288,10 +288,14 @@ export class AuthDataService { }); } - /** RESET_AUTH — back to the default state. */ + /** + * RESET_AUTH — back to the default state, but settled. The default starts + * verifying for app init; nothing verifies again after a reset, so keeping + * that flag would hold the auth guard and hang every later navigation. + */ private resetAuth(): void { this.prevLoggedIn = false; - this._auth.set(defaultAuthState); + this._auth.set({ ...defaultAuthState, verifying: false }); } private isDomainMismatch(headers: { has: (h: string) => boolean; get: (h: string) => string | null } | undefined): boolean { From 4e9b78f7a1a8408a3872fb875e1fbd5b1663130d Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 09:47:59 -0700 Subject: [PATCH 119/121] docs(changelog): note the audit fixes Release note for #6019, #6020 and #6021: the pages that waited forever, the views that did not show, the background work that kept running, and the removed users/remove page. --- .../0013-audit-leaks-hangs-rendering.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelog.d/0013-audit-leaks-hangs-rendering.md diff --git a/changelog.d/0013-audit-leaks-hangs-rendering.md b/changelog.d/0013-audit-leaks-hangs-rendering.md new file mode 100644 index 0000000000..a5b7e5f368 --- /dev/null +++ b/changelog.d/0013-audit-leaks-hangs-rendering.md @@ -0,0 +1,41 @@ +[BugFixes] +- Fixed pages that waited forever: + - The UAA setup wizard checks the credentials in step 1 and shows an + error instead of an endless "Saving configuration" overlay. The local + account wizard gets the same overlay fix. + - Kubernetes config import no longer stalls at the first Connect, and + moves on past a cluster that fails to register or connect. + - The role picker in Add User and Manage Roles no longer shows + "Loading organizations..." forever; a failure shows Retry. + - The navigation progress bar no longer stays at 95% after a page + fails to load. + - After a failed session check near expiry, navigation no longer stops + working until a reload. +- Fixed views that did not show or showed out-of-date content: + - SSH sessions show their progress and server errors. + - Deploy keeps its completion notice after you leave the page and keeps + every log line. A second deploy from the same wizard is labelled + "Deploy again". + - The application Autoscale tab is back. It shows the reason when the + autoscaler refuses a request, and Create Policy only when there is no + policy. Its scaling history and metric charts load again; every + request for them had failed with an HTTP 400 error. + - The git tab no longer stays on "Retrieving Git Details" after a + failed lookup. + - Service broker cards show the Space row, and the broker URL as the + link text. + - Home page application cards follow start, stop and delete. + - Helm release pages no longer show earlier data after a failed reload, + and the Helm catalog sidebar shows. + - The Kubernetes resource viewer shows its header, and Delete works. + - Metrics charts with a time range selector render. + - An active user is no longer warned and logged out as idle after a + short session check failure. +- Stopped background work that kept running after you left a page: + - the application stats poll (one request per Cloud Foundry every 30 + seconds, until a reload); + - the metrics polls; + - watches on endpoints, permissions and quotas. +- Disconnecting a Kubernetes endpoint clears its cached data and errors. +- Removed the `users/remove` page, which nothing linked to any more. The + users tabs remove users directly. From 399d1c397d9c4bb634b82f209f239f75a35cba65 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 10:21:43 -0700 Subject: [PATCH 120/121] fix(autoscaler): forward only sent history params The scaling history and metric history handlers always forwarded start-time, end-time, page, results-per-page and order, empty or not. The frontend sends the sort as order-direction, so order went out empty, and the autoscaler (14.x and later) rejects that with 400 ("order": invalid value). Every bound app's Autoscale tab showed the raw HTTP error, and the metric charts stayed empty. Forward only the parameters the caller sent, including order-direction, which both endpoints read (order is deprecated and ignored on scaling histories). --- .../plugins/autoscaler/autoscaler.go | 31 +++++++----- .../plugins/autoscaler/history_query_test.go | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 src/jetstream/plugins/autoscaler/history_query_test.go diff --git a/src/jetstream/plugins/autoscaler/autoscaler.go b/src/jetstream/plugins/autoscaler/autoscaler.go index 66174ed27e..409fad1096 100644 --- a/src/jetstream/plugins/autoscaler/autoscaler.go +++ b/src/jetstream/plugins/autoscaler/autoscaler.go @@ -1,7 +1,6 @@ package autoscaler import ( - "fmt" "net/url" "github.com/labstack/echo/v5" @@ -62,12 +61,7 @@ func (a *Autoscaler) detachAutoscalerPolicy(echoContext *echo.Context) error { func (a *Autoscaler) getAutoscalerMetric(echoContext *echo.Context) error { appID := echoContext.Param("appId") metricType := echoContext.Param("metricType") - start := echoContext.QueryParam("start-time") - end := echoContext.QueryParam("end-time") - page := echoContext.QueryParam("page") - perPage := echoContext.QueryParam("results-per-page") - order := echoContext.QueryParam("order") - queryURL := fmt.Sprintf("?start-time=%s&end-time=%s&page=%s&results-per-page=%s&order=%s", start, end, page, perPage, order) + queryURL := historyQuery(echoContext.QueryParams()) metricURL, _ := url.Parse("/v1/apps/" + appID + "/aggregated_metric_histories/" + metricType + queryURL) responses, err := a.portalProxy.ProxyRequest(echoContext, metricURL) if err != nil { @@ -78,12 +72,7 @@ func (a *Autoscaler) getAutoscalerMetric(echoContext *echo.Context) error { func (a *Autoscaler) getAutoscalerEvent(echoContext *echo.Context) error { appID := echoContext.Param("appId") - start := echoContext.QueryParam("start-time") - end := echoContext.QueryParam("end-time") - page := echoContext.QueryParam("page") - perPage := echoContext.QueryParam("results-per-page") - order := echoContext.QueryParam("order") - queryURL := fmt.Sprintf("?start-time=%s&end-time=%s&page=%s&results-per-page=%s&order=%s", start, end, page, perPage, order) + queryURL := historyQuery(echoContext.QueryParams()) eventURL, _ := url.Parse("/v1/apps/" + appID + "/scaling_histories" + queryURL) responses, err := a.portalProxy.ProxyRequest(echoContext, eventURL) if err != nil { @@ -111,3 +100,19 @@ func (a *Autoscaler) deleteAutoscalerCredential(echoContext *echo.Context) error } return a.portalProxy.SendProxiedResponse(echoContext, responses) } + +// historyQuery builds the query for the autoscaler's history endpoints from +// the request's. Only parameters the caller sent are forwarded: the +// autoscaler rejects an empty value. +func historyQuery(in url.Values) string { + out := url.Values{} + for _, key := range []string{"start-time", "end-time", "page", "results-per-page", "order-direction", "order"} { + if v := in.Get(key); v != "" { + out.Set(key, v) + } + } + if len(out) == 0 { + return "" + } + return "?" + out.Encode() +} diff --git a/src/jetstream/plugins/autoscaler/history_query_test.go b/src/jetstream/plugins/autoscaler/history_query_test.go new file mode 100644 index 0000000000..db8ca3caf6 --- /dev/null +++ b/src/jetstream/plugins/autoscaler/history_query_test.go @@ -0,0 +1,49 @@ +package autoscaler + +import ( + "net/url" + "testing" +) + +func TestHistoryQuery(t *testing.T) { + cases := []struct { + name string + in url.Values + want string + }{ + { + // order-direction is the parameter the autoscaler reads (15.x + // ignores the deprecated order on scaling histories). + name: "forwards order-direction unchanged", + in: url.Values{ + "start-time": {"0"}, "end-time": {"9"}, "page": {"1"}, + "results-per-page": {"5"}, "order-direction": {"asc"}, + }, + want: "?end-time=9&order-direction=asc&page=1&results-per-page=5&start-time=0", + }, + { + name: "forwards a sent order as well", + in: url.Values{"order": {"asc"}}, + want: "?order=asc", + }, + { + // The autoscaler rejects an empty value ("order": invalid value), + // so a parameter the caller did not send is left out. + name: "omits parameters that were not sent", + in: url.Values{"page": {"2"}, "start-time": {""}}, + want: "?page=2", + }, + { + name: "sends nothing for no parameters", + in: url.Values{}, + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := historyQuery(c.in); got != c.want { + t.Errorf("historyQuery() = %q, want %q", got, c.want) + } + }) + } +} From 5858eb49e71f9a1b89d3b26d8e9796834af7e69b Mon Sep 17 00:00:00 2001 From: Norman Abramovitz <nabramovitz@juno.com> Date: Mon, 19 Oct 2026 10:26:20 -0700 Subject: [PATCH 121/121] fix(autoscaler): provide app services to pages Since the V3 cutover (fff9dc8e15) ApplicationService needs AppDetailDataService and the services it depends on, which only the app page's base component provided. The autoscaler pages provided ApplicationService alone, so the policy editor, credential, metric and history pages all failed with NG0201 and rendered nothing. Export the app page's provider list as APP_DETAIL_PROVIDERS and use it for both base components. --- .../autoscaler-base.component.spec.ts | 7 ++ .../src/features/autoscaler-base.component.ts | 21 +--- .../application/application-base.component.ts | 110 ++++++++++-------- .../packages/cloud-foundry/src/public_api.ts | 2 +- 4 files changed, 72 insertions(+), 68 deletions(-) diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts index 65fd4e28e8..966b662c94 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.spec.ts @@ -57,4 +57,11 @@ describe('AutoscalerBaseComponent', () => { it('should be created', () => { expect(component).toBeTruthy(); }); + + // The policy editor and the other autoscaler pages get ApplicationService + // from this component. It needs the app-detail services the app page + // provides; without them every autoscaler page failed with NG0201. + it('provides an ApplicationService its pages can use', () => { + expect(() => fixture.debugElement.injector.get(ApplicationService)).not.toThrow(); + }); }); diff --git a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts index dbf323a9da..203fc4ad2f 100644 --- a/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts +++ b/src/frontend/packages/cf-autoscaler/src/features/autoscaler-base.component.ts @@ -1,26 +1,15 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { RouterOutlet } from '@angular/router'; -import { ApplicationService, getGuids } from '@stratosui/cloud-foundry'; -import { APP_GUID, CF_GUID } from '@stratosui/core'; +import { APP_DETAIL_PROVIDERS } from '@stratosui/cloud-foundry'; @Component({ selector: 'app-autoscaler-base', templateUrl: './autoscaler-base.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - ApplicationService, - { - provide: CF_GUID, - useFactory: getGuids('cf'), - deps: [ActivatedRoute] - }, - { - provide: APP_GUID, - useFactory: getGuids(), - deps: [ActivatedRoute] - }, - ], + // The same app-detail providers as the app page: ApplicationService + // needs AppDetailDataService and the services it depends on. + providers: APP_DETAIL_PROVIDERS, standalone: true, imports: [RouterOutlet] }) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-base.component.ts index e15d02d16c..3888f63c76 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-base.component.ts @@ -1,5 +1,5 @@ -import { Component, ChangeDetectionStrategy, effect, inject, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, effect, inject, OnInit, Provider } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { APP_GUID, CF_GUID } from '@stratosui/core'; @@ -22,6 +22,63 @@ export function getGuids(type?: string) { }; } +/** + * Providers for an app-detail subtree: the route-derived guids and the + * component-scoped app services. ApplicationService depends on the rest, + * so a page that provides it (the app page, the autoscaler pages) needs + * the whole set. + */ +export const APP_DETAIL_PROVIDERS: Provider[] = [ + { + provide: CF_GUID, + useFactory: getGuids('cf'), + deps: [ActivatedRoute] + }, + { + provide: APP_GUID, + useFactory: getGuids(), + deps: [ActivatedRoute] + }, + // ApplicationService is the facade shim. Provide as a plain class so + // Angular's DI handles construction in a proper injection context; + // the legacy useFactory wrapper triggered NG0201 inside the inject() + // field initializers under some lifecycle orderings. + ApplicationService, + // CloudFoundryEndpointService is providedIn:'root' but its constructor + // calls getEntityService(this.cfGuid) at construction time. The cfGuid + // comes from ActiveRouteCfOrgSpace, which the root injector only has as + // an empty `{}` (from CloudFoundryStoreModule). Without a component- + // level override, any tab whose injector chain falls back to root + // (Events, Variables, etc.) hits "get action for entity endpoint has + // no guid" and the page blanks. Provide both at the application-base + // level so the entire app-detail subtree gets a properly-initialized + // instance derived from the route's :endpointId param. + { + provide: ActiveRouteCfOrgSpace, + useFactory: (cfGuid: string) => ({ cfGuid }), + deps: [CF_GUID] + }, + CloudFoundryEndpointService, + // AppLifecycleStateService is the leaf shared-state holder for the + // "is a write in flight" flag. AppDetailDataService reads it for poll + // cadence; AppApplicationActionsService writes it. Provided here so + // both services see the same instance. + AppLifecycleStateService, + // AppApplicationActionsService and AppLifecycleProgressService must be + // provided at THIS level (not a deeper one like application-tabs-base) + // because AppDetailDataService — also provided here — needs the action + // service available in the same injector subtree. + AppApplicationActionsService, + AppLifecycleProgressService, + // AppDetailDataService is component-scoped — its signals live for the + // lifetime of the app-detail subtree only. Providing it here means each + // navigation to a different app gets a fresh instance and signals from + // the previous app are torn down cleanly. + AppDetailDataService, + // AppDeleteSelectionService is providedIn:'root' (see service file for + // why the root scope is required) — no component-level provider here. +]; + @Component({ selector: 'app-application-base', templateUrl: './application-base.component.html', @@ -30,56 +87,7 @@ export function getGuids(type?: string) { imports: [ RouterModule ], - providers: [ - { - provide: CF_GUID, - useFactory: getGuids('cf'), - deps: [ActivatedRoute] - }, - { - provide: APP_GUID, - useFactory: getGuids(), - deps: [ActivatedRoute] - }, - // ApplicationService is the facade shim. Provide as a plain class so - // Angular's DI handles construction in a proper injection context; - // the legacy useFactory wrapper triggered NG0201 inside the inject() - // field initializers under some lifecycle orderings. - ApplicationService, - // CloudFoundryEndpointService is providedIn:'root' but its constructor - // calls getEntityService(this.cfGuid) at construction time. The cfGuid - // comes from ActiveRouteCfOrgSpace, which the root injector only has as - // an empty `{}` (from CloudFoundryStoreModule). Without a component- - // level override, any tab whose injector chain falls back to root - // (Events, Variables, etc.) hits "get action for entity endpoint has - // no guid" and the page blanks. Provide both at the application-base - // level so the entire app-detail subtree gets a properly-initialized - // instance derived from the route's :endpointId param. - { - provide: ActiveRouteCfOrgSpace, - useFactory: (cfGuid: string) => ({ cfGuid }), - deps: [CF_GUID] - }, - CloudFoundryEndpointService, - // AppLifecycleStateService is the leaf shared-state holder for the - // "is a write in flight" flag. AppDetailDataService reads it for poll - // cadence; AppApplicationActionsService writes it. Provided here so - // both services see the same instance. - AppLifecycleStateService, - // AppApplicationActionsService and AppLifecycleProgressService must be - // provided at THIS level (not a deeper one like application-tabs-base) - // because AppDetailDataService — also provided here — needs the action - // service available in the same injector subtree. - AppApplicationActionsService, - AppLifecycleProgressService, - // AppDetailDataService is component-scoped — its signals live for the - // lifetime of the app-detail subtree only. Providing it here means each - // navigation to a different app gets a fresh instance and signals from - // the previous app are torn down cleanly. - AppDetailDataService, - // AppDeleteSelectionService is providedIn:'root' (see service file for - // why the root scope is required) — no component-level provider here. - ] + providers: APP_DETAIL_PROVIDERS, }) export class ApplicationBaseComponent implements OnInit { private readonly dataService = inject(AppDetailDataService); diff --git a/src/frontend/packages/cloud-foundry/src/public_api.ts b/src/frontend/packages/cloud-foundry/src/public_api.ts index 65bc4e0ed7..2fb2c896ef 100644 --- a/src/frontend/packages/cloud-foundry/src/public_api.ts +++ b/src/frontend/packages/cloud-foundry/src/public_api.ts @@ -17,7 +17,7 @@ export { CloudFoundryService } from './shared/data-services/cloud-foundry.servic export { CfOrgSpaceLabelService } from './shared/services/cf-org-space-label.service'; // Utility Functions -export { getGuids } from './features/applications/application/application-base.component'; +export { APP_DETAIL_PROVIDERS, getGuids } from './features/applications/application/application-base.component'; // Service Instance Constants export { CSI_CANCEL_URL } from './shared/components/add-service-instance/csi-mode.service';