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. 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/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', 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)), 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/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/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..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,5 +1,6 @@ - - @if (appAutoscalerPolicy$ | async; as policy) { + +
+ @if (appAutoscalerPolicy(); as policy) { } - } @else { + } @else if (noPolicy()) { } - +
- @if (appAutoscalerPolicy$ | async; as policy) { + @if (appAutoscalerPolicy(); as policy) { @if (policy.enabled) { @@ -36,20 +37,20 @@ } @if (policy.enabled) { - @if (policy.scaling_rules.length === 0) { + @if (!policy.scaling_rules?.length) { } - @if (policy.scaling_rules.length > 0) { + @if (policy.scaling_rules?.length) { Latest Metrics
- @for (metric of appAutoscalerAppMetricNames$ | async; track metric) { + @for (metric of appAutoscalerAppMetricNames(); track metric) {
- @if (appAutoscalerAppMetrics()[metric.name] | async; as metricData) { + @if (appAutoscalerAppMetrics()[metric.name]?.(); as metricData) { - +
- +
} } } - @if (appAutoscalerPolicy$ | async; as policy) { + @if (appAutoscalerPolicy(); as policy) {
@if (policy.enabled) { @@ -252,7 +253,7 @@
- @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) {
No recurring schedule.
@@ -265,14 +266,14 @@ }
} - @if (showAutoscalerHistory$ | async) { + @if (showAutoscalerHistory()) { - @if ((appAutoscalerPolicy$ | async) === false) { + @if (noPolicy()) { {{noPolicyMessageFirstLine}}. {{noPolicyMessageSecondLine.text}} } - @if (appAutoscalerScalingHistory$ | async; as scalingHistory) { + @if (appAutoscalerScalingHistory(); as scalingHistory) { @@ -326,21 +327,25 @@ }
- +
+ [manualPoll]="!scalingHistoryLoading()" + [isPolling]="scalingHistoryLoading()"> - +
}
} - @if (showNoPolicyMessage$ | async) { + @if (policyError() && !appAutoscalerPolicy()) { + + + } + @if (showNoPolicyMessage()) { 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 6e6c38a572..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 @@ -75,9 +75,81 @@ describe('AutoscalerTabExtensionComponent', () => { httpMock.verify(); }); + // The data services patch their state after an awaited request, a + // microtask after the flush. + const settle = async () => { + await new Promise(resolve => setTimeout(resolve)); + await fixture.whenStable(); + }; + it('should be created', () => { expect(component).toBeTruthy(); }); + // The policy loads after the tab first renders, so the gauges must follow + // it rather than read the policy once. + it('loads the metric gauges once the policy arrives', async () => { + const httpMock = TestBed.inject(HttpTestingController); + httpMock.match(r => r.url.endsWith('/policy')).forEach(req => req.flush({ + instance_min_count: 1, + instance_max_count: 2, + scaling_rules: [{ + metric_type: 'memoryused', + breach_duration_secs: 120, + threshold: 80, + operator: '>=', + cool_down_secs: 60, + adjustment: '+1', + }], + })); + await settle(); + + expect(Object.keys(component.appAutoscalerAppMetrics())).toEqual(['memoryused']); + expect(httpMock.match(r => r.url.endsWith('/metric/memoryused')).length).toBe(1); + }); + + it('says the app has no policy above its scaling events', 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(); + 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, + total_pages: 1, + })); + await settle(); + + expect(fixture.nativeElement.textContent) + .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 006bd1977e..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 @@ -1,25 +1,39 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, Injector, OnDestroy, OnInit, Signal, computed, effect, inject, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + Injector, + OnDestroy, + OnInit, + Signal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BaseChartDirective } from 'ng2-charts'; -import { combineLatest, Observable, of } from 'rxjs'; -import { catchError, map, publishReplay, refCount, switchMap, take } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { TailwindSnackBarService, TailwindSnackBarRef, StratosTab, StratosTabType, + CardContentComponent, + CardHeaderComponent, + CardTitleComponent, + CardWrapperComponent, CurrentUserPermissionsService, ConfirmationDialogConfig, ConfirmationDialogService, CustomIconComponent, MetadataItemComponent, NoContentMessageComponent, - PageSubNavComponent, PollingIndicatorComponent, TileComponent, TileGridComponent, @@ -112,10 +126,13 @@ interface AutoscalerTabPaginationParams { imports: [ CommonModule, BaseChartDirective, - PageSubNavComponent, TileGridComponent, TileGroupComponent, TileComponent, + CardWrapperComponent, + CardHeaderComponent, + CardTitleComponent, + CardContentComponent, CardAutoscalerDefaultComponent, CardAppUsageComponent, CustomIconComponent, @@ -156,27 +173,53 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { // dispatches and the `selectDeletionInfo` chain are gone. // - Metric history reads now flow through AutoscalerMetricDataService; // the legacy GetAppAutoscalerAppMetricAction dispatch is gone. - // - Templates still bind via `*$ | async`, so the markup is unchanged — - // we bridge the data-service signals into Observables locally. - appAutoscalerPolicy$!: Observable; - appAutoscalerPolicySafe$!: Observable; - appAutoscalerScalingHistory$!: Observable; - scalingHistoryLoading$!: Observable; - appAutoscalerAppMetricNames$!: Observable; - - public showNoPolicyMessage$!: Observable; - public showAutoscalerHistory$!: Observable; + // The template binds the data-service signals directly; the policy loads + // after the first render, so everything derived from it is computed. + appAutoscalerPolicy!: Signal; + appAutoscalerScalingHistory!: Signal; + scalingHistoryLoading!: Signal; + + readonly appAutoscalerAppMetricNames = computed(() => { + const entity = this.appAutoscalerPolicy(); + // Null safety: ensure entity and scaling_rules_map exist + if (!entity?.scaling_rules_map) { + return []; + } + return Object.keys(entity.scaling_rules_map).map((name) => { + const unit = entity.scaling_rules_map[name]?.upper?.[0]?.unit + || entity.scaling_rules_map[name]?.lower?.[0]?.unit; + return { + name, + unit }; + }); + }); + + // 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; + // 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; + + public readonly showAutoscalerHistory = computed(() => { + const history = this.appAutoscalerScalingHistory(); + return !!this.appAutoscalerPolicy() || (!!history && history.length > 0); + }); + public readonly showNoPolicyMessage = computed(() => { + const history = this.appAutoscalerScalingHistory(); + 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; private appAutoscalerScalingHistorySnackBarRef!: TailwindSnackBarRef; // Signal: written after the first render; an OnPush view under zoneless CD only repaints for signal writes. - readonly appAutoscalerAppMetrics = signal>>({}); + readonly appAutoscalerAppMetrics = signal>>({}); paramsMetrics: AutoscalerTabPaginationParams = { 'start-time': ((new Date()).getTime() - 60000).toString() + '000000', @@ -214,78 +257,35 @@ 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 two Observable - // wrappers preserve the legacy semantic split: - // appAutoscalerPolicy$ emits null while loading/no-policy and - // the policy local form when loaded. - // appAutoscalerPolicySafe$ used to wait for entity (waitForEntity$); - // here we filter out the in-flight state so - // downstream scaling-rule mapping only fires - // once we have a settled value. With the - // data service that's the same observable — - // it emits null until load() resolves and - // then the policy. - void this.policyData.load(cfGuid, appGuid); - const policySig = this.policyData.policy(cfGuid, appGuid); - this.appAutoscalerPolicy$ = toObservable(policySig, { injector: this.injector }).pipe( - publishReplay(1), - refCount(), - ); - this.appAutoscalerPolicySafe$ = this.appAutoscalerPolicy$; - - this.loadLatestMetricsUponPolicy(); + // 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); - this.appAutoscalerAppMetricNames$ = this.appAutoscalerPolicySafe$.pipe( - map(entity => { - // Null safety: ensure entity and scaling_rules_map exist - if (!entity?.scaling_rules_map) { - return []; - } - return Object.keys(entity.scaling_rules_map).map((name) => { - const unit = entity.scaling_rules_map[name]?.upper?.[0]?.unit - || entity.scaling_rules_map[name]?.lower?.[0]?.unit; - return { - name, - unit }; - }); - }), - ); + // The gauges follow the policy: it lands after the first render on a + // first visit, and changes when the policy is edited or detached. + effect(() => { + const policy = this.appAutoscalerPolicy(); + untracked(() => this.loadLatestMetrics(policy)); + }, { injector: this.injector }); // Signal-native history wiring. fetchScalingHistory() refreshes // paramsHistory.end-time and triggers load(); the data service caches - // results per (cnsi, app). The polling indicator binds via - // scalingHistoryLoading$. - this.appAutoscalerScalingHistory$ = toObservable( - this.scalingHistoryData.events(cfGuid, appGuid), - { injector: this.injector }, - ).pipe(publishReplay(1), refCount()); - this.scalingHistoryLoading$ = toObservable( - this.scalingHistoryData.loading(cfGuid, appGuid), - { injector: this.injector }, - ).pipe(publishReplay(1), refCount()); - this.fetchScalingHistory(); + // results per (cnsi, app). + this.appAutoscalerScalingHistory = this.scalingHistoryData.events(cfGuid, appGuid); + this.scalingHistoryLoading = this.scalingHistoryData.loading(cfGuid, appGuid); + // 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(); - - this.showAutoscalerHistory$ = combineLatest([ - this.appAutoscalerPolicy$, - this.appAutoscalerScalingHistory$ - ]).pipe( - map(([policy, history]) => !!policy || (!!history && history.length > 0)), - publishReplay(1), - refCount() - ); - - this.showNoPolicyMessage$ = combineLatest([ - this.appAutoscalerPolicy$, - this.appAutoscalerScalingHistory$ - ]).pipe( - map(([policy, history]) => !policy && (!history || history.length === 0)), - publishReplay(1), - refCount() - ); } - getAppMetric(metricName: string, trigger: AppScalingTrigger, params: AutoscalerTabPaginationParams): Observable<{ entity: AppAutoscalerMetricDataLocal }[]> { + getAppMetric(metricName: string, trigger: AppScalingTrigger, params: AutoscalerTabPaginationParams): Signal<{ entity: AppAutoscalerMetricDataLocal }[]> { const cfGuid = this.applicationService.cfGuid; const appGuid = this.applicationService.appGuid; const queryParams: AutoscalerMetricQueryParams = { @@ -304,7 +304,7 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { void this.metricData.load(cfGuid, appGuid, metricName, queryParams); const rawSig = this.metricData.metrics(cfGuid, appGuid, metricName); - const localSig = computed<{ entity: AppAutoscalerMetricDataLocal }[]>(() => { + return computed<{ entity: AppAutoscalerMetricDataLocal }[]>(() => { const resources = rawSig(); if (!resources || resources.length === 0) { // Match the legacy "no metric data" emission so the template's @@ -322,60 +322,31 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { ); return [{ entity: local }]; }); - - return toObservable(localSig, { injector: this.injector }).pipe( - publishReplay(1), - refCount(), - ); } loadLatestMetricsUponPolicy() { - this.appAutoscalerPolicySafe$.pipe( - take(1), - ).subscribe(appAutoscalerPolicy => { - // Null safety: ensure policy exists before processing - if (!appAutoscalerPolicy) { - return; - } - this.paramsMetrics['start-time'] = ((new Date()).getTime() - 60000).toString() + '000000'; - this.paramsMetrics['end-time'] = (new Date()).getTime().toString() + '000000'; - if (appAutoscalerPolicy.scaling_rules_map) { - this.appAutoscalerAppMetrics.set(Object.keys(appAutoscalerPolicy.scaling_rules_map).reduce((metricMap: Record>, metricName: string) => { - metricMap[metricName] = this.getAppMetric(metricName, appAutoscalerPolicy.scaling_rules_map[metricName], this.paramsMetrics); - return metricMap; - }, {})); - } - }); + this.loadLatestMetrics(this.appAutoscalerPolicy()); + } + + private loadLatestMetrics(appAutoscalerPolicy: AppAutoscalerPolicyLocal | null) { + // Null safety: ensure policy exists before processing + if (!appAutoscalerPolicy) { + return; + } + this.paramsMetrics['start-time'] = ((new Date()).getTime() - 60000).toString() + '000000'; + this.paramsMetrics['end-time'] = (new Date()).getTime().toString() + '000000'; + if (appAutoscalerPolicy.scaling_rules_map) { + this.appAutoscalerAppMetrics.set(Object.keys(appAutoscalerPolicy.scaling_rules_map).reduce((metricMap: Record>, metricName: string) => { + metricMap[metricName] = this.getAppMetric(metricName, appAutoscalerPolicy.scaling_rules_map[metricName], this.paramsMetrics); + return metricMap; + }, {})); + } } initErrorSub() { 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. @@ -467,7 +438,7 @@ export class AutoscalerTabExtensionComponent implements OnInit, OnDestroy { this.applicationService.cfGuid, this.applicationService.appGuid, { ...this.paramsHistory }, - ); + ).catch(() => undefined); } getMetricUnit(metricType: string, unit?: string) { 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; } 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; let stubSignalConfig: ReturnType; + // 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; beforeEach(async () => { stubSignalConfig = makeStubSignalConfigService(); @@ -127,9 +131,17 @@ describe('ApplicationWallComponent', () => { ] }).compileComponents(); + canCreate$ = new Subject(); + 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)(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/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/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..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,8 +1,12 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { HttpRequest } from '@angular/common/http'; +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 +17,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 +39,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 +75,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 +94,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 +109,113 @@ 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', () => { + 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 instances accordion', () => { - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('app-instances-accordion')).toBeTruthy(); + // 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'); + }); }); - 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 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; + + 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(isRepo); + const commitRequests = httpMock.match(isCommit); + 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(isRepo).length).toBe(0); + expect(httpMock.match(isCommit).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..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, 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, @@ -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); @@ -147,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( @@ -183,7 +192,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.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: 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), 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, LoadingPageComponent, NoContentMessageComponent] }, + }) + .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(); - - fixture = TestBed.createComponent(GitSCMTabComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('without a deployed project', () => { + beforeEach(async () => { + await configure(EMPTY, null); + fixture = TestBed.createComponent(GitSCMTabComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + 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 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; + + // 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); + + 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('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(); + + // 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)'); + + fixture.destroy(); + 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 () => { + 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'); + }); + + 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 }); + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + // 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 b577e848c4..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,18 +30,8 @@ import { GithubCommitAuthorComponent, } from '@stratosui/git'; import { getUnixTime } from 'date-fns'; -import { Observable, Subscription, combineLatest, of } from 'rxjs'; -import { - 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'; @@ -87,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.' @@ -122,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 { @@ -148,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). @@ -170,91 +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.hasRepo$ = this.gitData.getRepository(baseGitMeta.scm, baseGitMeta.projectName).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) - ); - - const blockedOnRepo$: Observable<[EnvVarStratosProject, GitMeta]> = this.hasRepo$.pipe( - filter(hasRepo => !!hasRepo), - switchMap(() => coreInfo$) - ); - - this.gitSCMRepo$ = blockedOnRepo$.pipe( - switchMap(([, meta]) => this.gitData.getRepository(meta.scm, meta.projectName).waitForValue$) - ); + this.icon.set(scm.getIcon()); + // Read once and held: a failed lookup leaves the cache, so a second + // 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(() => coreInfo$), - switchMap(([, meta]) => this.gitData.getRepository(meta.scm, meta.projectName).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()); + } - 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$ - ) - ); - // 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)); - }); - 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$ - ), - 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()), - ); - }); + 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 { @@ -296,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; @@ -343,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'); } } 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; 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..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 @@ -1,16 +1,124 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { Injector } from '@angular/core'; +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'; 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(Injector)); + 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).toBe(true); + 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).toBe(false); + 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('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' }] })); + 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(); + }); + }); + + 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', () => { @@ -18,7 +126,38 @@ 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'); }); + + 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..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 @@ -1,5 +1,4 @@ -import { Injector, signal, WritableSignal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +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'; @@ -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; @@ -104,9 +76,9 @@ export class DeployApplicationDeployer { connectSub!: Subscription; updateSub!: Subscription; msgSub!: Subscription; - streamTitle = 'Preparing...'; - // strict: populated from the MANIFEST websocket event before any read - appData!: AppData; + streamTitle = signal('Preparing...'); + // From the MANIFEST websocket event; unset until the deploy sends it + appData?: AppData; proxyAPIVersion = environment.proxyAPIVersion; cfGuid!: string; orgGuid!: string; @@ -131,12 +103,29 @@ export class DeployApplicationDeployer { private inputStream: any; + private deployState$: Observable; + private isOpen = false; // The backend can follow a specific close message with a generic one; // 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; @@ -145,18 +134,22 @@ export class DeployApplicationDeployer { constructor( public cfOrgSpaceService: CfOrgSpaceDataService, - private injector: Injector, + private injector: EnvironmentInjector, ) { this.status$ = createSignalWrapper({ error: false, deploying: false - }, this.injector); + }, alwaysNotify); - this.applicationGuid$ = createSignalWrapper(null, this.injector); + this.applicationGuid$ = createSignalWrapper(null, alwaysNotify); - this.fileTransferStatus$ = createSignalWrapper(undefined, this.injector); + this.fileTransferStatus$ = createSignalWrapper(undefined, alwaysNotify); - this.messages = createSignalWrapper('', this.injector); + this.messages = createSignalWrapper('', alwaysNotify); + + // 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) { @@ -180,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() { @@ -199,12 +194,29 @@ 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.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); + // 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 + // 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; @@ -251,18 +263,24 @@ 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(); // 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) { @@ -273,13 +291,14 @@ export class DeployApplicationDeployer { } deploy() { + this.started = true; // After the source has been sent, acknowledge the wait const msg = { message: '', 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) => { @@ -377,8 +396,8 @@ export class DeployApplicationDeployer { processWebSocketMessage = (log: any) => { switch (log.type) { case SocketEventTypes.MANIFEST: - this.streamTitle = 'Starting deployment...'; - // This info is will be used to retrieve the app Id + this.streamTitle.set('Starting deployment...'); + // This info will be used to retrieve the app Id this.appData = JSON.parse(log.message).Applications[0]; break; case SocketEventTypes.APP_GUID_NOTIFY: @@ -386,19 +405,19 @@ 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; 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: @@ -431,13 +450,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: @@ -457,6 +476,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) { @@ -473,31 +494,36 @@ 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); } } - private onClose(log: any, title: string | null, error: any) { + private onClose(log: any, title: string | null, error: string | null) { if (this.closeReported) { return; } this.closeReported = true; if (title) { - this.streamTitle = title; + this.streamTitle.set(title); } this.deploying = false; this.updateStatus( - error, - error ? `${error}\nReason: ${log.message}` : undefined + !!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-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..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 @@ -1,17 +1,42 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, afterEach, 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 { 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, 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"; +// Each connection gets a server stream the test drives; what the client +// 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) => { + const sentHere: unknown[] = []; + socket.sentOn.push(sentHere); + input.subscribe(m => { + socket.sent.push(m); + sentHere.push(m); + }); + return socket.server; + }); + }, +})); + describe('DeployApplicationStepSourceUploadComponent', () => { let component: DeployApplicationStepSourceUploadComponent; let fixture: ComponentFixture; @@ -54,4 +79,187 @@ describe('DeployApplicationStepSourceUploadComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('deployer ownership on destroy', () => { + let closeSpy: ReturnType; + + 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 while only the upload has run', () => { + fixture.destroy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('closes a handed-on deployer the deploy step never started', () => { + component.onNext(0, null as any).subscribe(); + fixture.destroy(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('leaves the deployer open once the deploy step started it, before the push', () => { + component.onNext(0, null as any).subscribe(); + 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(); + }); + }); + + 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 = []; + socket.sentOn = []; + 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(); + }); + + 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(() => 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(); + }); + + 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 () => { + // 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.deploy(); + 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 c203ebd5a6..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, 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'; +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'; @@ -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; @@ -35,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), ); } @@ -49,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(); } @@ -61,8 +69,10 @@ export class DeployApplicationStepSourceUploadComponent implements OnDestroy { return observableOf({ success: true, data: this.deployer }); } + // 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) { + if (this.deployer && !this.deployer.inFlight) { this.deployer.close(); } } 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..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}}
@@ -139,13 +139,13 @@ }
- @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.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; - } - } } } 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..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 @@ -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,162 @@ 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(); + }); + }); + + // 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('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)); + 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 7ed3b2711a..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 @@ -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'; @@ -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'; @@ -89,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) @@ -209,20 +216,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.redeployCommitSha : 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, @@ -366,16 +390,22 @@ export class DeployApplicationStep2Component if (this.isRedeploy) { const commitSha = commit || branch.commit?.sha; + this.redeployCommitSha = commitSha; 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. 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.set(p), + error: () => this.commitInfo.set(undefined), + }); } } }) @@ -397,7 +427,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(''); 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 37f20778a2..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 @@ -1,15 +1,41 @@ -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, EnvironmentInjector, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter, Router } from '@angular/router'; +import { NEVER, Observable, Subject, of } 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 { + GitAppDetails, + 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"; +// 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((input: Observable) => { + const sent: unknown[] = []; + socket.sentOn.push(sent); + input.subscribe(m => sent.push(m)); + return socket.server; + }); + }, +})); + describe('DeployApplicationStep3Component', () => { let component: DeployApplicationStep3Component; let fixture: ComponentFixture; @@ -32,10 +58,462 @@ describe('DeployApplicationStep3Component', () => { fixture = TestBed.createComponent(DeployApplicationStep3Component); component = fixture.componentInstance; - fixture.detectChanges(); + 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(); + }; + + // 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(); }); + + it('shows the manifest title before the push starts', async () => { + await enterConnected(); + 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(req => req.url.startsWith('/pp/v1/cf/apps/')); + }); + + 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); + + await enterConnected(); + 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: '' }); + 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); + 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'); + }); + + 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 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); + deployData.saveAppOverrides({} as OverrideAppDetails); + component.onEnter(undefined as any); + await fixture.whenStable(); + + expect(component.deployer.finished).toBe(true); + expect(component.deployer.status$().error).toBe(true); + expect(locked()).toBe(false); + }); + }); + + 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'; + + // 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('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('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('redeployAppGuid', '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'); + }); + }); + + 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(); + 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 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); + + await enterConnected(); + 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(); + }); + + 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); + + // 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); + 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(); + }); + + 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 3b1a77a31d..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 @@ -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, ChangeDetectionStrategy, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, @@ -38,15 +38,22 @@ 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); - @Input() appGuid!: string; + // The app being redeployed; none on a first deploy + @Input() 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 + // 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 @@ -54,42 +61,35 @@ 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 + private appNotFoundSubject = new BehaviorSubject(false); 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(); - // 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()) ); - 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')) @@ -113,35 +113,26 @@ 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)) ); 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); + 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 + // reported, so the user isn't trapped on the step. + this.disablePreviousSubject.next(!this.deployer.finished); }) ); - 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 - // 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. // @@ -152,28 +143,50 @@ 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), - ); - }), - ).subscribe(guid => { - this.deployer.applicationGuid$.next(guid); + switchMap(() => this.lookUpAppGuid()), + ).subscribe({ + next: guid => { + if (guid) { + this.deployer.applicationGuid$.next(guid); + } else { + this.appNotFoundSubject.next(true); + this.showAppNotFound(); + } + }, + error: () => { + this.appNotFoundSubject.next(true); + 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; + 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)?.guid), + take(1), + ); + } + private destroyDeployer() { safeUnsubscribe(...this.subscriptions); this.subscriptions = []; @@ -183,24 +196,41 @@ 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) { 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({ + next: guid => { + if (guid) { + this.appGuid = guid; + this.goToAppSummary(); + } else { + this.showAppNotFound(); + } + }, + error: () => this.showAppLookupFailed(), + }); } this.deployer.close(); }); @@ -220,9 +250,10 @@ 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.error.set(false); + this.appNotFoundSubject.next(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) { @@ -231,17 +262,31 @@ 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(); } - 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(); }; 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 }}

- + 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..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 @@ -119,6 +119,38 @@ 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); + }); + + // 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 54cb33e8c6..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; // 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('Deploy'); @@ -298,6 +299,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,17 +364,19 @@ 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. + // The deployer is kept: entering again after a file deploy ended (via + // Previous to the overrides) uploads the same files again and deploys. + // Previous from here leads back to the overrides, whose Next then + // deploys again. onEnter: () => { - const deployer = this.pendingDeployer; - this.pendingDeployer = undefined; - this._step3?.onEnter(deployer as any); + this.deployButtonTextSignal.set('Deploy again'); + this._step3?.onEnter(this.pendingDeployer as any); }, 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. showBusy: this.step3DisablePrevious.asReadonly(), cancelButtonText: signal('Close').asReadonly(), finishButtonText: signal('Go to App Summary').asReadonly(), diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.html index 8e60db523d..e4d4028d1e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/ssh-application/ssh-application.component.html @@ -2,13 +2,13 @@

SSH (#{{ instanceId }})

- @if (sshViewer.isConnected) { - } - @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/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/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-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/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/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/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..dd79e6fb8a --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.spec.ts @@ -0,0 +1,108 @@ +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>; + let quota: ReturnType>; + // 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(null); + quota = signal(null); + quotaReads = 0; + lookups = 0; + const countedQuota = (() => { + quotaReads++; + return quota(); + }) as Signal; + + 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: () => { lookups++; return { 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 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(); + } + + 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(); + }); + + 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 1158a968c2..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 @@ -2,8 +2,9 @@ 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'; +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'; @@ -232,9 +233,14 @@ 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. 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..33794868d0 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-space.service.spec.ts @@ -0,0 +1,105 @@ +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>; + let quota: ReturnType>; + // 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(null); + quota = signal(null); + quotaReads = 0; + lookups = 0; + const countedQuota = (() => { + quotaReads++; + return quota(); + }) as Signal; + + 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: () => { lookups++; return { 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 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(); + } + + 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(); + }); + + 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 c061d32766..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 @@ -1,7 +1,8 @@ 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'; @@ -142,10 +143,16 @@ 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), + // A rename patches the space but keeps its quota + distinctUntilChanged(), 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 }), ); 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; @@ -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; @@ -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/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/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/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/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..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'; @@ -107,6 +109,94 @@ 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('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('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 e643d8f78b..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,15 +9,14 @@ import { take, filter, map, mergeMap, - publishReplay, reduce, - refCount, shareReplay, startWith, switchMap, } from 'rxjs/operators'; import { naturalCompare } from '@stratosui/core'; +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'; @@ -55,6 +53,7 @@ export class CfRolesService { private userPerms = inject(CurrentUserPermissionsService); private rolesData = inject(CfUsersRolesDataService); private http = inject(HttpClient); + private injector = inject(Injector); existingRoles$: Observable; @@ -77,11 +76,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 @@ -115,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( @@ -309,18 +312,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]; @@ -336,12 +343,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 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 2477e42339..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(), @@ -127,7 +155,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()) { @@ -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(); } 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/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) { - + Recently updated applications @if (canRefresh) { - } @@ -18,7 +18,7 @@ @if (apps.length > 0) {
@for (app of apps; track app.metadata.guid) { - + }
} @@ -27,14 +27,14 @@
} } @else { - + Recently updated applications
- @for (app of placeholders; track app.metadata.guid) { - + @for (row of placeholders(); track row) { + }
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; + 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..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,14 +1,11 @@ -import { Component, EventEmitter, Injector, Input, OnInit, Output, Signal, ChangeDetectionStrategy, computed, inject } 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 { 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'; import { PollingIndicatorComponent, @@ -45,115 +42,69 @@ 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[]>; - @Input() allApps$!: Observable[]>; - @Input() loading$!: Observable; + readonly allApps$ = input[]>>(); + readonly loading$ = input>(); @Output() refresh = new EventEmitter(); - @Input() endpoint!: string; - @Input() mode!: string; - @Input() showDate = true; - @Input() dateMode!: string; - @Input() noStats = false; - @Input() placeholderMode = false; - @Input() hideWhenEmpty = false; - + readonly endpoint = input(); + readonly mode = input(); + readonly showDate = input(true); + readonly dateMode = input(); + // 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; show$!: Observable; + // The loading$ input; not loading when none is given. + appsLoading$!: Observable; - private readonly registry = inject(EndpointDataRegistry); - private readonly statsRegistry = inject(AppStatsDataRegistry); - private readonly injector = inject(Injector); - private statsRequested = new Set(); - - private maxRowsSubject = new BehaviorSubject(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$) { - // 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); - const appResources: Signal[]> = computed( - () => endpointData.apps().map(stAppToAPIResource), - ); - this.allApps$ = toObservable(appResources, { injector: this.injector }); - this.loading$ = toObservable(endpointData.isLoadingDetails, { injector: this.injector }); - this.hasEntities$ = toObservable( - computed(() => endpointData.apps().length > 0), - { injector: this.injector }, - ); - } else { - this.hasEntities$ = of(true); - } + // Every live caller passes allApps$ (home, CF, org and space summaries) + const allApps$ = this.allApps$() ?? of([]); + this.appsLoading$ = this.loading$() ?? of(false); + 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[]) { - 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[], maxRows = RECENT_ITEMS_COUNT): APIResource[] { 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); } } 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) {
- @if (showDate) { -
{{ app.metadata.updated_at | date:'medium' }}
+ @if (showDate()) { +
{{ app.metadata.updated_at | date:'medium' }}
}
} @else { @@ -14,9 +14,8 @@
- @if (showDate) { -
+ @if (showDate()) { +
}
} - 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; + let stats: { + stats: ReturnType>; + lastFetched: ReturnType>; + load: ReturnType; + }; + + beforeEach(() => { + stats = { + stats: signal([{ state: 'RUNNING' }]), + lastFetched: signal(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(); - @Input() app!: any; + readonly endpoint = input(); - @Input() endpoint!: string; + readonly showDate = input(true); + readonly dateMode = input(); + // 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 = signal({ label: '', indicator: StratosStatus.NONE, actions: null }); + readonly bcType = this.activeRouteCfOrgSpace ? breadcrumbType(this.activeRouteCfOrgSpace) : undefined; - appStatus: Signal = 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(() => { + 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 + }; } 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 @@ - + - + - @if (layout.x <= 2) { + @if (cardLayout().x <= 2) { - + } - @if (cardLoaded && recentAppsRows > 0) { + @if (cardLoaded() && recentAppsRows() > 0) { + dateMode="subtle" [showDate]="true" [maxRows]="recentAppsRows()" mode="plain" + [endpoint]="guid()" [allApps$]="allApps$"> } - @if (!cardLoaded && recentAppsRows > 0) { + @if (!cardLoaded() && recentAppsRows() > 0) { + [maxRows]="recentAppsRows()" mode="plain"> } - @if ((hasNoApps$ | async)) { + @if (hasNoApps()) {
apps
You don't have any applications
- @if (showDeployAppTiles) { + @if (showDeployAppTiles()) {
Get started by deploying an Application from ...
{ @@ -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) {{{ app.entity.name }}:{{ app.entity.state }}}`, +}) +class StubRecentAppsComponent { + @Input() allApps$!: Observable; + @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; + let eds: { + appCount: ReturnType>; + orgCount: ReturnType>; + routeCount: ReturnType>; + recentApps: ReturnType>; + lastFetched: ReturnType>; + loaded$: Subject; + }; + + 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(new Date()), + loaded$: new Subject(), + }; + 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(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; - allApps$!: Observable[]>; - 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[]>(() => + 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[]>; 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 = { - [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 { - 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[]), - ); - this.hasNoApps$ = afterLoad$.pipe( - map(() => this.endpointDataService!.appCount() === 0), - ); - - if (this.endpointDataService.lastFetched() !== null) { + if (endpointDataService.lastFetched() !== null) { return of(true); } return new Observable(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 { - const svc = this.endpointDataService!; - return svc.lastFetched() !== null - ? of(undefined as void) - : svc.loaded$.pipe(take(1)); - } - } 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'; 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(); + private readonly instances = new Map(); 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/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/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..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 @@ -703,6 +703,99 @@ 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 { + 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']); + }); + + // 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', () => { 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..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 @@ -20,6 +20,15 @@ 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; + +// 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(0); @@ -172,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 { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'load' }); // Warm-cache short-circuit: signals already populated, no network needed. @@ -348,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); @@ -451,8 +462,15 @@ 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)); + // 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 { @@ -461,11 +479,23 @@ 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): 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); + } + } + + private upsertRecentApp(app: StApp): void { + this._recentApps.update(curr => newestApps([app, ...curr.filter(a => a.guid !== app.guid)])); } removeServiceInstance(guid: string): void { @@ -541,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); 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(); + private readonly instances = new Map(); 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(); + private readonly instances = new Map(); 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 { 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 { 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/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..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 @@ -1,19 +1,52 @@ 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, of } 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(null); + readonly loaded = new Subject(); + // One entry per load() subscription; `closed` flips when it ends. + loads: { key: string, closed: boolean }[] = []; + + acquire(cnsi: string, spaceGuid: string) { + const key = `${cnsi}/${spaceGuid}`; + return { + space: this.space.asReadonly(), + // Reads `space` the way the real load() checks its cache. + load: (): Observable => this.space() ? of(undefined) : new Observable(subscriber => { + const load = { key, closed: false }; + this.loads.push(load); + const inner = this.loaded.subscribe(subscriber); + return () => { + load.closed = true; + inner.unsubscribe(); + }; + }), + }; + } +} describe('TableCellServiceBrokerComponent', () => { let component: TableCellServiceBrokerComponent; let fixture: ComponentFixture; let lastBrokerLookup: { cnsiGuid: string; brokerGuid: string } | null; + let lateBroker: ReturnType> | null; + let spaceStub: SpaceDataRegistryStub; beforeEach(async () => { lastBrokerLookup = null; + lateBroker = null; + spaceStub = new SpaceDataRegistryStub(); const serviceCatalogStub: Partial = { serviceBroker: (cnsiGuid: string, brokerGuid: string): SignalSource => { lastBrokerLookup = { cnsiGuid, brokerGuid }; @@ -29,7 +62,7 @@ describe('TableCellServiceBrokerComponent', () => { updatedAt: '', }; return { - value: signal(broker).asReadonly(), + value: (lateBroker ?? signal(broker)).asReadonly(), isLoading: signal(false).asReadonly(), error: signal(null).asReadonly(), }; @@ -39,7 +72,9 @@ describe('TableCellServiceBrokerComponent', () => { await TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), + provideRouter([]), { provide: ServiceCatalogDataService, useValue: serviceCatalogStub }, + { provide: SpaceDataRegistry, useValue: spaceStub }, ], imports: [TableCellServiceBrokerComponent], }).compileComponents(); @@ -48,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', @@ -63,9 +98,87 @@ 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'); }); + + it('renders the space link once the broker and its space land', async () => { + lateBroker = signal(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(); + + // 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(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 d72bdf9ba1..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, OnDestroy, 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'; @@ -35,22 +35,30 @@ interface SpaceLink { }) export class TableCellServiceBrokerComponent extends TableCellCustom implements OnInit, OnDestroy { + TableCellServiceBrokerComponentConfig> implements OnInit { private serviceCatalog = inject(ServiceCatalogDataService); private spaceRegistry = inject(SpaceDataRegistry); private injector = inject(Injector); private readonly _brokerSource = signal | null>(null); - private _spaceData: SpaceDataService | null = null; - private _spaceKey: { cnsi: string, guid: string } | null = null; + // A signal so `spaceLink` re-evaluates when the effect swaps it in. + private readonly _spaceData = signal(null); readonly broker: Signal = 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 = computed(() => { - const space = this._spaceData?.space(); + const space = this._spaceData()?.space(); const broker = this.broker(); if (!space || !broker) return null; return { @@ -82,34 +90,24 @@ 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) { - this.spaceRegistry.release(curr.cnsi, curr.guid); - this._spaceData = null; - this._spaceKey = null; - } - if (targetKey) { - this._spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - this._spaceData.load().subscribe(); - this._spaceKey = targetKey; + effect(onCleanup => { + const key = this._spaceKey(); + if (!key) { + this._spaceData.set(null); + return; } + // 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); + }); }); }); } - - ngOnDestroy(): void { - if (this._spaceKey) { - this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData = null; - this._spaceKey = null; - } - } } 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..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 @@ -6,15 +6,18 @@ - - Name - {{ b?.name }} - + Name + {{ b?.name }} + + Broker Link - - launch - + @if (b?.url; as url) { + + {{ url }} (opens in a new tab) + + + } 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..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,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, of } 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,61 @@ 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> | null = null; + serviceBroker(_cnsi: string, _broker: string): SignalSource { return { - value: signal(this.brokerResponse).asReadonly(), + value: (this.lateBroker ?? signal(this.brokerResponse)).asReadonly(), isLoading: signal(false).asReadonly(), error: signal(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(null); + readonly loaded = new Subject(); + acquired: string[] = []; + // One entry per load() subscription; `closed` flips when it ends. + loads: { key: string, closed: boolean }[] = []; + + acquire(cnsi: string, spaceGuid: string) { + const key = `${cnsi}/${spaceGuid}`; + this.acquired.push(key); + return { + space: this.space.asReadonly(), + // Reads `space` the way the real load() checks its cache. + load: (): Observable => this.space() ? of(undefined) : new Observable(subscriber => { + const load = { key, closed: false }; + this.loads.push(load); + const inner = this.loaded.subscribe(subscriber); + return () => { + load.closed = true; + inner.unsubscribe(); + }; + }), + }; + } + + land(space: StSpace): void { + this.space.set(space); + this.loaded.next(); + } +} + describe('ServiceBrokerCardComponent', () => { let component: ServiceBrokerCardComponent; let fixture: ComponentFixture; let catalogStub: ServiceCatalogDataServiceStub; + let spaceStub: SpaceDataRegistryStub; const setup = async () => { catalogStub = new ServiceCatalogDataServiceStub(); + spaceStub = new SpaceDataRegistryStub(); await TestBed.configureTestingModule({ imports: [ServiceBrokerCardComponent], providers: [ @@ -51,6 +92,7 @@ describe('ServiceBrokerCardComponent', () => { ...STORE_TEST_PROVIDERS, importProvidersFrom(generateCfBaseTestModulesNoShared()), { provide: ServiceCatalogDataService, useValue: catalogStub }, + { provide: SpaceDataRegistry, useValue: spaceStub }, ], }).compileComponents(); @@ -58,9 +100,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()); @@ -75,6 +115,24 @@ 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('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'); @@ -91,13 +149,71 @@ 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'); 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(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(); + + // 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(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 f605f65b5d..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, OnDestroy, 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 { @@ -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,19 +59,26 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { private readonly _brokerGuid = signal(''); private readonly _brokerSource = signal | 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. - private _spaceData: SpaceDataService | null = null; - private _spaceKey: { cnsi: string, guid: string } | null = null; + // 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(null); readonly broker: Signal = 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 = computed(() => { const cfGuid = this._cfGuid(); - const space = this._spaceData?.space(); + const space = this._spaceData()?.space(); if (!space || !cfGuid) return null; return { name: space.name, @@ -102,37 +109,25 @@ export class ServiceBrokerCardComponent implements OnInit, OnDestroy { } ngOnInit(): void { - // Acquire/release 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) { - this.spaceRegistry.release(curr.cnsi, curr.guid); - this._spaceData = null; - this._spaceKey = null; - } - if (targetKey) { - this._spaceData = this.spaceRegistry.acquire(targetKey.cnsi, targetKey.guid); - this._spaceData.load().subscribe(); - this._spaceKey = targetKey; + effect(onCleanup => { + const key = this._spaceKey(); + if (!key) { + this._spaceData.set(null); + return; } + // 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); + }); }); }); } - - ngOnDestroy(): void { - if (this._spaceKey) { - this.spaceRegistry.release(this._spaceKey.cnsi, this._spaceKey.guid); - this._spaceData = null; - this._spaceKey = null; - } - } } 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..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 @@ -13,6 +13,18 @@ Loading organizations…
+ } @else if (orgsError()) { + } @else {
    @for (org of filteredAllowedOrgs(); track org.metadata.guid) { @@ -28,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.
  • + } }
} @@ -89,6 +105,18 @@

Loading spaces…

+ } @else if (spacesFailedFor(org.metadata.guid)) { + } @else { { }).compileComponents(); } - function createFixture(): ComponentFixture { + /** 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 } }, + ], + }).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"]'); + } + + 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 +101,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 +131,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 +140,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 +149,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 +177,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 +197,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 +206,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 +228,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 +250,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 +279,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 +312,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 +320,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 +346,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 +360,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 +372,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 +399,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 +435,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 +465,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 +505,7 @@ describe('RoleAssignmentComponent', () => { }, }, }); - fixture.detectChanges(); await fixture.whenStable(); - fixture.detectChanges(); const component = fixture.componentInstance; @@ -541,9 +534,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 +560,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 +572,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 +589,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 +605,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 +623,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 +641,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 +667,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 +715,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 +725,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 +736,97 @@ describe('RoleAssignmentComponent', () => { expect(component.spacesLoadingFor('o1')).toBe(false); }); - it('E: orgsLoading is false after ngOnInit resolves', async () => { + 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('You have no organizations where you can manage roles.'); + }); + + 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('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 = createFixture(); + const fixture = await createFixture(); + + fixture.componentInstance.orgSearchText.set('zzz'); await fixture.whenStable(); - fixture.detectChanges(); + + 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 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..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 @@ -113,12 +113,21 @@ 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 { 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>({}); @@ -228,13 +237,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 +251,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 { @@ -376,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); } 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; 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 | 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(null); + readonly plan: Signal = this._servicePlan.asReadonly(); + readonly planAccessibility: Signal = 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; 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).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> = computed(() => this._appStats()); - private statsTimer?: ReturnType; - // 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(); - 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 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); 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-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/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 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..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 @@ -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, @@ -10,11 +10,16 @@ import { StratosCurrentUserPermissions, } from '@stratosui/core'; import { EndpointsDataService, SessionData } from '@stratosui/store'; -import { firstValueFrom, of } from 'rxjs'; -import { describe, expect, it, beforeEach } from 'vitest'; +import { firstValueFrom, Observable, of, timeout } from 'rxjs'; +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 { CfPermissionStrings, CfPermissionTypes, CfScopeStrings } 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,116 @@ 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]); + }); +}); + +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); + }); + + 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 be9c679754..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 @@ -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 { 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'; @@ -206,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, guid)))), map(checks => checks.some(check => check)), distinctUntilChanged() ); @@ -241,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() ); @@ -270,13 +269,17 @@ 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)); + return combineLatestOrEmpty(guids.map(createFFObs)); }), map(endpointFeatureFlags => endpointFeatureFlags.some(featureFlags => this.checkFeatureFlag(featureFlags, permission))), distinctUntilChanged(), 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, 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, ]; } 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 ]; 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(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; + + 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; + 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 => { 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(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; + + 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; + 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/permissions/current-user-permissions.service.spec.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts index 400714a7fb..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,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 { firstValueFrom, timeout } from 'rxjs'; +import { AppState, BaseEntityValues, CurrentUserRolesDataService, EndpointsDataService, EntityCatalogEntityConfig, EntityCatalogTestModule, EndpointModel, PaginationEntityState, TEST_CATALOGUE_ENTITIES, endpointEntityType, generateStratosEntities, stratosEntityFactory } from '@stratosui/store'; import { createBasicStoreModule, createEntityStoreState, @@ -10,8 +10,10 @@ 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 { BaseCurrentUserPermissionsChecker } from './current-user-permissions.types'; import { StratosPermissionStrings, StratosPermissionTypes, StratosScopeStrings } from './stratos-user-permissions.checker'; @@ -570,3 +572,76 @@ 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); + }); +}); + +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 8ec579e578..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,7 +1,6 @@ import { EnvironmentInjector, Injectable, inject, runInInjectionContext } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { EndpointsDataService } 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 { @@ -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 ? @@ -159,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)), 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 = 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 = observeSignal(this.auth, inject(Injector)); + readonly loggedIn: Signal = this.authData.loggedIn; readonly loggingIn: Signal = this.authData.loggingIn; readonly verifying: Signal = 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 = observeSignal(this.status, inject(Injector)); + readonly loading: Signal = computed(() => !!this.status().loading); readonly error: Signal = computed(() => !!this.status().error); readonly message: Signal = computed(() => this.status().message ?? ''); 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 - ); -} 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..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 @@ -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 check the UAA credentials.', 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/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({ busy: false, error: false, message: '' }); + const fetching = signal({ fetching: false }); + const endpoint = signal(null); + let reads = 0; + const counted = (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; 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(source: Signal): Observable { + const subject = new ReplaySubject(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 = []; } } 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 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 @@

Endpoints

- @if (!customizations.noEndpointsComponent) { + @if (customizations.noEndpointsComponent) { + + } @else { } } - - @if (!customizations.noEndpointsComponent) { - - } @else { - } - }
- @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 1d3e5d6e2c..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,10 +1,10 @@ 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'; -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"; @@ -16,14 +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: '

Welcome

' }) +class TestWelcomeComponent {} + describe('EndpointsPageComponent', () => { let component: EndpointsPageComponent; let fixture: ComponentFixture; + let perm$: Subject; + let persistence$: Subject; + let customizations: CustomizationsMetadata; // Mock services const mockSessionService = { @@ -33,16 +39,14 @@ describe('EndpointsPageComponent', () => { }; const mockEndpointsService = { - disablePersistenceFeatures$: of(false), + disablePersistenceFeatures$: new Subject(), haveRegistered$: of(false), haveConnected$: of(false), checkAllEndpoints: vi.fn() }; const mockCustomizationService = { - get: () => ({ - noEndpointsComponent: null - }) + get: () => customizations }; const mockSnackBarService = { @@ -82,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 }, @@ -97,6 +103,11 @@ describe('EndpointsPageComponent', () => { }); beforeEach(async () => { + perm$ = new Subject(); + persistence$ = new Subject(); + mockEndpointsService.disablePersistenceFeatures$ = persistence$; + customizations = { noEndpointsComponent: null }; + vi.spyOn(TestBed.inject(CurrentUserPermissionsService), 'can').mockReturnValue(perm$); fixture = TestBed.createComponent(EndpointsPageComponent); component = fixture.componentInstance; await fixture.whenStable(); @@ -107,9 +118,52 @@ 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); + }); + + 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 c98d82a0c4..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,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 { 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 { 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(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; /** @@ -93,9 +114,6 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit public showRegisterModal = false; public isInitialised$: Observable; - @ViewChild('customNoEndpointsContainer', { read: ViewContainerRef, static: true }) customNoEndpointsContainer!: ViewContainerRef; - customContentComponentRef!: ComponentRef; - private snackBarText = { message: `There are no connected endpoints, connect with your personal credentials to get started.`, action: 'Got it' @@ -106,7 +124,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(); @@ -120,36 +137,20 @@ 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) }); - // 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 @@ -199,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 { @@ -280,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) { 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 { // 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>; readonly pageSize = this.state.pageSize; readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal = 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 = 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 { 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..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 @@ -1,65 +1,137 @@ +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('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(); + + 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); }; } 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..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 @@ -1,57 +1,251 @@ +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' }); - beforeEach(() => { + 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(); + + 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', () => { + 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. + 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'); + }); + + 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', () => { + 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'); + }); + + 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); + }); + }); }); 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] }), 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(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(); }); 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) {
@if (endpoint.canShowLink) { {{ endpoint.name }} 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; @@ -41,18 +64,43 @@ describe('TableCellEndpointNameComponent', () => { // Initialize EntityCatalogHelper so components using stratosEntityCatalog..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 { private endpointsData = inject(EndpointsDataService); - private injector = inject(Injector); + private endpointId = signal(undefined); - public endpoint$!: Observable; + // 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']); } } 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) { 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"> more_vert - @if (menuOpen) { + @if (menuOpen()) {
}
- @for (metaItem of metaItems; track metaItem) { + @for (metaItem of metaItems(); track metaItem) {
@if (metaItem?.content) { 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: ` + + + Always + here + + @if (showLate()) { + + Late + arrived + + } + ` +}) +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; + // 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); } } } 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 { }) 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); }); } 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/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) { 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/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)); })); 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..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'; @@ -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<void | Partial<StepOnNextResult>>; @@ -217,11 +217,13 @@ export class StepComponent { } @Output() disablePreviousChange = new EventEmitter<boolean>(); - 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-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. 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..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 @@ -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,360 @@ 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: ` + <app-steppers> + <app-step title="One" [hidden]="firstHidden()" [blocked]="firstLegacyBlocked()" [signalHandle]="firstHandle"></app-step> + <app-step title="Two" [hidden]="secondHidden()" [signalHandle]="secondHandle"></app-step> + </app-steppers> + `, +}) +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: `<app-steppers></app-steppers>`, +}) +class EmptyStepperHostComponent { } + +describe('SteppersComponent — first step entry', () => { + let hostFixture: ComponentFixture<FirstStepHostComponent>; + 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); + }); +}); + +// 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: ` + <app-steppers> + <app-step title="One" [signalHandle]="firstHandle"></app-step> + <app-step title="Two" [signalHandle]="secondHandle"></app-step> + </app-steppers> + `, +}) +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<SignalHiddenHostComponent>; + 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']); + }); +}); + +// 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<FirstStepHostComponent>; + 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']); + }); +}); + +// 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<SignalHiddenHostComponent>; + 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 73230d9cbf..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 @@ -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,57 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { private enterData: any; private snackBarRef!: TailwindSnackBarRef<any>; - // 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<typeof setInterval>; - private isDestroyed = false; + + // Mirrors `steps` so firstStepEntry re-runs whenever the steps re-filter. + private readonly visibleSteps = signal<StepComponent[]>([]); + // The projected steps, set once in ngAfterContentInit. + private readonly contentSteps = signal<StepComponent[]>([]); + // 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 + // 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 + // 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 +156,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 +165,7 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { ngAfterContentInit() { this.allSteps = this.stepComponents.toArray(); - this.setActive(0); + this.contentSteps.set(this.allSteps); this.allSteps.forEach((step => { this.hiddenSubs.push(step.hiddenChange.subscribe((_hidden) => { @@ -154,7 +198,29 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { } private filterSteps() { + 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 + // 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) { + // 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). After every step was + // hidden the old position means nothing, so start from the first. + current.active = false; + this.enterStep(parked ? 0 : 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 // list as it was at ngAfterContentInit, so steps whose [hidden] @@ -286,35 +352,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; } @@ -329,13 +368,19 @@ 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; }); 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 <ng-content> inside 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; } 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..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 @@ -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> = {}): 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<GitRepo>(`${host}/${endpointGuid}/repos/${p}`), + getBranch: (http: HttpClient, p: string, b: string) => + http.get<GitBranch>(`${host}/${endpointGuid}/repos/${p}/branches/${b}`), + getCommit: (http: HttpClient, p: string, sha: string) => + http.get<GitCommit>(`${host}/${endpointGuid}/repos/${p}/commits/${sha}`), + }); +} + +const notFound = { status: 404, statusText: 'Not Found' }; + describe('GitDataService', () => { let service: GitDataService; @@ -133,4 +153,135 @@ 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(); + }); + + // 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' }); + }); + + // 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' } }); + }); + + // 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<void>(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 () => { + 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..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 { toObservable } from '@angular/core/rxjs-interop'; -import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { observeSignal } from '@stratosui/store'; +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,10 +28,21 @@ export interface GitResource<T> { readonly errorMessage: Signal<string>; /** Continuous state stream — equivalent to the old `entityObs$`. */ readonly state$: Observable<GitResourceState<T>>; - /** 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<T>; } +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). @@ -43,6 +54,11 @@ export interface GitResource<T> { * `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`. A failed + * lookup is dropped from the cache so the next read retries it. */ @Injectable({ providedIn: 'root' }) export class GitDataService { @@ -55,16 +71,20 @@ export class GitDataService { getRepository(scm: GitSCM, projectName: string): GitResource<GitRepo> { 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<GitBranch> { + /** + * 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<GitBranch> { 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)) ) @@ -73,7 +93,7 @@ export class GitDataService { getCommit(scm: GitSCM, projectName: string, commitSha: string): GitResource<GitCommit> { 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)) ) @@ -95,10 +115,12 @@ export class GitDataService { cache: Map<string, GitResource<T>>, guid: string, scm: GitSCM, + options: GitReadOptions, fetch: () => Observable<T> ): GitResource<T> { - const existing = cache.get(guid); - if (existing) { + const key = `${GitDataService.hostKey(scm)}::${guid}`; + const existing = cache.get(key); + if (existing && (!options.refresh || existing.fetching())) { return existing; } @@ -108,7 +130,7 @@ export class GitDataService { error: false, errorMessage: '', }); - const state$ = toObservable(state, { injector: this.injector }); + const state$ = observeSignal(state, this.injector); const resource: GitResource<T> = { value: computed(() => state().value), @@ -117,25 +139,42 @@ 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(guid, resource); + cache.set(key, resource); 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; } + /** + * 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; 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<EndpointModel[]>([{ 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)) ); } 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}`; } } 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(); } 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 c41573a556..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 @@ -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,37 +160,43 @@ 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<IAuthForm>(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 = { - authType: authType.value, - authVal: endpointFormInstance.getValues(user._authData.values), - systemShared: false, - bodyContent: endpointFormInstance.getBody() - }; - } else { - // Use values as is - data = { + // Destroy the form even when it fails to build the connect data + try { + const form = fb.group({ 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; } 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..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 @@ -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,40 @@ 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', + // 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, + _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: '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; + describe('KubeConfigImportComponent', () => { let component: KubeConfigImportComponent; let fixture: ComponentFixture<KubeConfigImportComponent>; @@ -46,13 +83,121 @@ 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<typeof vi.fn>; + + 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('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) + .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); + }); + + 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); + 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 5dd6463da3..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 @@ -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<T>(initialValue: T) { - const _signal = signal<T>(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<T>(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<T> & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable<T>; - }; -} - @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<KubeConfigImportAction, Observable<IActionMonitorComponentState>>(); + // 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<IActionMonitorComponentState> { - 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[] = []; @@ -220,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) { @@ -229,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) { @@ -274,27 +231,47 @@ 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; + } + // 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; } - const helper = new KubeConfigAuthHelper(); - const data = helper.getAuthDataForConnect(this.environmentInjector, this.injector, this.fb, connect.user); 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), 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); })); } else { connect.state.next({ message: 'Can not connect - could not get user auth data', error: true }); + this.processAction(next); } } 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<T>(initialValue: T) { - const _signal = signal<T>(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<T>(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<T> & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable<T>; - }; -} - /** * 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/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; })); 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}'`); 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 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/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 }); 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<T>(initialValue: T) { - const _signal = signal<T>(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<T> & { - next: (value: T) => void; - getValue: () => T; - asObservable: () => Observable<T>; - }; -} - export interface KubernetesNamespacesFilterItem<T = any> { list$: Observable<T[]>; loading$: Observable<boolean>; 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 77a4b91958..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 @@ -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}`; } @@ -83,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); } @@ -99,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); } } } 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<typeof signal<number>>; + 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 = <T>(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<HelmReleaseHelperService['fetchReleaseGraph']>) + .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<HelmReleaseGraph> { // 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<HelmReleaseResources> { // 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<HelmReleaseChartData> { 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) ); } 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..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 @@ -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'; @@ -128,4 +129,99 @@ 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'); + }); + + // 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')); + }); + }); + + // 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 507fd2f743..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 @@ -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, 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 @@ -48,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 @@ -62,6 +64,15 @@ export class KubeAnalysisDataService { readonly errorsSignal: Signal<StratosError[]> = this._errors.asReadonly(); readonly unavailable: Signal<string[]> = this._unavailable.asReadonly(); + constructor() { + 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); + evictErrors(this._errors, kubeGuid); + }); + } + // 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. @@ -128,9 +139,15 @@ export class KubeAnalysisDataService { private fetchReports(kubeGuid: string): Observable<AnalysisReport[]> { const url = `/pp/v1/analysis/reports/${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 (!owns()) return; this._byEndpoint.update(curr => { const next = new Map(curr); next.set(kubeGuid, { reports: items, fetchedAt: Date.now() }); @@ -139,6 +156,7 @@ export class KubeAnalysisDataService { this.clearUnavailable(kubeGuid); }), catchError((err: HttpErrorResponse | unknown) => { + 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. @@ -152,10 +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. + // 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; @@ -177,12 +196,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() }); @@ -192,20 +214,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; @@ -255,7 +280,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.spec.ts b/src/frontend/packages/kubernetes/src/services/domain-data/kube-generic-resource-data.base.spec.ts index fe2594f98e..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,6 +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 } }> { @@ -23,9 +27,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 +45,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 +59,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,9 +68,67 @@ 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'); }); }); + +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 d04d2d7ac2..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 @@ -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, 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'; @@ -38,6 +39,17 @@ 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() { + this.eviction.register(kubeGuid => { + const owned = (key: string) => key === kubeGuid || key.startsWith(`${kubeGuid}/`); + evictKeys(this._clusterItems, owned); + evictKeys(this._namespaceItems, owned); + evictKeys(this._workloadItems, owned); + evictErrors(this._errors, kubeGuid); + }); + } // ---- Read API ---------------------------------------------------------- @@ -75,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); @@ -86,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); @@ -149,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, @@ -170,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(0, 50)); + if (live()) 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..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'; @@ -132,4 +133,32 @@ 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'); + }); + // 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 f5ace81028..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,10 +1,11 @@ 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'; 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 @@ -20,20 +21,28 @@ import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.re export class KubeNamespaceDataService { private readonly http = inject(HttpClient); private readonly registry = inject(KubeEndpointDataRegistry); + private readonly eviction = inject(KubeEndpointEviction); - // 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[]>([]); + // The namespace list itself lives on the per-endpoint service, which the + // registry drops; only fetchDirect's errors are held here. + constructor() { + 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 @@ -74,8 +83,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 +94,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'; @@ -105,7 +109,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..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'; @@ -88,4 +89,52 @@ 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'); + }); + // 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 1649387764..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 @@ -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, 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 @@ -16,6 +17,7 @@ import { KubeEndpointDataRegistry } from '../endpoint-data/kube-endpoint-data.re 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 @@ -24,6 +26,13 @@ export class KubeNodeDataService { private readonly _nodes = signal<Map<string, KubeNode[]>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + constructor() { + this.eviction.register(kubeGuid => { + evictKeys(this._nodes, key => key === kubeGuid); + evictErrors(this._errors, kubeGuid); + }); + } + // Signal projecting the cached nodes for a given endpoint. Composed // via computed() so consumers re-render when the underlying map // changes. @@ -41,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); @@ -70,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}`, @@ -91,7 +105,7 @@ export class KubeNodeDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 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 1f53834954..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 @@ -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'; @@ -150,6 +151,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 @@ -202,4 +234,68 @@ 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([]); + }); + + 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 9d1c910c12..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,7 @@ 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'; // Per-resource domain service for k8s pods. Mirrors KubeNamespaceDataService @@ -30,6 +30,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[]>>(); @@ -42,7 +43,7 @@ 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: // cluster:{kubeGuid} @@ -52,6 +53,10 @@ export class KubePodDataService { private readonly _errors = signal<StratosError[]>([]); private readonly _unavailable = signal<Set<string>>(new Set()); + constructor() { + this.eviction.register(kubeGuid => this.evictEndpoint(kubeGuid)); + } + errors(): Signal<StratosError[]> { return this._errors.asReadonly(); } @@ -135,16 +140,26 @@ 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. + 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); + evictErrors(this._errors, kubeGuid, a => a === kubeGuid || owned(a)); } private invalidate(key: string): void { @@ -157,46 +172,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[]); }), ); @@ -257,9 +259,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 { @@ -269,6 +272,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..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'; @@ -78,6 +79,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({ @@ -123,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 d3878b9d04..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 @@ -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, 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 @@ -28,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()); @@ -35,6 +37,17 @@ export class KubeServiceDataService { private readonly _clusterEndpoints = signal<Map<string, KubeEndpoint[]>>(new Map()); private readonly _errors = signal<StratosError[]>([]); + constructor() { + this.eviction.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); + evictErrors(this._errors, kubeGuid); + }); + } + // ---- Read API ---------------------------------------------------------- servicesInCluster(kubeGuid: string): Signal<KubeService[]> { @@ -101,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); @@ -117,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); @@ -154,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, @@ -175,7 +194,7 @@ export class KubeServiceDataService { detail: (err as Error)?.message ?? String(err), affected: [kubeGuid], }; - this._errors.update(curr => [...curr, next].slice(0, 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-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 new file mode 100644 index 0000000000..6979a56aaa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/services/endpoint-data/kube-endpoint-eviction.ts @@ -0,0 +1,75 @@ +import { Injectable, WritableSignal, signal, untracked } 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 +// 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; + } + + // 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 +// 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; + }); +} + +// 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 dd086f2fa8..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 @@ -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,18 @@ 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 { 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'; 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 +152,201 @@ 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('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; + 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.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 2108cedd64..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 @@ -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, @@ -40,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[]>([]); @@ -61,6 +63,13 @@ export class KubeHelmDataService { ...this._chartErrors(), ]); + constructor() { + this.eviction.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[]> { @@ -100,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'), @@ -112,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)); }); @@ -287,7 +299,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 { 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..95ad30bdb7 --- /dev/null +++ b/src/frontend/packages/store/src/helpers/signal-interop.spec.ts @@ -0,0 +1,207 @@ +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<number>([]).pipe(toArray())); + expect(emitted).toEqual([[]]); + }); + + it('behaves like combineLatest for non-empty sources', () => { + const a = new Subject<number>(); + const b = new Subject<string>(); + const values: (number | string)[][] = []; + let completed = false; + + combineLatestOrEmpty<number | string>([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('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[] = []; + 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..8e027201e5 --- /dev/null +++ b/src/frontend/packages/store/src/helpers/signal-interop.ts @@ -0,0 +1,76 @@ +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. + * + * 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. + */ +export function observeSignal<T>(source: Signal<T>, injector: Injector): Observable<T> { + return new Observable<T>(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<T>(sources: Observable<T>[]): Observable<T[]> { + 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. + * It returns the same observable on every call, so a template can bind 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<T>(initialValue: T, options?: { equal?: ValueEqualityFn<T> }) { + const _signal = signal<T>(initialValue, options?.equal ? { equal: options.equal } : undefined); + const _subject = new BehaviorSubject<T>(initialValue); + const _observable = _subject.asObservable(); + 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: () => _observable, + } + ); + return wrapper as WritableSignal<T> & { + next: (value: T) => void; + getValue: () => T; + asObservable: () => Observable<T>; + }; +} 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, 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 { 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++; }, }; 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) + } + }) + } +} 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",