Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> }).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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class CloudFoundryOrganizationSpaceQuotasComponent {
this.quotasConfig.initialize(cfGuid);
void this.quotasConfig.loadAll();
(this as { totalSpaceQuotas: Signal<number> }).totalSpaceQuotas =
this.quotasConfig.view.totalFilteredResults;
this.quotasConfig.view.totalItems;
this.createSpaceQuotaAction = {
label: 'Create Space Quota',
icon: 'add',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class CloudFoundryOrganizationSpacesSignalComponent {
const router = inject(Router);
this.spacesConfig.initialize(cfGuid, orgGuid);
(this as { totalSpaces: Signal<number> }).totalSpaces =
this.spacesConfig.view.totalFilteredResults;
this.spacesConfig.view.totalItems;
this.createSpaceAction = {
label: 'Create Space',
icon: 'add',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeStubSignalConfigService() {
const sortSig = signal({ field: 'username' as const, direction: 'asc' as const });
const view = {
pagedItems: signal<StUser[]>([]).asReadonly(),
totalItems: signal(0).asReadonly(),
totalFilteredResults: signal(0).asReadonly(),
totalPages: signal(1).asReadonly(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> }).totalUsers = this.usersConfig.view.totalFilteredResults;
(this as { totalUsers: Signal<number> }).totalUsers = this.usersConfig.view.totalItems;

const renderUsername = (u: StUser): string =>
u.username && u.username.length > 0 ? u.username : (u.presentationName ?? u.guid);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeStubSignalConfigService() {
const sortSig = signal({ field: 'username' as const, direction: 'asc' as const });
const view = {
pagedItems: signal<StUser[]>([]).asReadonly(),
totalItems: signal(0).asReadonly(),
totalFilteredResults: signal(0).asReadonly(),
totalPages: signal(1).asReadonly(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> }).totalUsers = this.usersConfig.view.totalFilteredResults;
(this as { totalUsers: Signal<number> }).totalUsers = this.usersConfig.view.totalItems;

const renderUsername = (u: StUser): string =>
u.username && u.username.length > 0 ? u.username : (u.presentationName ?? u.guid);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class CloudFoundryOrganizationsSignalComponent {
const currentUserPermissionsService = inject(CurrentUserPermissionsService);
this.orgsConfig.initialize(cfGuid);
(this as { totalOrganizations: Signal<number> }).totalOrganizations =
this.orgsConfig.view.totalFilteredResults;
this.orgsConfig.view.totalItems;
this.canCreateOrganization = toSignal(
currentUserPermissionsService.can(CfCurrentUserPermissions.ORGANIZATION_CREATE, cfGuid),
{ initialValue: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class CloudFoundryQuotasComponent {
this.quotasConfig.initialize(cfGuid);
void this.quotasConfig.loadAll();
(this as { totalQuotas: Signal<number> }).totalQuotas =
this.quotasConfig.view.totalFilteredResults;
this.quotasConfig.view.totalItems;
this.createQuotaAction = {
label: 'Create Organization Quota',
icon: 'add',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(cfGuid);
const cnsiLocked: Signal<boolean> = signal(true).asReadonly();

const dropdowns: SignalListDropdown[] = [
{
label: 'Cloud Foundry',
options: cnsiOptions,
selected: selectedCnsi,
disabled: cnsiLocked,
},
{
label: 'Organization',
options: this.routesConfig.orgOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeStubSignalConfigService(opts?: {
const sortSig = signal({ field: 'username' as const, direction: 'asc' as const });
const view = {
pagedItems: signal<StUser[]>([]).asReadonly(),
totalItems: signal(0).asReadonly(),
totalFilteredResults: signal(0).asReadonly(),
totalPages: signal(1).asReadonly(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class CloudFoundryUsersComponent {
constructor() {
const cfGuid = this.cfEndpointService.cfGuid;
this.usersConfig.initialize(cfGuid);
(this as { totalUsers: Signal<number> }).totalUsers = this.usersConfig.view.totalFilteredResults;
(this as { totalUsers: Signal<number> }).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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,44 @@ 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);

pageIndex.set(1);
expect(pipe.pagedItems().map(r => r.name)).toEqual(['epsilon', 'delta']);
});

it('totalItems stays unfiltered when filter narrows the view', () => {
const items = signal<Row[]>([
{ 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<Row>(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<Row[]>([{ name: 'a', created: 1 }, { name: 'b', created: 2 }]).asReadonly();
const filter = signal<(r: Row) => boolean>(() => true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export class ViewPipeline<T> {
readonly filteredItems: Signal<T[]>;
readonly sortedItems: Signal<T[]>;
readonly pagedItems: Signal<T[]>;
// 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<number>;
readonly totalFilteredResults: Signal<number>;
readonly totalPages: Signal<number>;

Expand Down Expand Up @@ -48,11 +53,19 @@ export class ViewPipeline<T> {
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;
});
});
Expand All @@ -61,6 +74,7 @@ export class ViewPipeline<T> {
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()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<StOrgDetail>): void {
this._org.update(curr => curr ? { ...curr, ...p } : curr);
}

private addError(resource: string, err: unknown): void {
this._errors.update(errors => [...errors, {
resource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading