From 3a96e524418b60c69ef47408f4ca15d917ab4e46 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 01:21:25 -0700 Subject: [PATCH 1/9] fix(cf): patch detail caches after edit-org/space update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After CnsiOrgsSource.update / CnsiSpacesSource.update, the steppers auto-navigated to /summary while OrgDataService._org and SpaceDataService._space still held the pre-edit values. Those signals are private caches with warm-cache short-circuits — CnsiXSource patches the EndpointDataService _orgs/_spaces list cache but doesn't reach the detail signal the summary view reads from, so the new name/SSH/quota only appeared after a hard reload. Add patch(p) to both data services that merges into the cached entity in place, and call it from each stepper after the canonical update completes. Mirrors the existing eds.updateOrg(guid, patch) pattern. Verified on dev.98 with browser playwright that the regression reproduces; tests cover the new patch + the no-op-before-load case. --- .../edit-organization-step.component.ts | 8 +++++++ .../edit-space-step.component.ts | 14 ++++++++++- .../endpoint-data/org-data.service.spec.ts | 20 ++++++++++++++++ .../endpoint-data/org-data.service.ts | 9 ++++++++ .../endpoint-data/space-data.service.spec.ts | 23 +++++++++++++++++++ .../endpoint-data/space-data.service.ts | 10 ++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/edit-organization/edit-organization-step/edit-organization-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/edit-organization/edit-organization-step/edit-organization-step.component.ts index e18492b771..b3be37b685 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/edit-organization/edit-organization-step/edit-organization-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/edit-organization/edit-organization-step/edit-organization-step.component.ts @@ -87,6 +87,14 @@ export class EditOrganizationStepComponent implements OnInit, OnDestroy { // so the orgs list re-fetches. eds.applyCascade('org.update'); } + // Patch the OrgDataService cache so the auto-navigate to /summary + // shows the new values without a hard reload — CnsiOrgsSource only + // updates the EndpointData _orgs list, not the detail signal that + // the summary view reads from. + this.cfOrgService.orgDataService.patch({ + name: newName, + ...(newQuotaGuid !== this.originalQuotaGuid ? { quotaGuid: newQuotaGuid ?? undefined } : {}), + }); } catch (err: unknown) { throw new Error(`Failed to update organization: ${err instanceof Error ? err.message : String(err)}`); } finally { 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 285810b8d1..75362a7763 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 @@ -208,6 +208,13 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn { enabled: allowSsh }, ).pipe(map(() => true)); }), + tap(() => { + // Patch the SpaceDataService cache so the auto-navigate to /summary + // shows the new values without a hard reload — CnsiSpacesSource only + // updates the EndpointData _spaces list, not the detail signal that + // the summary view reads from. + this.cfSpaceService.spaceDataService.patch({ name, allowSsh }); + }), map(() => ({ success: true })), catchError(err => { const message = err?.error?.error || err?.message || `Failed to update space`; @@ -248,7 +255,12 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn { space_guids: [this.spaceGuid] }, ).pipe(map(() => ({ success: true, redirect: true } as StepOnNextResult))); }), - tap(() => eds.applyCascade('space.update')), + tap(() => { + eds.applyCascade('space.update'); + // Mirror the new quota onto the detail cache so the summary's + // "Quota Definition" row reflects the change without a reload. + this.cfSpaceService.spaceDataService.patch({ quotaGuid: nextGuid ?? undefined }); + }), catchError(err => { const message = err?.error?.error || err?.message || `Failed to update space quota`; return of({ success: false, redirect: false, message: `Failed to update space quota: ${message}` } as StepOnNextResult); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.spec.ts index 117eb7a73c..e4a2cf74a0 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.spec.ts @@ -91,4 +91,24 @@ describe('OrgDataService', () => { httpMock.expectNone('/pp/v1/cf/org/cnsi-1/org-1'); httpMock.expectNone('/pp/v1/cf/org/cnsi-1/org-1/spaces'); }); + + it('patch() merges partial updates into the cached org detail', async () => { + const mockOrg = { guid: 'org-1', name: 'Old', status: 'active', labels: {}, annotations: {}, createdAt: '', updatedAt: '', spaces: [], quotaGuid: 'q-1' }; + service.load().subscribe(); + httpMock.expectOne('/pp/v1/cf/org/cnsi-1/org-1').flush(mockOrg); + httpMock.expectOne('/pp/v1/cf/org/cnsi-1/org-1/spaces').flush({ resources: [], totalResults: 0 }); + await Promise.resolve(); + + service.patch({ name: 'New', quotaGuid: 'q-2' }); + + expect(service.org()?.name).toBe('New'); + expect(service.org()?.quotaGuid).toBe('q-2'); + expect(service.org()?.status).toBe('active'); + expect(service.org()?.guid).toBe('org-1'); + }); + + it('patch() is a no-op before the first load', () => { + service.patch({ name: 'X' }); + expect(service.org()).toBeNull(); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.ts index 1bd14aee7b..f5402e6e9c 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-data.service.ts @@ -75,6 +75,15 @@ export class OrgDataService { return this._inFlightLoad; } + // Merge a partial update into the cached org detail. Called by stepper + // submit handlers after a successful CnsiOrgsSource.update so the + // /summary view reflects the new name immediately — the EndpointData + // _orgs list cache already patches in place, but this signal is read + // independently and would otherwise stay stale until hard reload. + patch(p: Partial): void { + this._org.update(curr => curr ? { ...curr, ...p } : curr); + } + private addError(resource: string, err: unknown): void { this._errors.update(errors => [...errors, { resource, diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.spec.ts index cfafff92a3..41d30416ed 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.spec.ts @@ -91,4 +91,27 @@ describe('SpaceDataService', () => { service.load().subscribe(); httpMock.expectNone('/pp/v1/cf/spaces/cnsi-1/sp-1'); }); + + it('patch() merges partial updates into the cached space', async () => { + const mockSpace = { + guid: 'sp-1', name: 'Old', orgGuid: 'org-1', + createdAt: '', updatedAt: '', cnsiGuid: 'cnsi-1', + appCount: 0, routeCount: 0, allowSsh: false, quotaGuid: 'q-1', + }; + service.load().subscribe(); + httpMock.expectOne('/pp/v1/cf/spaces/cnsi-1/sp-1').flush(mockSpace); + await Promise.resolve(); + + service.patch({ name: 'New', allowSsh: true }); + + expect(service.space()?.name).toBe('New'); + expect(service.space()?.allowSsh).toBe(true); + expect(service.space()?.quotaGuid).toBe('q-1'); + expect(service.space()?.orgGuid).toBe('org-1'); + }); + + it('patch() is a no-op before the first load', () => { + service.patch({ name: 'X' }); + expect(service.space()).toBeNull(); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.ts index e2ca059474..e7a890a29f 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/space-data.service.ts @@ -62,6 +62,16 @@ export class SpaceDataService { return this._inFlightLoad; } + // Merge a partial update into the cached space detail. Called by the + // edit-space stepper after a successful CnsiSpacesSource.update so the + // /summary view reflects the new name + SSH state immediately — the + // EndpointData _spaces list cache already patches in place, but this + // signal is read independently and would otherwise stay stale until + // hard reload. + patch(p: Partial): void { + this._space.update(curr => curr ? { ...curr, ...p } : curr); + } + private addError(resource: string, err: unknown): void { this._errors.update(errors => [...errors, { resource, From 25d782ec84a93ac080c9affcf3b7f72019e4b1eb Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 01:34:05 -0700 Subject: [PATCH 2/9] fix(cf): load space data on CloudFoundrySpaceService init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudFoundryOrganizationService kicks off orgDataService.load() in its constructor so any consumer reading the org signal sees a populated value, including the /edit-org direct URL. CloudFoundrySpaceService had no equivalent call, so direct navigation to /edit-space landed with spaceDataService.space()===null and the form prefilled empty (Space Name blank, SSH "Disabled" regardless of backend state). The form only populated when the user reached /edit-space via the summary page, which had already warmed the cache through cloud-foundry-space-base. Add the matching load() on construction. Warm-cache short-circuit makes it a no-op on the common summary→edit path. --- .../features/cf/services/cloud-foundry-space.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 581aa994e0..64d9d9a08e 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 @@ -81,6 +81,14 @@ export class CloudFoundrySpaceService { this.orgGuid = activeRouteCfOrgSpace.orgGuid; this.cfGuid = activeRouteCfOrgSpace.cfGuid; + // Kick off the space load so consumers reading spaceDataService.space + // signal get a populated value. Mirrors CloudFoundryOrganizationService + // which loads the org on construction — direct-URL routes that don't + // pass through cloud-foundry-space-base (notably /edit-space) would + // otherwise see space()===null and prefill with empty form values. + // Warm-cache short-circuit makes it a no-op when the signal is hot. + this.spaceDataService.load().subscribe({ error: () => {} }); + this.initialiseObservables(); } From 57c051e244e90ccd6dee8dcb58da32fd9a11b473 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 01:47:17 -0700 Subject: [PATCH 3/9] fix(list): L5 sub-nav Total X counter reflects unfiltered total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Total X" L5 sub-nav title (e.g. "Total Organizations") was bound to view.totalFilteredResults so a non-matching filter drove the headline to 0 while the underlying dataset still held N items. Users saw "Total Organizations: 0" on an endpoint with 56 orgs — jarring and inconsistent with the label semantics. Add view.totalItems (raw items().length) alongside totalFilteredResults on ViewPipeline (and the endpoints-page local copy of the same class) so the headline can stay anchored to the dataset size while the filter/sort/paginator chain continues to feed off the filtered view. Repoint every L5 sub-nav binding to view.totalItems: orgs, spaces, space-quotas, org-quotas, users (cf/org/space), apps wall, routes-tab, variables-tab, endpoints. The remaining totalFilteredResults reads in the same component files are pass-through to SignalListConfig where the paginator + empty-state branches genuinely want the filtered count — those are unchanged. --- .../application-wall/application-wall.component.ts | 4 ++-- .../routes-tab/routes-tab/routes-tab.component.ts | 2 +- .../tabs/variables-tab/variables-tab.component.ts | 2 +- ...d-foundry-organization-space-quotas.component.ts | 2 +- ...-foundry-organization-spaces-signal.component.ts | 2 +- .../cloud-foundry-space-users.component.ts | 2 +- .../cloud-foundry-organization-users.component.ts | 2 +- .../cloud-foundry-organizations-signal.component.ts | 2 +- .../cf-quotas/cloud-foundry-quotas.component.ts | 2 +- .../tabs/cf-users/cloud-foundry-users.component.ts | 2 +- .../src/services/data-sources/view-pipeline.spec.ts | 13 +++++++++++++ .../src/services/data-sources/view-pipeline.ts | 6 ++++++ .../endpoints-signal-config.service.ts | 5 +++++ .../endpoints-signal-list.component.ts | 2 +- 14 files changed, 36 insertions(+), 12 deletions(-) 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 1ae02654ee..894afac4d3 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 @@ -255,9 +255,9 @@ export class ApplicationWallComponent implements OnInit { this.appsConfig.clearLockedSpace(); this.appsConfig.initialize(cnsiGuids); // appsConfig.view exists only after initialize(); assign here so the - // L5 sub-nav can read totalFilteredResults as a stable Signal. + // L5 sub-nav can read the unfiltered total as a stable Signal. (this as { totalApplications: Signal }).totalApplications = - this.appsConfig.view.totalFilteredResults; + this.appsConfig.view.totalItems; // First user interaction with ANY of the toolbar dropdowns triggers a // one-shot org/space catalog fetch via ensureNamesLoaded(). This keeps // the apps wall mount fast — eager fetch on init was saturating the diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.ts index 66cd488788..96d297bf9a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.ts @@ -126,7 +126,7 @@ export class RoutesTabComponent implements OnInit, OnDestroy { sort: this.routesConfig.sort, }; - this.totalRoutes = this.routesConfig.view.totalFilteredResults; + this.totalRoutes = this.routesConfig.view.totalItems; } ngOnInit(): void { diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.ts index 5038939f66..6092692d53 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.ts @@ -225,7 +225,7 @@ export class VariablesTabComponent implements OnInit { sort: this.variablesConfig.sort, }; - this.totalVariables = this.variablesConfig.view.totalFilteredResults; + this.totalVariables = this.variablesConfig.view.totalItems; } ngOnInit(): void { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organization-space-quotas/cloud-foundry-organization-space-quotas.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organization-space-quotas/cloud-foundry-organization-space-quotas.component.ts index 5ca03cdca8..75496f0a3a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organization-space-quotas/cloud-foundry-organization-space-quotas.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organization-space-quotas/cloud-foundry-organization-space-quotas.component.ts @@ -79,7 +79,7 @@ export class CloudFoundryOrganizationSpaceQuotasComponent { this.quotasConfig.initialize(cfGuid); void this.quotasConfig.loadAll(); (this as { totalSpaceQuotas: Signal }).totalSpaceQuotas = - this.quotasConfig.view.totalFilteredResults; + this.quotasConfig.view.totalItems; this.createSpaceQuotaAction = { label: 'Create Space Quota', icon: 'add', diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-organization-spaces-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-organization-spaces-signal.component.ts index 79e988ae06..0ebdd17a90 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-organization-spaces-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/cloud-foundry-organization-spaces-signal.component.ts @@ -90,7 +90,7 @@ export class CloudFoundryOrganizationSpacesSignalComponent { const router = inject(Router); this.spacesConfig.initialize(cfGuid, orgGuid); (this as { totalSpaces: Signal }).totalSpaces = - this.spacesConfig.view.totalFilteredResults; + this.spacesConfig.view.totalItems; this.createSpaceAction = { label: 'Create Space', icon: 'add', diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-users/cloud-foundry-space-users.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-users/cloud-foundry-space-users.component.ts index 1c8d92377a..bb88497103 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-users/cloud-foundry-space-users.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-spaces/tabs/cloud-foundry-space-users/cloud-foundry-space-users.component.ts @@ -62,7 +62,7 @@ export class CloudFoundrySpaceUsersComponent { const cfGuid = this.cfEndpointService.cfGuid; const spaceGuid = this.cfSpaceService.spaceGuid; this.usersConfig.initializeForSpace(cfGuid, spaceGuid); - (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalFilteredResults; + (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalItems; const renderUsername = (u: StUser): string => u.username && u.username.length > 0 ? u.username : (u.presentationName ?? u.guid); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.ts index d0cb59079b..dd47de476d 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.ts @@ -61,7 +61,7 @@ export class CloudFoundryOrganizationUsersComponent { const cfGuid = this.cfEndpointService.cfGuid; const orgGuid = this.cfOrgService.orgGuid; this.usersConfig.initializeForOrg(cfGuid, orgGuid); - (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalFilteredResults; + (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalItems; const renderUsername = (u: StUser): string => u.username && u.username.length > 0 ? u.username : (u.presentationName ?? u.guid); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cloud-foundry-organizations-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cloud-foundry-organizations-signal.component.ts index 3f94218119..444ee9e1d6 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cloud-foundry-organizations-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cloud-foundry-organizations-signal.component.ts @@ -100,7 +100,7 @@ export class CloudFoundryOrganizationsSignalComponent { const currentUserPermissionsService = inject(CurrentUserPermissionsService); this.orgsConfig.initialize(cfGuid); (this as { totalOrganizations: Signal }).totalOrganizations = - this.orgsConfig.view.totalFilteredResults; + this.orgsConfig.view.totalItems; this.canCreateOrganization = toSignal( currentUserPermissionsService.can(CfCurrentUserPermissions.ORGANIZATION_CREATE, cfGuid), { initialValue: false }, diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-quotas/cloud-foundry-quotas.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-quotas/cloud-foundry-quotas.component.ts index 3f045d921c..4a37232984 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-quotas/cloud-foundry-quotas.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-quotas/cloud-foundry-quotas.component.ts @@ -64,7 +64,7 @@ export class CloudFoundryQuotasComponent { this.quotasConfig.initialize(cfGuid); void this.quotasConfig.loadAll(); (this as { totalQuotas: Signal }).totalQuotas = - this.quotasConfig.view.totalFilteredResults; + this.quotasConfig.view.totalItems; this.createQuotaAction = { label: 'Create Organization Quota', icon: 'add', 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 c6da16b953..829816f59f 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 @@ -52,7 +52,7 @@ export class CloudFoundryUsersComponent { constructor() { const cfGuid = this.cfEndpointService.cfGuid; this.usersConfig.initialize(cfGuid); - (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalFilteredResults; + (this as { totalUsers: Signal }).totalUsers = this.usersConfig.view.totalItems; // Cell renderers. Each role-bucket cell resolves org/space names via // the config service's lookup signals (which read EndpointDataService diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.spec.ts index 95c623980a..727083412e 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.spec.ts @@ -23,6 +23,7 @@ describe('ViewPipeline', () => { expect(pipe.filteredItems().map(r => r.name)).toEqual(['alpha', 'gamma', 'delta', 'epsilon']); expect(pipe.sortedItems().map(r => r.name)).toEqual(['gamma', 'alpha', 'epsilon', 'delta']); expect(pipe.pagedItems().map(r => r.name)).toEqual(['gamma', 'alpha']); + expect(pipe.totalItems()).toBe(5); expect(pipe.totalFilteredResults()).toBe(4); expect(pipe.totalPages()).toBe(2); @@ -30,6 +31,18 @@ describe('ViewPipeline', () => { expect(pipe.pagedItems().map(r => r.name)).toEqual(['epsilon', 'delta']); }); + it('totalItems stays unfiltered when filter narrows the view', () => { + const items = signal([ + { name: 'a', created: 1 }, + { name: 'b', created: 2 }, + { name: 'c', created: 3 }, + ]).asReadonly(); + const filter = signal<(r: Row) => boolean>(r => r.name === 'zzz'); + const pipe = new ViewPipeline(items, filter, signal({ field: 'created', direction: 'asc' }), signal(10), signal(0)); + expect(pipe.totalItems()).toBe(3); + expect(pipe.totalFilteredResults()).toBe(0); + }); + it('descending sort', () => { const items = signal([{ name: 'a', created: 1 }, { name: 'b', created: 2 }]).asReadonly(); const filter = signal<(r: Row) => boolean>(() => true); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts index 4431b53afb..f581e77369 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts @@ -15,6 +15,11 @@ export class ViewPipeline { readonly filteredItems: Signal; readonly sortedItems: Signal; readonly pagedItems: Signal; + // Unfiltered count of raw items. L5 sub-nav "Total X" labels read + // this so the headline stays anchored to the dataset size while + // filter input narrows the rendered list. totalFilteredResults + // (below) is what the paginator and empty-state branches react to. + readonly totalItems: Signal; readonly totalFilteredResults: Signal; readonly totalPages: Signal; @@ -61,6 +66,7 @@ export class ViewPipeline { const start = this.pageIndex() * size; return this.sortedItems().slice(start, start + size); }); + this.totalItems = computed(() => this.items().length); this.totalFilteredResults = computed(() => this.filteredItems().length); this.totalPages = computed(() => Math.ceil(this.totalFilteredResults() / this.pageSize())); } 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 704676421b..9a63d3f649 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 @@ -47,6 +47,10 @@ export class ViewPipeline { readonly filteredItems: Signal; readonly sortedItems: Signal; readonly pagedItems: Signal; + // Unfiltered count of raw items. L5 sub-nav "Total X" labels read + // this so the headline stays anchored to the dataset size while + // filter input narrows the rendered list. + readonly totalItems: Signal; readonly totalFilteredResults: Signal; readonly totalPages: Signal; @@ -83,6 +87,7 @@ export class ViewPipeline { const start = this.pageIndex() * size; return this.sortedItems().slice(start, start + size); }); + this.totalItems = computed(() => this.items().length); this.totalFilteredResults = computed(() => this.filteredItems().length); this.totalPages = computed(() => Math.ceil(this.totalFilteredResults() / this.pageSize())); } diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.ts index 2ffad30809..803794d172 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.ts @@ -116,7 +116,7 @@ export class EndpointsSignalListComponent { constructor() { this.endpointsConfig.initialize(); - (this as { totalEndpoints: Signal }).totalEndpoints = this.endpointsConfig.view.totalFilteredResults; + (this as { totalEndpoints: Signal }).totalEndpoints = this.endpointsConfig.view.totalItems; const typeLabel = (ep: EndpointModel): string => { const def = entityCatalog.getEndpoint(ep.cnsi_type, ep.sub_type); From 6a406c883b5651e8fce02c1e1262b3d0ba7c1ff1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 01:55:31 -0700 Subject: [PATCH 4/9] fix(cf-routes): show locked Cloud Foundry scope indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services + Marketplace tabs lead their filter row with a Cloud Foundry dropdown locked to the URL-pinned CNSI so the user can always see which endpoint they're scoped to. Routes was missing the same indicator — the toolbar started with Organization, leaving the CF scope implicit. Adds a single-option Cloud Foundry dropdown (label = endpoint name) at the head of the filter row, disabled, mirroring the existing services + marketplace pattern. Endpoint name is sourced from the existing cfEndpointService.endpoint signal; falls back to the cfGuid string if the EndpointModel hasn't hydrated yet. --- .../cloud-foundry-routes-signal.component.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-routes/cloud-foundry-routes-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-routes/cloud-foundry-routes-signal.component.ts index b5559db9dd..94e5d1064b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-routes/cloud-foundry-routes-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-routes/cloud-foundry-routes-signal.component.ts @@ -153,7 +153,23 @@ export class CloudFoundryRoutesSignalComponent { const typeColor = (r: StRoute): SignalListPillColor => r.port != null ? 'warning' : 'neutral'; + // CNSI is pre-chosen by the URL — show the dropdown but disable it so + // the scope is visible and can't drift. Parity with services + market- + // place tabs which also lead with a locked Cloud Foundry indicator. + const endpointName = computed(() => + this.cfEndpointService.endpoint()?.entity?.name ?? cfGuid, + ); + const cnsiOptions = computed(() => [{ label: endpointName(), value: cfGuid }]); + const selectedCnsi = signal(cfGuid); + const cnsiLocked: Signal = signal(true).asReadonly(); + const dropdowns: SignalListDropdown[] = [ + { + label: 'Cloud Foundry', + options: cnsiOptions, + selected: selectedCnsi, + disabled: cnsiLocked, + }, { label: 'Organization', options: this.routesConfig.orgOptions, From f10962931627a4b309236a0d45cb6d8871862582 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 02:03:45 -0700 Subject: [PATCH 5/9] fix(endpoints): remember username across disconnect/reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend clears endpoint.user on disconnect, so the reconnect dialog opened with the credentials form empty even when the same user was about to reconnect to the same endpoint. Friction every time and a likely source of typos (long Cloud Foundry usernames). Cache the username in localStorage on a successful credentials connect (keyed by endpoint guid), and prefill it into the dialog's authValues group when the form has a username control. Token / SSO forms have no username field, so the prefill is a no-op there. Storage is wrapped in try/catch so private-browsing quota failures degrade silently — the user just types the username again, no error surfaces. --- .../connect-endpoint.component.ts | 21 +++++++++++++ .../src/features/endpoints/connect.service.ts | 20 +++++++++--- .../endpoints/remembered-username.spec.ts | 31 +++++++++++++++++++ .../features/endpoints/remembered-username.ts | 29 +++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/frontend/packages/core/src/features/endpoints/remembered-username.spec.ts create mode 100644 src/frontend/packages/core/src/features/endpoints/remembered-username.ts diff --git a/src/frontend/packages/core/src/features/endpoints/connect-endpoint/connect-endpoint.component.ts b/src/frontend/packages/core/src/features/endpoints/connect-endpoint/connect-endpoint.component.ts index 390ee91d0d..20e6a88973 100644 --- a/src/frontend/packages/core/src/features/endpoints/connect-endpoint/connect-endpoint.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/connect-endpoint/connect-endpoint.component.ts @@ -10,6 +10,7 @@ import { Subscription } from 'rxjs'; import { BaseEndpointAuth } from '../../../core/endpoint-auth'; import { safeUnsubscribe } from '../../../core/utils.service'; import { ConnectEndpointConfig, ConnectEndpointData, ConnectEndpointService } from '../connect.service'; +import { rememberedUsernameKey } from '../remembered-username'; /** * Base interface for the endpoint form structure. @@ -123,6 +124,11 @@ export class ConnectEndpointComponent implements OnInit, OnDestroy { }); // Add authValues as a separate group to handle dynamic auth type switching this.endpointForm.addControl('authValues', this.fb.group(this.autoSelected.form || {})); + // Prefill the username from the last successful connect (stored in + // localStorage by connect.service on success) — backend EndpointModel + // clears endpoint.user on disconnect, so the dialog otherwise opens + // empty even when the same user is reconnecting. + this.prefillRememberedUsername(config.guid); this.authChanged(); // Template container reference is not available at construction @@ -198,6 +204,21 @@ export class ConnectEndpointComponent implements OnInit, OnDestroy { return a.length === b.length && a.filter(item => b.indexOf(item) < 0).length === 0; } + private prefillRememberedUsername(endpointGuid: string): void { + const authValues = this.endpointForm.get('authValues'); + if (!authValues || !authValues.get('username')) { + return; + } + try { + const stored = window.localStorage?.getItem(rememberedUsernameKey(endpointGuid)); + if (stored) { + authValues.patchValue({ username: stored }); + } + } catch { + // Private mode / quota — silent fail, no prefill. + } + } + private getData(): ConnectEndpointData { const { authType, authValues, systemShared } = this.endpointForm.value; let authVal = authValues; 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 91eed9aef2..ddb7ba0fd4 100644 --- a/src/frontend/packages/core/src/features/endpoints/connect.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/connect.service.ts @@ -18,6 +18,7 @@ import { delay, distinctUntilChanged, filter, map, pairwise, startWith, switchMa import { EndpointsService } from '../../core/endpoints.service'; import { safeUnsubscribe } from '../../core/utils.service'; +import { rememberUsername } from './remembered-username'; export interface ConnectEndpointConfig { name: string; @@ -216,10 +217,21 @@ export class ConnectEndpointService { }), ), ).pipe( - map(actionState => ({ - success: !actionState.error, - errorMessage: actionState.message, - })), + map(actionState => { + if (!actionState.error) { + // Cache the username so the next reconnect dialog (which sees an + // endpoint.user cleared by disconnect) can prefill it. Only for + // credentials-style auth — SSO / token forms don't carry one. + const username = (authVal as { username?: string })?.username; + if (username) { + rememberUsername(this.config.guid, username); + } + } + return { + success: !actionState.error, + errorMessage: actionState.message, + }; + }), ); } diff --git a/src/frontend/packages/core/src/features/endpoints/remembered-username.spec.ts b/src/frontend/packages/core/src/features/endpoints/remembered-username.spec.ts new file mode 100644 index 0000000000..1826e536eb --- /dev/null +++ b/src/frontend/packages/core/src/features/endpoints/remembered-username.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { rememberUsername, forgetRememberedUsername, rememberedUsernameKey } from './remembered-username'; + +describe('remembered-username', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('round-trips a username for a given endpoint guid', () => { + rememberUsername('ep-1', 'alice'); + expect(window.localStorage.getItem(rememberedUsernameKey('ep-1'))).toBe('alice'); + }); + + it('keeps usernames per-endpoint', () => { + rememberUsername('ep-1', 'alice'); + rememberUsername('ep-2', 'bob'); + expect(window.localStorage.getItem(rememberedUsernameKey('ep-1'))).toBe('alice'); + expect(window.localStorage.getItem(rememberedUsernameKey('ep-2'))).toBe('bob'); + }); + + it('ignores empty usernames so we never persist a placeholder', () => { + rememberUsername('ep-1', ''); + expect(window.localStorage.getItem(rememberedUsernameKey('ep-1'))).toBeNull(); + }); + + it('forgetRememberedUsername clears the entry', () => { + rememberUsername('ep-1', 'alice'); + forgetRememberedUsername('ep-1'); + expect(window.localStorage.getItem(rememberedUsernameKey('ep-1'))).toBeNull(); + }); +}); diff --git a/src/frontend/packages/core/src/features/endpoints/remembered-username.ts b/src/frontend/packages/core/src/features/endpoints/remembered-username.ts new file mode 100644 index 0000000000..7e5e44841d --- /dev/null +++ b/src/frontend/packages/core/src/features/endpoints/remembered-username.ts @@ -0,0 +1,29 @@ +// localStorage-backed cache of the username used to connect to a given +// endpoint. Written by connect.service on a successful credential connect; +// read by connect-endpoint.component to prefill the reconnect dialog after +// disconnect (backend clears endpoint.user on disconnect so the prefill +// would otherwise be lost). Per-browser-profile by nature of localStorage — +// users switching machines see no prefill, which is acceptable. + +const STORAGE_PREFIX = 'stratos.endpoint.username.'; + +export function rememberedUsernameKey(endpointGuid: string): string { + return `${STORAGE_PREFIX}${endpointGuid}`; +} + +export function rememberUsername(endpointGuid: string, username: string): void { + if (!username) return; + try { + window.localStorage?.setItem(rememberedUsernameKey(endpointGuid), username); + } catch { + // Private mode / quota — silent fail, prefill simply won't work. + } +} + +export function forgetRememberedUsername(endpointGuid: string): void { + try { + window.localStorage?.removeItem(rememberedUsernameKey(endpointGuid)); + } catch { + // ignore + } +} From f9ca59ee5c6c43d7ecae3f45532fcfe6134be8ff Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 02:22:14 -0700 Subject: [PATCH 6/9] fix(signal-list): keep refresh-spin alive across button clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signal-list template gated the inline refresh spinner on config.isAnyLoading(), but most config services wire that to !hasLoadedOnce() — true only during the initial load. Every refresh click after that ran a real HTTP fetch (verified XHR fires) but showed no visual feedback, so the button looked dead. The quota config services were fixed in 2b26a07a4d by adding a dedicated loading signal; the other 15+ list pages were never updated. Self-contained fix at the signal-list layer: an internal isRefreshing signal flips during invokeRefresh(), and the template gates the spinner on (config.isAnyLoading() || isRefreshing()). Components keep their existing onRefresh wiring; no per-config churn. The page-level loading indicator (empty + loading branch) inherits the same gate for consistency. Verified live on adepttech orgs page: refresh button click fires GET /pp/v1/cf/orgs (7.7s on this dataset), signal repopulates the list — only the animation was missing, this fix restores it. --- .../signal-list/signal-list.component.html | 12 ++++---- .../signal-list/signal-list.component.spec.ts | 28 +++++++++++++++++++ .../signal-list/signal-list.component.ts | 24 ++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html index 60f368e97e..4005f92c95 100644 --- a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html +++ b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html @@ -117,10 +117,10 @@ -->