diff --git a/package.json b/package.json index fd81c521db..e8f2b3cc3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stratos", - "version": "v5.0.0-dev.98+build.20260523.e6f43bec13", + "version": "v5.0.0-dev.99+build.20260523.f9ca59ee5c", "type": "module", "description": "Stratos Console", "main": "index.js", 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 d1c2c74273..5d96758845 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 @@ -22,6 +22,7 @@ function makeStubSignalConfigService() { const sortSig = signal({ field: 'name' as const, direction: 'asc' as const }); const view = { pagedItems: signal([]).asReadonly(), + totalItems: signal(0).asReadonly(), totalFilteredResults: signal(0).asReadonly(), totalPages: signal(1).asReadonly(), }; 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.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.spec.ts index 595a0d18f8..51d48c2b33 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/routes-tab/routes-tab/routes-tab.component.spec.ts @@ -85,6 +85,7 @@ describe('RoutesTabComponent', () => { const filtered = computed(() => routes()); const view = { pagedItems: filtered, + totalItems: computed(() => routes().length), totalFilteredResults: computed(() => filtered().length), totalPages: computed(() => 1), }; 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.spec.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.spec.ts index 188c8743c4..64c036401a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/variables-tab/variables-tab.component.spec.ts @@ -86,6 +86,7 @@ describe('VariablesTabComponent', () => { const filtered = computed(() => variables()); const view = { pagedItems: filtered, + totalItems: computed(() => variables().length), totalFilteredResults: computed(() => filtered().length), totalPages: computed(() => 1), }; 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/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/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(); } 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.spec.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.spec.ts index 67ce6a5664..30637325a9 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.spec.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.spec.ts @@ -24,6 +24,7 @@ function makeStubSignalConfigService() { const sortSig = signal({ field: 'username' as const, direction: 'asc' as const }); const view = { pagedItems: signal([]).asReadonly(), + totalItems: signal(0).asReadonly(), totalFilteredResults: signal(0).asReadonly(), totalPages: signal(1).asReadonly(), }; 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.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.spec.ts index 8d91b042b3..3bc768b36b 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-organizations/cf-organization-users/cloud-foundry-organization-users.component.spec.ts @@ -23,6 +23,7 @@ function makeStubSignalConfigService() { const sortSig = signal({ field: 'username' as const, direction: 'asc' as const }); const view = { pagedItems: signal([]).asReadonly(), + totalItems: signal(0).asReadonly(), totalFilteredResults: signal(0).asReadonly(), totalPages: signal(1).asReadonly(), }; 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-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, diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts index 1184c0b5f5..3bbf59766f 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts @@ -23,6 +23,7 @@ function makeStubSignalConfigService(opts?: { const sortSig = signal({ field: 'username' as const, direction: 'asc' as const }); const view = { pagedItems: signal([]).asReadonly(), + totalItems: signal(0).asReadonly(), totalFilteredResults: signal(0).asReadonly(), totalPages: signal(1).asReadonly(), }; 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..b1515d7d45 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,36 @@ 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('strings sort case-insensitively and naturally (numeric-aware)', () => { + // Without natural sort: 'OrgNoSelectedQuota' < 'e2e' (capital O = 0x4F + // < lowercase e = 0x65) and 'org_10' lands between 'org_1' and 'org_2'. + // With localeCompare(numeric, sensitivity:'base') case folds away and + // numbers sort by value. + const items = signal<{ name: string }[]>([ + { name: 'org_10' }, + { name: 'org_2' }, + { name: 'OrgAlpha' }, + { name: 'org_1' }, + { name: 'eee' }, + ]).asReadonly(); + const filter = signal<(r: { name: string }) => boolean>(() => true); + const sort = signal<{ field: 'name'; direction: 'asc' | 'desc' }>({ field: 'name', direction: 'asc' }); + const pipe = new ViewPipeline(items, filter, sort, signal(10), signal(0)); + expect(pipe.sortedItems().map(r => r.name)).toEqual(['eee', 'org_1', 'org_2', 'org_10', 'OrgAlpha']); + }); + 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..9c9057da2c 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; @@ -48,11 +53,19 @@ export class ViewPipeline { if (bv == null) return -1; // Prefer numeric comparison when both sides are numbers — prevents // accidental string coercion when, e.g., a number column gets a - // stringified value from a legacy backend path. Fall back to the - // generic `<` / `>` operators for string / date / other types. + // stringified value from a legacy backend path. if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * sign; } + // Natural string sort for string/string comparisons: + // - case-insensitive (orgs starting with capital letters don't + // jump to the top of the list) + // - numeric-aware (org_2 sorts before org_10, not after) + // Falls back to `<` / `>` for non-string / mixed types (dates as + // ISO strings still compare correctly under localeCompare). + if (typeof av === 'string' && typeof bv === 'string') { + return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * sign; + } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); }); @@ -61,6 +74,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/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, 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/endpoints-page/endpoints-signal-config.service.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts index 704676421b..fc323d9e32 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; @@ -75,6 +79,11 @@ export class ViewPipeline { if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * sign; } + // Case-insensitive + numeric-aware string sort, mirroring the + // shared ViewPipeline in cloud-foundry/services/data-sources. + if (typeof av === 'string' && typeof bv === 'string') { + return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * sign; + } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); }); @@ -83,6 +92,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); 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 + } +} 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 @@ -->