diff --git a/package.json b/package.json index a7406d78e2..fd81c521db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stratos", - "version": "v5.0.0-dev.94+build.20260519.1cfce0b572", + "version": "v5.0.0-dev.98+build.20260523.e6f43bec13", "type": "module", "description": "Stratos Console", "main": "index.js", diff --git a/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-factory.ts b/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-factory.ts index d79244e64c..7960e7db3d 100644 --- a/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-factory.ts +++ b/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-factory.ts @@ -1,7 +1,7 @@ import { Schema, schema } from 'normalizr'; import { getCFCompositeEntityId } from '../../../cloud-foundry/src/store/selectors/api.selectors'; -import { EntitySchema, metricEntityType } from '@stratosui/store'; +import { EntitySchema } from '@stratosui/store'; export const appAutoscalerInfoEntityType = 'autoscalerInfo'; export const appAutoscalerHealthEntityType = 'autoscalerHealth'; @@ -74,9 +74,6 @@ entityCache[appAutoscalerAppMetricEntityType] = new AutoscalerEntitySchema( { idAttribute: getCFCompositeEntityId } ); -const MetricSchema = new AutoscalerEntitySchema(metricEntityType); -entityCache[metricEntityType] = MetricSchema; - export function autoscalerEntityFactory(key: string): EntitySchema { const entity = entityCache[key]; if (!entity) { diff --git a/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-generator.ts b/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-generator.ts index 83dea62bd8..27ceda9330 100644 --- a/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-generator.ts +++ b/src/frontend/packages/cf-autoscaler/src/store/autoscaler-entity-generator.ts @@ -3,7 +3,6 @@ import { StratosCatalogEndpointEntity, StratosCatalogEntity, StratosEndpointExtensionDefinition, - metricEntityType, APIResource, IFavoriteMetadata } from '@stratosui/store'; @@ -46,7 +45,6 @@ export function generateASEntities(): StratosBaseCatalogEntity[] { generateHealthEntity(endpointDefinition), generateScalingEntity(endpointDefinition), generateAppMetricEntity(endpointDefinition), - generateMetricEntity(endpointDefinition), generateCredentialEntity(endpointDefinition), ]; } @@ -119,13 +117,3 @@ function generateAppMetricEntity(endpointDefinition: StratosEndpointExtensionDef return new StratosCatalogEntity>(definition); } -function generateMetricEntity(endpointDefinition: StratosEndpointExtensionDefinition) { - const definition = { - type: metricEntityType, - schema: autoscalerEntityFactory(metricEntityType), - label: 'Autoscaler Metric', - labelPlural: 'Autoscaler Metrics', - endpoint: endpointDefinition, - }; - return new StratosCatalogEntity>(definition); -} diff --git a/src/frontend/packages/cloud-foundry/src/actions/cf-metrics.actions.ts b/src/frontend/packages/cloud-foundry/src/actions/cf-metrics.actions.ts deleted file mode 100644 index 6364bf5da4..0000000000 --- a/src/frontend/packages/cloud-foundry/src/actions/cf-metrics.actions.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { MetricQueryConfig, MetricsAction, MetricsChartAction } from '../../../store/src/actions/metrics.actions'; -import { MetricQueryType } from '../../../store/src/types/metric.types'; -import { PaginatedAction } from '../../../store/src/types/pagination.types'; -import { CF_ENDPOINT_TYPE } from '../cf-types'; - -class CfMetricsAction extends MetricsAction { - constructor( - guid: string, - endpointGuid: string, - query: MetricQueryConfig, - url: string, - windowValue: string = null, - queryType: MetricQueryType, - isSeries = true, - ) { - super( - guid, - endpointGuid, - query, - url, - windowValue, - queryType, - isSeries, - CF_ENDPOINT_TYPE - ); - } - -} - -export class FetchCFMetricsAction extends CfMetricsAction { - constructor( - guid: string, - cfGuid: string, - public query: MetricQueryConfig, - queryType: MetricQueryType = MetricQueryType.QUERY, - isSeries = true) { - super(guid, cfGuid, query, `${MetricsAction.getBaseMetricsURL()}/cf`, null, queryType, isSeries); - } -} - -export class FetchCFCellMetricsAction extends CfMetricsAction { - constructor( - cfGuid: string, - cellId: string, - public query: MetricQueryConfig, - queryType: MetricQueryType = MetricQueryType.QUERY, - isSeries = true) { - super(cfGuid + '-' + cellId, cfGuid, query, `${MetricsAction.getBaseMetricsURL()}/cf/cells`, null, queryType, isSeries); - } -} - -export class FetchCFMetricsPaginatedAction extends FetchCFMetricsAction implements PaginatedAction { - constructor( - guid: string, - cfGuid: string, - public query: MetricQueryConfig, - queryType: MetricQueryType = MetricQueryType.QUERY) { - super(guid, cfGuid, query, queryType); - this.paginationKey = this.guid; - } - actions: string[] = []; - paginationKey: string; - initialParams = { - 'order-direction': 'desc', - 'order-direction-field': 'id', - }; -} - -export class FetchCFCellMetricsPaginatedAction extends FetchCFCellMetricsAction implements PaginatedAction { - constructor(cfGuid: string, cellId: string, public query: MetricQueryConfig, queryType: MetricQueryType = MetricQueryType.QUERY) { - super(cfGuid, cellId, query, queryType); - this.paginationKey = this.guid; - } - actions: string[] = []; - paginationKey: string; - initialParams = { - 'order-direction': 'desc', - 'order-direction-field': 'id', - }; -} - -export class FetchApplicationMetricsAction extends CfMetricsAction { - constructor( - guid: string, - cfGuid: string, - query: MetricQueryConfig, - queryType: MetricQueryType = MetricQueryType.RANGE_QUERY, - isSeries = true) { - super(guid, cfGuid, query, `${MetricsAction.getBaseMetricsURL()}/cf/app/${guid}`, null, queryType, isSeries); - } - -} -export class FetchApplicationChartMetricsAction extends MetricsChartAction { - constructor( - guid: string, - cfGuid: string, - query: MetricQueryConfig, ) { - super(guid, cfGuid, query, `${MetricsAction.getBaseMetricsURL()}/cf/app/${guid}`, CF_ENDPOINT_TYPE); - } - -} diff --git a/src/frontend/packages/cloud-foundry/src/actions/cloud-foundry.actions.ts b/src/frontend/packages/cloud-foundry/src/actions/cloud-foundry.actions.ts deleted file mode 100644 index 4896e94330..0000000000 --- a/src/frontend/packages/cloud-foundry/src/actions/cloud-foundry.actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EntityRequestAction } from '../../../store/src/types/request.types'; -import { cfInfoEntityType } from '../cf-entity-types'; -import { CF_ENDPOINT_TYPE } from '../cf-types'; - -export const GET_CF_INFO = '[CF Endpoint] Get Info'; - -export class GetCFInfo implements EntityRequestAction { - constructor(public guid: string) { } - type = GET_CF_INFO; - endpointType = CF_ENDPOINT_TYPE; - entityType = cfInfoEntityType; -} diff --git a/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts b/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts index a79eb604a0..92fe706874 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts @@ -18,7 +18,6 @@ import { IApp, IAppSummary, IBuildpack, - ICfV2Info, IDomain, IFeatureFlag, IOrganization, @@ -37,7 +36,6 @@ import { AppSummaryActionBuilders } from './entity-action-builders/application-s import { ApplicationActionBuilders } from './entity-action-builders/application.action-builders'; import { BuildpackActionBuilders } from './entity-action-builders/buildpack.action-builders'; import { CfEventActionBuilders } from './entity-action-builders/cf-event.action-builders'; -import { CfInfoDefinitionActionBuilders } from './entity-action-builders/cf-info.action-builders'; import { DomainActionBuilders } from './entity-action-builders/domin.action-builder'; import { FeatureFlagActionBuilders } from './entity-action-builders/feature-flag.action-builder'; import { OrganizationActionBuilders } from './entity-action-builders/organization.action-builders'; @@ -93,12 +91,6 @@ export class CfEntityCatalog { APIResource >; - public cfInfo!: StratosBaseCatalogEntity< - IFavoriteMetadata, - APIResource, - CfInfoDefinitionActionBuilders - >; - public appStats!: StratosBaseCatalogEntity< IFavoriteMetadata, AppStat, @@ -206,10 +198,6 @@ export class CfEntityCatalog { OrganizationActionBuilders >; - public metric!: StratosBaseCatalogEntity< - IFavoriteMetadata - >; - public userProvidedService!: StratosBaseCatalogEntity< IFavoriteMetadata, APIResource, diff --git a/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts b/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts index e0c6f22cd2..d632b8d640 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts @@ -1,4 +1,4 @@ -import { EntitySchema, metricEntityType, APIResource } from '@stratosui/store'; +import { EntitySchema, APIResource } from '@stratosui/store'; import { CFApplicationEntitySchema, CFEntitySchema, @@ -16,7 +16,6 @@ import { appSummaryEntityType, buildpackEntityType, cfEventEntityType, - cfInfoEntityType, cfUserEntityType, domainEntityType, featureFlagEntityType, @@ -56,9 +55,6 @@ entityCache[appStatsEntityType] = AppStatSchema; const AppEnvVarSchema = new CFEntitySchema(appEnvVarsEntityType, {}, { idAttribute: getCFCompositeEntityId }); entityCache[appEnvVarsEntityType] = AppEnvVarSchema; -const CFInfoSchema = new CFEntitySchema(cfInfoEntityType); -entityCache[cfInfoEntityType] = CFInfoSchema; - const EventSchema = new CFEntitySchema(cfEventEntityType, {}, { idAttribute: getCFCompositeEntityId }); entityCache[cfEventEntityType] = EventSchema; @@ -92,9 +88,6 @@ const ServiceNoPlansSchema = new CFEntitySchema(serviceEntityType, { }, { idAttribute: getCFCompositeEntityId }); entityCache[serviceEntityType] = ServiceSchema; -const MetricSchema = new CFEntitySchema(metricEntityType); -entityCache[metricEntityType] = MetricSchema; - const SpaceQuotaSchema = new CFEntitySchema(spaceQuotaEntityType, {}, { idAttribute: getCFCompositeEntityId }); entityCache[spaceQuotaEntityType] = SpaceQuotaSchema; diff --git a/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts b/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts index b7dc0faac1..3714162127 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts @@ -19,7 +19,6 @@ import { IStratosEntityDefinition, JetstreamError, JetstreamResponse, - metricEntityType, PaginatedAction, PaginationEntityState, RequestInfoState, @@ -48,6 +47,7 @@ import { StSpaceQuota, StStack, } from './services/endpoint-data/stratos-types'; +import { refreshCfInfo } from './services/endpoint-data/cf-info-helper'; import { v3EntitiesFromResponse, v3PaginationConfig, v3SingleResourceMapper } from './v3-native'; import { IService, @@ -63,7 +63,6 @@ import { IApp, IAppSummary, IBuildpack, - ICfV2Info, IDomain, IFeatureFlag, IOrganization, @@ -84,7 +83,6 @@ import { appSummaryEntityType, buildpackEntityType, cfEventEntityType, - cfInfoEntityType, cfUserEntityType, domainEntityType, featureFlagEntityType, @@ -123,10 +121,6 @@ import { import { applicationActionBuilder, ApplicationActionBuilders } from './entity-action-builders/application.action-builders'; import { BuildpackActionBuilders, buildpackActionBuilders } from './entity-action-builders/buildpack.action-builders'; import { CfEventActionBuilders, cfEventActionBuilders } from './entity-action-builders/cf-event.action-builders'; -import { - CfInfoDefinitionActionBuilders, - cfInfoDefinitionActionBuilders, -} from './entity-action-builders/cf-info.action-builders'; import { DomainActionBuilders, domainActionBuilders } from './entity-action-builders/domin.action-builder'; import { FeatureFlagActionBuilders, featureFlagActionBuilders } from './entity-action-builders/feature-flag.action-builder'; import { @@ -288,7 +282,12 @@ export function generateCFEntities(): StratosBaseCatalogEntity[] { }, listDetailsComponent: CfEndpointDetailsComponent, renderPriority: 1, - healthCheck: new EndpointHealthCheck(CF_ENDPOINT_TYPE, (endpoint) => cfEntityCatalog.cfInfo.api.get(endpoint.guid)), + // W-e: was `cfEntityCatalog.cfInfo.api.get(endpoint.guid)` which dispatched + // a GetCFInfo ngrx action handled by CloudFoundryEffects.fetchInfo$ — both + // the action class and the effect are gone. refreshCfInfo() bypasses + // CfInfoDataService's warm-cache short-circuit so the periodic endpoint + // health pulse still produces a fresh /pp/v1/cf/info/{guid} fetch. + healthCheck: new EndpointHealthCheck(CF_ENDPOINT_TYPE, (endpoint) => refreshCfInfo(endpoint.guid)), getEndpointIdFromEntity: (entity: CfAPIResource) => entity.entity.cfGuid, globalPreRequest: (request, action) => { return addCfRelationParams(request, action); @@ -437,13 +436,11 @@ export function generateCFEntities(): StratosBaseCatalogEntity[] { generateCFBuildPackEntity(endpointDefinition), generateCFAppStatsEntity(endpointDefinition), generateCFUserProvidedServiceInstanceEntity(endpointDefinition), - generateCFInfoEntity(endpointDefinition), generateCFPrivateDomainEntity(endpointDefinition), generateCFSpaceQuotaEntity(endpointDefinition), generateCFAppSummaryEntity(endpointDefinition), generateCFAppEnvVarEntity(endpointDefinition), generateCFQuotaDefinitionEntity(endpointDefinition), - generateCFMetrics(endpointDefinition) ]; } @@ -608,29 +605,6 @@ function generateCFPrivateDomainEntity(endpointDefinition: StratosEndpointExtens return cfEntityCatalog.privateDomain; } -function generateCFInfoEntity(endpointDefinition: StratosEndpointExtensionDefinition) { - const cfInfoDefinition: IStratosEntityDefinition = { - type: cfInfoEntityType, - schema: cfEntityFactory(cfInfoEntityType), - label: 'Cloud Foundry Info', - labelPlural: 'Cloud Foundry Infos', - endpoint: endpointDefinition - }; - cfEntityCatalog.cfInfo = new StratosCatalogEntity, CfInfoDefinitionActionBuilders>( - cfInfoDefinition, - { - actionBuilders: cfInfoDefinitionActionBuilders, - entityBuilder: { - getMetadata: info => ({ - name: info.entity.name, - }), - getGuid: entity => entity.metadata.guid - } - } - ); - return cfEntityCatalog.cfInfo; -} - function generateCFUserProvidedServiceInstanceEntity(endpointDefinition: StratosEndpointExtensionDefinition) { const definition: IStratosEntityDefinition = { type: userProvidedServiceInstanceEntityType, @@ -1422,18 +1396,3 @@ function generateCfOrgEntity(endpointDefinition: StratosEndpointExtensionDefinit return cfEntityCatalog.org; } -function generateCFMetrics(endpointDefinition: StratosEndpointExtensionDefinition) { - const definition: IStratosEntityDefinition = { - type: metricEntityType, - schema: cfEntityFactory(metricEntityType), - label: 'CF Metric', - labelPlural: 'CF Metrics', - endpoint: endpointDefinition, - }; - cfEntityCatalog.metric = new StratosCatalogEntity( - definition, - { - } - ); - return cfEntityCatalog.metric; -} diff --git a/src/frontend/packages/cloud-foundry/src/cf-entity-types.ts b/src/frontend/packages/cloud-foundry/src/cf-entity-types.ts index 37036961e0..5dc21caba4 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-types.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-types.ts @@ -33,7 +33,6 @@ export const domainEntityType = 'domain'; export const organizationEntityType = 'organization'; export const quotaDefinitionEntityType = 'quota_definition'; export const cfEventEntityType = 'cloudFoundryEvent'; -export const cfInfoEntityType = 'cloudFoundryInfo'; export const cfUserEntityType = 'user'; export const appSummaryEntityType = 'applicationSummary'; export const appStatsEntityType = 'applicationStats'; diff --git a/src/frontend/packages/cloud-foundry/src/cloud-foundry-package.module.ts b/src/frontend/packages/cloud-foundry/src/cloud-foundry-package.module.ts index d2610cd619..d44741b7ed 100644 --- a/src/frontend/packages/cloud-foundry/src/cloud-foundry-package.module.ts +++ b/src/frontend/packages/cloud-foundry/src/cloud-foundry-package.module.ts @@ -1,10 +1,11 @@ import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { Injector, NgModule, inject } from '@angular/core'; import { MDAppModule } from '../../core/src/core/md.module'; import { SharedModule } from '@stratosui/core'; import { EntityCatalogModule } from '../../store/src/entity-catalog.module'; import { generateCFEntities } from './cf-entity-generator'; +import { setCfInfoHelperInjector } from './services/endpoint-data/cf-info-helper'; import { CfUserService } from './shared/data-services/cf-user.service'; import { CloudFoundryService } from './shared/data-services/cloud-foundry.service'; import { LongRunningCfOperationsService } from './shared/data-services/long-running-cf-op.service'; @@ -28,4 +29,12 @@ import { cfCurrentUserPermissionsService } from './user-permissions/cf-user-perm CloudFoundryUserProvidedServicesService, ] }) -export class CloudFoundryPackageModule { } +export class CloudFoundryPackageModule { + constructor() { + // W-e: capture root injector so the CF endpoint health-check callback + // (registered in cf-entity-generator at module-init time, outside any + // Angular injection context) can resolve CfInfoDataRegistry and trigger + // a signal-native refresh. Replaces the deleted GetCFInfo ngrx effect. + setCfInfoHelperInjector(inject(Injector)); + } +} diff --git a/src/frontend/packages/cloud-foundry/src/cloud-foundry-test.module.ts b/src/frontend/packages/cloud-foundry/src/cloud-foundry-test.module.ts index 622d2d9ac4..07846f93af 100644 --- a/src/frontend/packages/cloud-foundry/src/cloud-foundry-test.module.ts +++ b/src/frontend/packages/cloud-foundry/src/cloud-foundry-test.module.ts @@ -13,12 +13,9 @@ import { CfUserService } from './shared/data-services/cf-user.service'; import { LongRunningCfOperationsService } from './shared/data-services/long-running-cf-op.service'; import { CloudFoundryReducersModule } from './store/cloud-foundry.reducers.module'; import { cfCurrentUserPermissionsService } from './user-permissions/cf-user-permissions-checkers'; -import { AppEffects } from './store/effects/app.effects'; -import { CloudFoundryEffects } from './store/effects/cloud-foundry.effects'; import { DeployAppEffects } from './store/effects/deploy-app.effects'; import { CfValidateEffects } from './store/effects/request.effects'; import { ServiceInstanceEffects } from './store/effects/service-instance.effects'; -import { UpdateAppEffects } from './store/effects/update-app-effects'; import { UsersRolesEffects } from './store/effects/users-roles.effects'; @NgModule({ @@ -28,10 +25,7 @@ import { UsersRolesEffects } from './store/effects/users-roles.effects'; EffectsModule.forRoot([]), EffectsModule.forFeature([ DeployAppEffects, - CloudFoundryEffects, ServiceInstanceEffects, - AppEffects, - UpdateAppEffects, CfValidateEffects, UsersRolesEffects ]), diff --git a/src/frontend/packages/cloud-foundry/src/entity-action-builders/cf-info.action-builders.ts b/src/frontend/packages/cloud-foundry/src/entity-action-builders/cf-info.action-builders.ts deleted file mode 100644 index 86a6efea47..0000000000 --- a/src/frontend/packages/cloud-foundry/src/entity-action-builders/cf-info.action-builders.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { OrchestratedActionBuilders } from '../../../store/src/entity-catalog/action-orchestrator/action-orchestrator'; -import { GetCFInfo } from '../actions/cloud-foundry.actions'; - -export interface CfInfoDefinitionActionBuilders extends OrchestratedActionBuilders { - get: (cfGuid: string) => GetCFInfo; -} - -export const cfInfoDefinitionActionBuilders: CfInfoDefinitionActionBuilders = { - get: (cfGuid: string) => new GetCFInfo(cfGuid), -}; diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/app-detail-data.service.ts b/src/frontend/packages/cloud-foundry/src/features/applications/app-detail-data.service.ts index 9870238020..7fbf694116 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/app-detail-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/app-detail-data.service.ts @@ -7,6 +7,8 @@ import { AppLifecycleStateService } from './app-lifecycle-state.service'; import { ApplicationStateService, ApplicationStateData } from '../../shared/services/application-state.service'; import { IApp, IAppSummary } from '../../cf-api.types'; import { APIResource } from '@stratosui/store'; +import { CnsiAppsSource } from '../../services/data-sources/cnsi-apps-source'; +import { EndpointDataRegistry } from '../../services/endpoint-data/endpoint-data.registry'; import { EnvVarStratosProject } from './application/application-tabs-base/tabs/build-tab/application-env-vars.service'; import { StAppDetail, @@ -52,6 +54,7 @@ export class AppDetailDataService { private readonly prefs = inject(AppDetailPrefs); private readonly lifecycle = inject(AppLifecycleStateService); private readonly appStateService = inject(ApplicationStateService); + private readonly endpointDataRegistry = inject(EndpointDataRegistry); cnsiGuid!: string; appGuid!: string; @@ -367,13 +370,17 @@ export class AppDetailDataService { environment_json?: Record; }): Promise { this._updating.set(true); + // Route the PATCH through CnsiAppsSource so the canonical + // EDS._apps row updates immediately + the app.update cascade fires. + // The local refresh() below still re-fetches the rich detail + // (stats/env/routes) that the per-app cache doesn't track. + const eds = this.endpointDataRegistry.acquire(this.cnsiGuid); try { - await firstValueFrom(this.http.patch( - `/pp/v1/cf/apps/${this.cnsiGuid}/${this.appGuid}`, - body, - )); + const source = new CnsiAppsSource(this.cnsiGuid, this.http, eds); + await source.update(this.appGuid, body); await this.refresh('app'); } finally { + this.endpointDataRegistry.release(this.cnsiGuid); this._updating.set(false); } } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/metrics-tab/metrics-tab.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/metrics-tab/metrics-tab.component.ts index 1ae270146f..63e60a4090 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/metrics-tab/metrics-tab.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/tabs/metrics-tab/metrics-tab.component.ts @@ -8,10 +8,21 @@ import { ChartDataTypes, getMetricsChartConfigBuilder } from '@stratosui/core'; -import { MetricQueryConfig, IMetricMatrixResult, IMetricApplication } from '@stratosui/store'; -import { FetchApplicationChartMetricsAction } from '../../../../../../actions/cf-metrics.actions'; +import { MetricQueryConfig, MetricQueryType, MetricsRequest, IMetricMatrixResult, IMetricApplication } from '@stratosui/store'; import { ApplicationService } from '../../../../application.service'; +const APP_METRICS_BASE_URL = '/pp/v1/metrics'; + +function buildAppMetricRequest(appGuid: string, cfGuid: string, metric: string): MetricsRequest { + return { + endpointGuid: cfGuid, + url: `${APP_METRICS_BASE_URL}/cf/app/${appGuid}`, + query: new MetricQueryConfig(metric), + queryType: MetricQueryType.RANGE_QUERY, + windowValue: null, + }; +} + @Component({ selector: 'app-metrics-tab', templateUrl: './metrics-tab.component.html', @@ -31,32 +42,22 @@ export class MetricsTabComponent { ][]; constructor() { + const appGuid = this.applicationService.appGuid; + const cfGuid = this.applicationService.cfGuid; const chartConfigBuilder = getMetricsChartConfigBuilder(result => `Instance ${result.metric.instance_index}`); this.instanceMetricConfigs = [ chartConfigBuilder( - new FetchApplicationChartMetricsAction( - this.applicationService.appGuid, - this.applicationService.cfGuid, - new MetricQueryConfig('firehose_container_metric_cpu_percentage') - ), + buildAppMetricRequest(appGuid, cfGuid, 'firehose_container_metric_cpu_percentage'), 'CPU Usage (%)', ChartDataTypes.CPU_PERCENT ), chartConfigBuilder( - new FetchApplicationChartMetricsAction( - this.applicationService.appGuid, - this.applicationService.cfGuid, - new MetricQueryConfig('firehose_container_metric_memory_bytes') - ), + buildAppMetricRequest(appGuid, cfGuid, 'firehose_container_metric_memory_bytes'), 'Memory Usage (MB)', ChartDataTypes.BYTES ), chartConfigBuilder( - new FetchApplicationChartMetricsAction( - this.applicationService.appGuid, - this.applicationService.cfGuid, - new MetricQueryConfig('firehose_container_metric_disk_bytes') - ), + buildAppMetricRequest(appGuid, cfGuid, 'firehose_container_metric_disk_bytes'), 'Disk Usage (MB)', ChartDataTypes.BYTES ) diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application-step3/create-application-step3.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application-step3/create-application-step3.component.ts index c25d009e7f..520feac4e0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application-step3/create-application-step3.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application-step3/create-application-step3.component.ts @@ -1,15 +1,18 @@ -import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { AbstractControl, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms'; import { Store } from '@stratosui/store'; -import { Observable, of as observableOf, throwError } from 'rxjs'; +import { from, Observable, of as observableOf, throwError } from 'rxjs'; import { catchError, filter, map, mergeMap, switchMap } from 'rxjs/operators'; import { CustomFormFieldComponent, CustomSelectComponent, CustomOptionComponent, ErrorStateMatcher, ShowOnDirtyErrorStateMatcher, StepOnNextFunction } from '@stratosui/core'; import { RouterNav } from '@stratosui/store'; import { CFAppState } from '@stratosui/cloud-foundry'; -import type { StApp, StDomain, StRoute } from '../../../../services/endpoint-data/stratos-types'; +import { CnsiAppsSource } from '../../../../services/data-sources/cnsi-apps-source'; +import { CnsiRoutesSource } from '../../../../services/data-sources/cnsi-routes-source'; +import { EndpointDataRegistry } from '../../../../services/endpoint-data/endpoint-data.registry'; +import type { StDomain } from '../../../../services/endpoint-data/stratos-types'; import { selectNewAppState } from '../../../../store/selectors/create-application.selectors'; import { CreateNewApplicationState } from '../../../../store/types/create-application.types'; @@ -35,12 +38,19 @@ interface DomainHostForm { { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } ] }) -export class CreateApplicationStep3Component implements OnInit { +export class CreateApplicationStep3Component implements OnInit, OnDestroy { private store = inject(Store); private http = inject(HttpClient); + private endpointDataRegistry = inject(EndpointDataRegistry); setDomainHost: FormGroup; + // Acquired CNSI guid + acquire-count so destroy releases each acquire + // exactly once. createApp always acquires; createRoute acquires only + // when the user supplied a host+domain. + private acquiredCnsi: string | null = null; + private acquireCount = 0; + constructor() { this.setDomainHost = new FormGroup({ domain: new FormControl('', {validators: [Validators.required], nonNullable: true}), @@ -83,10 +93,14 @@ export class CreateApplicationStep3Component implements OnInit { private createApp(): Observable { const { cloudFoundryDetails, name } = this.newAppData; const { cloudFoundry, space } = cloudFoundryDetails; - return this.http.post(`/pp/v1/cf/apps/${cloudFoundry}`, { + const eds = this.endpointDataRegistry.acquire(cloudFoundry); + this.acquiredCnsi = cloudFoundry; + this.acquireCount++; + const source = new CnsiAppsSource(cloudFoundry, this.http, eds); + return from(source.create({ name, relationships: { space: { data: { guid: space } } }, - }).pipe( + })).pipe( map(app => app.guid), this.tagError('Could not create application'), ); @@ -100,18 +114,30 @@ export class CreateApplicationStep3Component implements OnInit { if (!selectedDomainGuid || !hostName) { return observableOf(null); } - return this.http.post(`/pp/v1/cf/routes/${cloudFoundry}`, { + const eds = this.endpointDataRegistry.acquire(cloudFoundry); + this.acquiredCnsi = cloudFoundry; + this.acquireCount++; + const source = new CnsiRoutesSource(cloudFoundry, this.http, eds); + return from(source.create({ host: hostName, relationships: { space: { data: { guid: space } }, domain: { data: { guid: selectedDomainGuid } }, }, - }).pipe( + })).pipe( map(route => route.guid), this.tagError('Application created. Could not create route'), ); } + ngOnDestroy() { + if (this.acquiredCnsi) { + for (let i = 0; i < this.acquireCount; i++) { + this.endpointDataRegistry.release(this.acquiredCnsi); + } + } + } + private associateRoute(cnsiGuid: string, appGuid: string, routeGuid: string): Observable { return this.http.put( `/pp/v1/cf/apps/${cnsiGuid}/${appGuid}/routes/${routeGuid}`, diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts index 1ac981d72f..037be75153 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts @@ -10,17 +10,12 @@ import { inject, signal, } from '@angular/core'; -import { Store } from '@stratosui/store'; import { Subscription, firstValueFrom } from 'rxjs'; -import { filter, take, tap } from 'rxjs/operators'; import { PageHeaderComponent, SignalStepHandle, StepComponent, SteppersComponent } from '@stratosui/core'; -import { CFAppState } from '@stratosui/cloud-foundry'; -import { applicationEntityType } from '../../../cf-entity-types'; -import { CfAppsDataSource } from '../../../shared/components/list/list-types/app/cf-apps-data-source'; import { CreateApplicationStep1Component } from '../../../shared/components/create-application/create-application-step1/create-application-step1.component'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; -import { selectCfPaginationState } from '../../../store/selectors/pagination.selectors'; +import { CfAppsSignalConfigService } from '../../../shared/components/list/list-types/app/cf-apps-signal-config.service'; import { CreateApplicationStep2Component } from './create-application-step2/create-application-step2.component'; import { CreateApplicationStep3Component } from './create-application-step3/create-application-step3.component'; @@ -42,11 +37,9 @@ import { CreateApplicationStep3Component } from './create-application-step3/crea }) export class CreateApplicationComponent implements OnInit, OnDestroy { - paginationStateSub?: Subscription; - - private store = inject(Store); private cdr = inject(ChangeDetectorRef); public cfOrgSpaceService = inject(CfOrgSpaceDataService); + private appsConfig = inject(CfAppsSignalConfigService); // FWT-959 Part 2 (Partition B): SignalStepHandle wiring for the 3-step // create-application flow. Cross-step state (CF/org/space + new app @@ -167,26 +160,24 @@ export class CreateApplicationComponent implements OnInit, OnDestroy { }; ngOnInit() { - // We will auto select endpoint/org/space that have been selected on the app wall. + // Auto-select endpoint/org/space from the apps wall's current filter + // selections — root-scoped CfAppsSignalConfigService holds them in + // signals, so the stepper reads them directly without a store hop. this.cfOrgSpaceService.enableAutoSelectors(); - // FIXME: This has been broken for a while (setting cf will clear org + space after org and space has been set) - // With new tools (set initial/enable auto) this should be easier to fix - const appWallPaginationState = this.store.select(selectCfPaginationState(applicationEntityType, CfAppsDataSource.paginationKey)); - this.paginationStateSub = appWallPaginationState.pipe(filter(pag => !!pag), take(1), tap(pag => { - const { cf, org, space } = pag.clientPagination.filter.items; - if (cf) { - this.cfOrgSpaceService.cf.select.set(cf); - } - if (cf && org) { - this.cfOrgSpaceService.org.select.set(org); - } - if (cf && org && space) { - this.cfOrgSpaceService.space.select.set(space); - } - })).subscribe(); + const cf = this.appsConfig.selectedCnsi(); + const org = this.appsConfig.selectedOrg(); + const space = this.appsConfig.selectedSpace(); + if (cf) { + this.cfOrgSpaceService.cf.select.set(cf); + } + if (cf && org) { + this.cfOrgSpaceService.org.select.set(org); + } + if (cf && org && space) { + this.cfOrgSpaceService.space.select.set(space); + } } ngOnDestroy(): void { - this.paginationStateSub?.unsubscribe(); this.step1Sub?.unsubscribe(); this.step2Sub?.unsubscribe(); this.step3Sub?.unsubscribe(); diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts index 3b50e9e30c..b0fadd738f 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/deploy-application/deploy-application.component.ts @@ -21,14 +21,12 @@ import { DeleteDeployAppSection, StoreCFSettings } from '../../../actions/deploy-applications.actions'; import { CFAppState } from '@stratosui/cloud-foundry'; -import { getCFEntityKey } from '../../../cf-entity-helpers'; -import { applicationEntityType } from '@stratosui/cloud-foundry'; import { selectApplicationSource } from '../../../store/selectors/deploy-application.selector'; import { DeployApplicationSource, SourceType } from '../../../store/types/deploy-application.types'; -import { RouterNav, selectPaginationState } from '@stratosui/store'; +import { RouterNav } from '@stratosui/store'; import { CfDeployAppDataService } from '../../../services/domain-data/cf-deploy-app-data.service'; -import { CfAppsDataSource } from '../../../shared/components/list/list-types/app/cf-apps-data-source'; +import { CfAppsSignalConfigService } from '../../../shared/components/list/list-types/app/cf-apps-signal-config.service'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; import { AUTO_SELECT_CF_URL_PARAM } from '../new-application-base-step/new-application-base-step.component'; import { ApplicationDeploySourceTypes } from './deploy-application-steps.types'; @@ -66,6 +64,7 @@ import { DeployApplicationStep3Component } from './deploy-application-step3/depl export class DeployApplicationComponent implements OnInit, OnDestroy { private store = inject>(Store); cfOrgSpaceService = inject(CfOrgSpaceDataService); + private appsConfig = inject(CfAppsSignalConfigService); private activatedRoute = inject(ActivatedRoute); private cdr = inject(ChangeDetectorRef); private deployData = inject(CfDeployAppDataService); @@ -78,7 +77,6 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { skipConfig$: Observable = observableOf(false); isRedeploy: boolean; selectedSourceType$: Observable; - entityKey: string; // Reactive deploy button text — switches to "Redeploy" when the // wizard is invoked with an existing appGuid. Step 4's handle reads @@ -361,7 +359,6 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { const activatedRoute = this.activatedRoute; const appDeploySourceTypes = inject(ApplicationDeploySourceTypes); - this.entityKey = getCFEntityKey(applicationEntityType); this.appGuid = this.activatedRoute.snapshot.queryParams.appGuid; this.isRedeploy = !!this.appGuid; @@ -418,21 +415,21 @@ export class DeployApplicationComponent implements OnInit, OnDestroy { }) ).subscribe()); } else { - this.initCfOrgSpaceService.push(this.store.select(selectPaginationState(this.entityKey, CfAppsDataSource.paginationKey)).pipe( - filter((pag) => !!pag), - tap(pag => { - const { cf, org, space } = pag.clientPagination.filter.items; - if (cf) { - this.cfOrgSpaceService.cf.select.set(cf); - } - if (org) { - this.cfOrgSpaceService.org.select.set(org); - } - if (space) { - this.cfOrgSpaceService.space.select.set(space); - } - }) - ).subscribe()); + // Auto-select endpoint/org/space from the apps wall's current filter + // (root-scoped CfAppsSignalConfigService keeps them in signals — read + // directly instead of round-tripping through ngrx pagination state). + const cf = this.appsConfig.selectedCnsi(); + const org = this.appsConfig.selectedOrg(); + const space = this.appsConfig.selectedSpace(); + if (cf) { + this.cfOrgSpaceService.cf.select.set(cf); + } + if (org) { + this.cfOrgSpaceService.org.select.set(org); + } + if (space) { + this.cfOrgSpaceService.space.select.set(space); + } // Delete any state in deployApplication this.store.dispatch(new DeleteDeployAppSection()); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/add-organization/create-organization-step/create-organization-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/add-organization/create-organization-step/create-organization-step.component.ts index af02c0cd7f..bb922c4e80 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/add-organization/create-organization-step/create-organization-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/add-organization/create-organization-step/create-organization-step.component.ts @@ -1,4 +1,5 @@ import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; import { Component, Injector, Input, OnDestroy, OnInit, ChangeDetectionStrategy, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { AbstractControl, ReactiveFormsModule, ValidatorFn, Validators, FormBuilder, FormControl, FormGroup } from '@angular/forms'; @@ -6,9 +7,9 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Observable, Subscription, firstValueFrom } from 'rxjs'; import { filter, map, tap } from 'rxjs/operators'; -import { AppInputDirective, CustomFormFieldComponent, CustomSelectComponent, CustomOptionComponent, FocusDirective, SignalStepHandle } from '@stratosui/core'; +import { AppInputDirective, CustomFormFieldComponent, CustomSelectComponent, CustomOptionComponent, FocusDirective, SignalStepHandle, TailwindSnackBarService } from '@stratosui/core'; +import { CnsiOrgsSource } from '../../../../services/data-sources/cnsi-orgs-source'; import { EndpointDataRegistry } from '../../../../services/endpoint-data/endpoint-data.registry'; -import { OrgWriteService } from '../../../../services/endpoint-data/org-write.service'; import { QuotaDataService, SignalSource } from '../../../../services/endpoint-data/quota-data.service'; import { StOrgQuota } from '../../../../services/endpoint-data/stratos-types'; @@ -35,11 +36,12 @@ interface CreateOrganizationForm { }) export class CreateOrganizationStepComponent implements OnInit, OnDestroy { private endpointDataRegistry = inject(EndpointDataRegistry); - private orgWriteService = inject(OrgWriteService); + private http = inject(HttpClient); private quotaData = inject(QuotaDataService); private injector = inject(Injector); private fb = inject(FormBuilder); private router = inject(Router); + private snackBar = inject(TailwindSnackBarService); private validSignal = signal(false); private formStatusSub?: Subscription; @@ -53,9 +55,21 @@ export class CreateOrganizationStepComponent implements OnInit, OnDestroy { const name = this.orgName.value; const quotaGuid = this.quotaDefinition.value; try { - const org = await firstValueFrom(this.orgWriteService.createOrg(this.cfGuid, { name })); - if (quotaGuid) { - await firstValueFrom(this.quotaData.applyOrgQuotaToOrgs(this.cfGuid, quotaGuid, [org.guid])); + // Route through CnsiOrgsSource so the new org is added to + // EndpointDataService._orgs immediately and org.create cascade + // fires. The previous OrgWriteService.createOrg path was a thin + // http.post that left the canonical cache stale — the new org + // didn't appear in the list until a hard reload. + const eds = this.endpointDataRegistry.acquire(this.cfGuid); + try { + const source = new CnsiOrgsSource(this.cfGuid, this.http, eds); + const org = await source.create({ name }); + if (quotaGuid) { + await firstValueFrom(this.quotaData.applyOrgQuotaToOrgs(this.cfGuid, quotaGuid, [org.guid])); + } + this.snackBar.open(`Organization "${name}" created`); + } finally { + this.endpointDataRegistry.release(this.cfGuid); } } catch (err: unknown) { throw new Error(`Failed to create organization: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts index f2fbc48204..919d2123bb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/add-space/create-space-step/create-space-step.component.ts @@ -5,14 +5,15 @@ import { Component, Injector, OnDestroy, OnInit, ChangeDetectionStrategy, effect import { AbstractControl, ReactiveFormsModule, ValidatorFn, Validators, FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { CustomSelectComponent, CustomOptionComponent } from '../../../../../../core/src/shared/components/custom-select/custom-select.component'; import { ActivatedRoute, Router } from '@angular/router'; -import { firstValueFrom, Observable, Subscription, throwError } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { firstValueFrom, from, Observable, Subscription, throwError } from 'rxjs'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { FocusDirective } from '../../../../../../core/src/shared/components/focus.directive'; import { SignalStepHandle, StepOnNextFunction, StepOnNextResult } from '../../../../../../core/src/shared/components/stepper/step/step.component'; +import { CnsiSpacesSource } from '../../../../services/data-sources/cnsi-spaces-source'; +import { EndpointDataRegistry } from '../../../../services/endpoint-data/endpoint-data.registry'; import { OrgDataRegistry } from '../../../../services/endpoint-data/org-data.registry'; import { QuotaDataService } from '../../../../services/endpoint-data/quota-data.service'; -import { StSpace } from '../../../../services/endpoint-data/stratos-types'; import { AddEditSpaceStepBase } from '../../add-edit-space-step-base'; import { ActiveRouteCfOrgSpace } from '../../cf-page.types'; @@ -42,6 +43,7 @@ export class CreateSpaceStepComponent extends AddEditSpaceStepBase implements On private fb = inject(FormBuilder); private router = inject(Router); private http = inject(HttpClient); + private endpointDataRegistry = inject(EndpointDataRegistry); /** See QuotaDefinitionFormComponent for rationale. */ private validSignal = signal(false); @@ -134,18 +136,22 @@ export class CreateSpaceStepComponent extends AddEditSpaceStepBase implements On submit: StepOnNextFunction = () => this.runCreate(); - // Chains POST /pp/v1/cf/spaces/:cnsi (V3-native space create) with an - // optional POST /pp/v1/cf/space_quotas/:cnsi/:quotaGuid/relationships/spaces - // when the wizard's quota dropdown carries a selection. CF v3 dropped the - // inline space_quota_definition_guid field that the V2 legacy action used, - // so the apply-quota step lives outside the create call. + // Routes the V3-native space create through CnsiSpacesSource so the + // new space lands in EndpointDataService._spaces and fires the + // space.create cascade — direct http.post bypassed the cache and the + // new row didn't appear in the spaces list until a hard reload. + // Quota-apply still uses a raw http.post; space_quotas don't have an + // EDS cache to patch, so the cascade alone is enough to invalidate + // any consumer reading quota_guid off the space. private runCreate(): Observable { const quotaValue = this.quotaDefinition.value; const quotaGuid = quotaValue ? String(quotaValue) : null; - return this.http.post(`/pp/v1/cf/spaces/${this.cfGuid}`, { + const eds = this.endpointDataRegistry.acquire(this.cfGuid); + const source = new CnsiSpacesSource(this.cfGuid, this.http, eds); + return from(source.create({ name: this.spaceName.value, relationships: { organization: { data: { guid: this.orgGuid } } }, - }).pipe( + })).pipe( switchMap(space => { if (!quotaGuid) { return [space]; @@ -163,6 +169,7 @@ export class CreateSpaceStepComponent extends AddEditSpaceStepBase implements On // Convert throwError to a successful emit of the failure result so // the stepper's pipeline can read it without rxjs error semantics. catchError((result: StepOnNextResult) => [result]), + tap(() => this.endpointDataRegistry.release(this.cfGuid)), ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cf-cell.helpers.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cf-cell.helpers.ts deleted file mode 100644 index 73211cb304..0000000000 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cf-cell.helpers.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Store } from '@ngrx/store'; -import { Observable, of } from 'rxjs'; -import { take, filter, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; - -import { endpointHasMetricsByAvailable } from '../../../../core/src/features/endpoints/endpoint-helpers'; -import { - AppState, - EndpointsDataService, - getPaginationObservables, - IMetrics, - MetricQueryConfig, - MetricQueryType, - PaginationMonitorFactory -} from '@stratosui/store'; -import { FetchCFCellMetricsPaginatedAction } from '../../actions/cf-metrics.actions'; -import { CFEntityConfig } from '../../cf-types'; -import { CellMetrics } from './tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell.service'; - -export class CfCellHelper { - - constructor( - private store: Store, - private paginationMonitorFactory: PaginationMonitorFactory, - private endpointsService: EndpointsDataService) { - } - - public createCellMetricAction(cfId: string, cellId?: string): Observable { - const cellIdString = cellId ? `{bosh_job_id="${cellId}"}` : ''; - - const newMetricAction: FetchCFCellMetricsPaginatedAction = new FetchCFCellMetricsPaginatedAction( - cfId, - cfId, - new MetricQueryConfig(CellMetrics.HEALTHY + cellIdString, {}), - MetricQueryType.QUERY - ); - return this.hasMetric(newMetricAction).pipe( - switchMap(hasNewMetric => hasNewMetric ? - of(hasNewMetric) : - this.hasMetric(new FetchCFCellMetricsPaginatedAction( - cfId, - cfId, - new MetricQueryConfig(CellMetrics.HEALTHY_DEP + cellIdString, {}), - MetricQueryType.QUERY - )) - ) - ); - } - - private hasMetric(action: FetchCFCellMetricsPaginatedAction): Observable { - return getPaginationObservables({ - store: this.store, - action, - paginationMonitor: this.paginationMonitorFactory.create( - action.paginationKey, - new CFEntityConfig(action.entityType), - true - ) - }).entities$.pipe( - filter(entities => !!entities && !!entities.length), - take(1), - map(entities => entities.find(entity => entity.data && entity.data.result.length) ? action : null), - publishReplay(1), - refCount() - ); - } - - public hasCellMetrics(endpointId: string): Observable { - return endpointHasMetricsByAvailable(this.endpointsService, endpointId).pipe( - // If metrics set up for this endpoint check if we can fetch cell metrics from it. - // If the metric is unknown an empty list is returned - switchMap(hasMetrics => hasMetrics ? this.createCellMetricAction(endpointId).pipe(map(action => !!action)) : of(false)) - ); - } -} 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 9db82d2d77..e18492b771 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 @@ -6,9 +6,10 @@ import { Router } from '@angular/router'; import { firstValueFrom, Observable, Subscription } from 'rxjs'; import { filter, map, take, tap } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; import { AppInputDirective, CustomFormFieldComponent, CustomOptionComponent, CustomSelectComponent, FocusDirective, SignalStepHandle, safeUnsubscribe } from '@stratosui/core'; +import { CnsiOrgsSource } from '../../../../services/data-sources/cnsi-orgs-source'; import { EndpointDataRegistry } from '../../../../services/endpoint-data/endpoint-data.registry'; -import { OrgWriteService } from '../../../../services/endpoint-data/org-write.service'; import { QuotaDataService, SignalSource } from '../../../../services/endpoint-data/quota-data.service'; import { StOrgQuota } from '../../../../services/endpoint-data/stratos-types'; import { @@ -51,7 +52,7 @@ interface EditOrganizationForm { export class EditOrganizationStepComponent implements OnInit, OnDestroy { private cfOrgService = inject(CloudFoundryOrganizationService); private endpointDataRegistry = inject(EndpointDataRegistry); - private orgWriteService = inject(OrgWriteService); + private http = inject(HttpClient); private quotaData = inject(QuotaDataService); private injector = inject(Injector); private fb = inject(FormBuilder); @@ -68,16 +69,28 @@ export class EditOrganizationStepComponent implements OnInit, OnDestroy { submit: async () => { const newName = this.orgName.value; const newQuotaGuid = this.quotaDefinition.value; + // Route the org PATCH through CnsiOrgsSource so the canonical + // EndpointDataService._orgs row updates immediately and the + // org.update cascade fires. The previous OrgWriteService.updateOrg + // path was a thin http.patch that left the canonical cache stale. + const eds = this.endpointDataRegistry.acquire(this.cfGuid); try { - await firstValueFrom(this.orgWriteService.updateOrg(this.cfGuid, this.orgGuid, { + const source = new CnsiOrgsSource(this.cfGuid, this.http, eds); + await source.update(this.orgGuid, { name: newName, suspended: !this.status, - })); + }); if (newQuotaGuid && newQuotaGuid !== this.originalQuotaGuid) { await firstValueFrom(this.quotaData.applyOrgQuotaToOrgs(this.cfGuid, newQuotaGuid, [this.orgGuid])); + // org_quotas isn't cached on EDS but the org row's quotaGuid + // is the source of truth for the displayed quota — mark stale + // so the orgs list re-fetches. + eds.applyCascade('org.update'); } } catch (err: unknown) { throw new Error(`Failed to update organization: ${err instanceof Error ? err.message : String(err)}`); + } finally { + this.endpointDataRegistry.release(this.cfGuid); } await this.router.navigateByUrl(this.redirectUrl); }, 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 b1967a6374..285810b8d1 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 @@ -4,7 +4,7 @@ import { Component, OnDestroy, OnInit, ChangeDetectionStrategy, Injector, inject import { toObservable } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule, FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { firstValueFrom, Observable, of, Subscription, throwError } from 'rxjs'; +import { firstValueFrom, from, Observable, of, Subscription, throwError } from 'rxjs'; import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { @@ -18,9 +18,10 @@ import { StepOnNextFunction, StepOnNextResult } from '@stratosui/core'; +import { CnsiSpacesSource } from '../../../../services/data-sources/cnsi-spaces-source'; +import { EndpointDataRegistry } from '../../../../services/endpoint-data/endpoint-data.registry'; import { OrgDataRegistry } from '../../../../services/endpoint-data/org-data.registry'; import { QuotaDataService } from '../../../../services/endpoint-data/quota-data.service'; -import { StSpace } from '../../../../services/endpoint-data/stratos-types'; import { AddEditSpaceStepBase } from '../../add-edit-space-step-base'; import { ActiveRouteCfOrgSpace } from '../../cf-page.types'; import { CloudFoundrySpaceService } from '../../services/cloud-foundry-space.service'; @@ -55,6 +56,7 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn private injector = inject(Injector); private router = inject(Router); private http = inject(HttpClient); + private endpointDataRegistry = inject(EndpointDataRegistry); /** See QuotaDefinitionFormComponent for rationale. */ private validSignal = signal(false); @@ -185,16 +187,18 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn (!this.originalSpaceQuotaGuid && !next); } - // Two-leg update: PATCH /pp/v1/cf/spaces/:cnsi/:spaceGuid (name) then - // PUT /pp/v1/cf/spaces/:cnsi/:spaceGuid/features/ssh (allow_ssh) when - // the toggle changed. CF v3 lifted SSH out of the space attributes - // endpoint into a separate feature, so the two-call chain stands in - // for the legacy single V2 PATCH /v2/spaces/{guid}. + // Two-leg update routes the name PATCH through CnsiSpacesSource so the + // canonical EndpointDataService._spaces row updates immediately + the + // space.update cascade fires. The SSH feature PUT is a side endpoint + // (CF v3 lifted SSH out of the space attributes endpoint) and isn't + // cached on EDS, so it stays as a raw http.put. updateSpace(): Observable { const name = this.editSpaceForm.value.spaceName as string; const allowSsh = !!this.editSpaceForm.value.toggleSsh; const sshChanged = allowSsh !== this.originalAllowSsh; - return this.http.patch(`/pp/v1/cf/spaces/${this.cfGuid}/${this.spaceGuid}`, { name }).pipe( + const eds = this.endpointDataRegistry.acquire(this.cfGuid); + const source = new CnsiSpacesSource(this.cfGuid, this.http, eds); + return from(source.update(this.spaceGuid, { name })).pipe( switchMap(() => { if (!sshChanged) { return of(true); @@ -210,6 +214,7 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn return throwError(() => ({ success: false, message } as StepOnNextResult)); }), catchError((result: StepOnNextResult) => [result]), + tap(() => this.endpointDataRegistry.release(this.cfGuid)), ); } @@ -218,10 +223,16 @@ export class EditSpaceStepComponent extends AddEditSpaceStepBase implements OnIn // detach (DELETE same path + /{spaceGuid}). On a quota change we just // need the new attachment; remove the previous attachment first when // one existed so the space ends up only with the new quota. + // + // After either leg completes, we mark the space cache stale so the + // spaces list (which renders the quota name) re-fetches the updated + // quotaGuid — space_quotas isn't cached on EDS, but the space row's + // quotaGuid field is the source of truth for the displayed quota. updateSpaceQuota(): Observable { const next = this.editSpaceForm.value.quotaDefinition; const nextGuid = next ? String(next) : null; const oldGuid = this.originalSpaceQuotaGuid || null; + const eds = this.endpointDataRegistry.acquire(this.cfGuid); const detach$ = oldGuid ? this.http.delete(`/pp/v1/cf/space_quotas/${this.cfGuid}/${oldGuid}/relationships/spaces/${this.spaceGuid}`) @@ -237,10 +248,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')), 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); }), + tap(() => this.endpointDataRegistry.release(this.cfGuid)), ); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts index 3c1eaecafb..a0c6191228 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-endpoint.service.ts @@ -243,12 +243,11 @@ export class CloudFoundryEndpointService { } fetchApps() { - // Signal-native refresh: re-runs loadDetails() which re-populates the - // apps signal. Internal call sites (org / space services) called this - // for "make sure the apps cache is warm before reading appsPagObs" — - // the new pipeline already enqueues loadDetails on acquire(), so this - // is now an explicit re-fetch (e.g. user-initiated refresh button). - this.endpointData.loadDetails().subscribe(); + // Signal-native refresh: refreshDetails() forces a re-fetch of + // orgs+apps+spaces, bypassing the cache guard that loadDetails() + // honours. This is the user-initiated refresh entry point — the + // initial-hydration path is loadDetails() on registry acquire. + this.endpointData.refreshDetails().subscribe(); } } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts index 24661bae5d..4214a38ff5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/services/cloud-foundry-organization.service.ts @@ -114,15 +114,6 @@ export class CloudFoundryOrganizationService { this.initialiseObservables(); } - public deleteSpace(spaceGuid: string, _orgGuid: string, endpointGuid: string): void { - // V3-native DELETE /pp/v1/cf/spaces/:cnsi/:guid — fire-and-forget. - // Callers that need outcome tracking should consume the http response - // directly (none today; the only caller dispatches and walks away). - this.http.delete(`/pp/v1/cf/spaces/${endpointGuid}/${spaceGuid}`).subscribe({ - error: () => {/* swallow — UI consumer reads via row refresh */}, - }); - } - public fetchApps() { this.cfEndpointService.fetchApps(); } diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.html b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.html index 2c981c5266..7c26d64659 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.html @@ -3,6 +3,6 @@

Cell: {{ name }}

} - + \ No newline at end of file diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.spec.ts index 1f6ae24a16..f6655bd371 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { describe, it, expect, beforeEach } from 'vitest'; import { of as observableOf } from 'rxjs'; @@ -10,6 +11,7 @@ import { EntityCatalogTestModule, TEST_CATALOGUE_ENTITIES, generateStratosEntiti import { STORE_TEST_PROVIDERS, createBasicStoreModule } from '@stratosui/store/testing'; import { ActiveRouteCfCell, generateCFEntities } from '@test-framework/cf'; import { AppTestModule, CoreTestingModule } from '@test-framework'; +import { getActiveRouteCfCellProvider } from '../../../../cf.helpers'; import { CloudFoundryEndpointService } from '../../../../services/cloud-foundry-endpoint.service'; import { CloudFoundryCellService } from '../cloud-foundry-cell.service'; import { CloudFoundryCellBaseComponent } from './cloud-foundry-cell-base.component'; @@ -21,7 +23,6 @@ class MockCloudFoundryCellService { cellId = 'cellId'; cellMetric$ = observableOf(null); healthy$ = observableOf(null); - healthyMetricId = null; cpus$ = observableOf(null); usageContainers$ = observableOf(null); remainingContainers$ = observableOf(null); @@ -38,6 +39,8 @@ describe('CloudFoundryCellBaseComponent', () => { let component: CloudFoundryCellBaseComponent; let fixture: ComponentFixture; + const mockCellService = new MockCloudFoundryCellService(); + beforeEach(() => { TestBed.configureTestingModule({ imports: [ @@ -53,12 +56,9 @@ describe('CloudFoundryCellBaseComponent', () => { provideZonelessChangeDetection(), provideRouter([]), provideHttpClient(), + provideHttpClientTesting(), ...STORE_TEST_PROVIDERS, CloudFoundryEndpointService, - { - provide: CloudFoundryCellService, - useValue: new MockCloudFoundryCellService(), - }, { provide: ActivatedRoute, useValue: { @@ -80,8 +80,16 @@ describe('CloudFoundryCellBaseComponent', () => { }, ActiveRouteCfCell, ] - }) - .compileComponents(); + }); + TestBed.overrideComponent(CloudFoundryCellBaseComponent, { + set: { + providers: [ + getActiveRouteCfCellProvider, + { provide: CloudFoundryCellService, useValue: mockCellService }, + ], + }, + }); + TestBed.compileComponents(); }); beforeEach(() => { diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts index b25c730b25..d5059282c4 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-base/cloud-foundry-cell-base.component.ts @@ -1,15 +1,13 @@ import { CommonModule } from '@angular/common'; import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { Observable } from 'rxjs'; -import { take, map } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { catchError, map, startWith, take } from 'rxjs/operators'; import { PageHeaderComponent } from '../../../../../../../../core/src/shared/components/page-header/page-header.component'; import { LoadingPageComponent } from '../../../../../../../../core/src/shared/components/loading-page/loading-page.component'; import { IPageSideNavTab } from '../../../../../../../../core/src/features/dashboard/page-side-nav/page-side-nav.component'; import { IHeaderBreadcrumb } from '../../../../../../../../core/src/shared/components/page-header/page-header.types'; -import { metricEntityType } from '../../../../../../../../store/src/helpers/stratos-entity-factory'; -import { cfEntityFactory } from '../../../../../../cf-entity-factory'; import { getActiveRouteCfCellProvider } from '../../../../cf.helpers'; import { CloudFoundryEndpointService } from '../../../../services/cloud-foundry-endpoint.service'; import { CloudFoundryCellService } from '../cloud-foundry-cell.service'; @@ -55,8 +53,7 @@ export class CloudFoundryCellBaseComponent { public breadcrumbs$: Observable; public name$: Observable; - public waitForEntityId: string; - public waitForEntitySchema = cfEntityFactory(metricEntityType); + public isLoading$: Observable; public cfCellService!: CloudFoundryCellService; @@ -65,7 +62,11 @@ export class CloudFoundryCellBaseComponent { const cfCellService = inject(CloudFoundryCellService); - this.waitForEntityId = cfCellService.healthyMetricId; + this.isLoading$ = cfCellService.cellMetric$.pipe( + map(() => false), + catchError(() => of(false)), + startWith(true) + ); this.name$ = cfCellService.cellMetric$.pipe( map(metric => `${metric.bosh_job_id}`) ); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-charts/cloud-foundry-cell-charts.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-charts/cloud-foundry-cell-charts.component.spec.ts index 9273c3dfe2..ff564595e2 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-charts/cloud-foundry-cell-charts.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-charts/cloud-foundry-cell-charts.component.spec.ts @@ -12,7 +12,6 @@ import { CloudFoundryTestingModule } from '../../../../../../cloud-foundry-test. import { ActiveRouteCfCell } from '../../../../cf-page.types'; import { CloudFoundryCellService } from '../cloud-foundry-cell.service'; import { CloudFoundryCellChartsComponent } from './cloud-foundry-cell-charts.component'; -import { FetchCFCellMetricsAction } from '../../../../../../actions/cf-metrics.actions'; class MockCloudFoundryCellService { cfGuid = 'cfGuid'; @@ -21,12 +20,13 @@ class MockCloudFoundryCellService { buildMetricConfig = (queryString: string, queryRange: MetricQueryType): MetricsConfig => ({ getSeriesName: (result: any) => `${result}`, mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, - metricsAction: new FetchCFCellMetricsAction( - 'guid', - 'cellId', - new MetricQueryConfig(queryString, {}), - queryRange, - ), + request: { + endpointGuid: 'cfGuid', + url: '/pp/v1/metrics/cf/cells', + query: new MetricQueryConfig(queryString, {}), + queryType: queryRange, + windowValue: null, + }, }) buildChartConfig = (yAxisLabel: string): MetricsLineChartConfig => ({ diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-summary/cloud-foundry-cell-summary.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-summary/cloud-foundry-cell-summary.component.spec.ts index 8826cc1f4a..13c30a527c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-summary/cloud-foundry-cell-summary.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell-summary/cloud-foundry-cell-summary.component.spec.ts @@ -27,7 +27,6 @@ import { } from '@stratosui/store'; import { STORE_TEST_PROVIDERS, testSCFEndpointGuid } from '@stratosui/store/testing'; import { generateCFEntities } from '@test-framework/cf'; -import { FetchCFCellMetricsAction } from '../../../../../../actions/cf-metrics.actions'; import { ActiveRouteCfCell } from '../../../../cf-page.types'; import { CloudFoundryCellService } from '../cloud-foundry-cell.service'; import { CloudFoundryCellSummaryComponent } from './cloud-foundry-cell-summary.component'; @@ -38,7 +37,6 @@ class MockCloudFoundryCellService { cellMetric$ = observableOf(null); healthy$ = observableOf(null); - healthyMetricId = null; cpus$ = observableOf(null); usageContainers$ = observableOf(null); @@ -56,12 +54,13 @@ class MockCloudFoundryCellService { buildMetricConfig = (queryString: string, queryRange: MetricQueryType): MetricsConfig => ({ getSeriesName: (result: any) => `${result}`, mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, - metricsAction: new FetchCFCellMetricsAction( - 'guid', - 'cellId', - new MetricQueryConfig(queryString, {}), - queryRange, - ), + request: { + endpointGuid: 'cfGuid', + url: '/pp/v1/metrics/cf/cells', + query: new MetricQueryConfig(queryString, {}), + queryType: queryRange, + windowValue: null, + }, }) buildChartConfig = (yAxisLabel: string): MetricsLineChartConfig => ({ chartType: MetricsChartTypes.LINE, diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell.service.ts index 116d22763e..69fc685ffb 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-cells/cloud-foundry-cell/cloud-foundry-cell.service.ts @@ -1,7 +1,6 @@ import { Injectable, inject } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { combineLatest, Observable } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { combineLatest, defer, Observable, from } from 'rxjs'; +import { map, shareReplay, switchMap } from 'rxjs/operators'; import { MetricsConfig } from '../../../../../../../core/src/shared/components/metrics-chart/metrics-chart.component'; import { MetricsLineChartConfig } from '../../../../../../../core/src/shared/components/metrics-chart/metrics-chart.types'; @@ -9,16 +8,13 @@ import { MetricsChartHelpers, } from '../../../../../../../core/src/shared/components/metrics-chart/metrics.component.helpers'; import { MetricQueryConfig } from '../../../../../../../store/src/actions/metrics.actions'; -import { AppState } from '../../../../../../../store/src/app-state'; -import { EntityServiceFactory } from '../../../../../../../store/src/entity-service-factory.service'; -import { PaginationMonitorFactory } from '../../../../../../../store/src/monitors/pagination-monitor.factory'; -import { EndpointsDataService } from '../../../../../../../store/src/services/endpoints-data.service'; -import { IMetricMatrixResult, IMetrics, IMetricVectorResult } from '../../../../../../../store/src/types/base-metric.types'; +import { MetricsDataService, MetricsRequest } from '../../../../../../../store/src/services/metrics-data.service'; +import { IMetricMatrixResult, IMetricVectorResult } from '../../../../../../../store/src/types/base-metric.types'; import { IMetricCell, MetricQueryType } from '../../../../../../../store/src/types/metric.types'; -import { FetchCFCellMetricsAction } from '../../../../../actions/cf-metrics.actions'; -import { CfCellHelper } from '../../../cf-cell.helpers'; import { ActiveRouteCfCell } from '../../../cf-page.types'; +const CELL_METRICS_BASE_URL = '/pp/v1/metrics/cf/cells'; + export const enum CellMetrics { /** @@ -46,7 +42,7 @@ export const enum CellMetrics { providedIn: 'root' }) export class CloudFoundryCellService { - private entityServiceFactory = inject(EntityServiceFactory); + private metricsDataService = inject(MetricsDataService); cfGuid!: string; @@ -54,7 +50,6 @@ export class CloudFoundryCellService { cellMetric$!: Observable; healthy$!: Observable; - healthyMetricId!: string; cpus$!: Observable; usageContainers$!: Observable; @@ -71,9 +66,6 @@ export class CloudFoundryCellService { constructor() { const activeRouteCfCell = inject(ActiveRouteCfCell); - const store = inject>(Store); - const paginationMonitorFactory = inject(PaginationMonitorFactory); - const endpointsService = inject(EndpointsDataService); this.cellId = activeRouteCfCell.cellId; @@ -91,18 +83,15 @@ export class CloudFoundryCellService { this.usageDisk$ = this.generateUsage(this.remainingDisk$, this.totalDisk$); this.usageMemory$ = this.generateUsage(this.remainingMemory$, this.totalMemory$); - const cellHelper = new CfCellHelper(store, paginationMonitorFactory, endpointsService); - const action$ = cellHelper.createCellMetricAction(this.cfGuid); - this.cellMetric$ = action$.pipe( - switchMap(action => { - this.healthyMetricId = action.guid; - return this.generate(action.query.metric as CellMetrics, true); - }) + // Probe both the post-v2.31 and pre-v2.31 health metrics; the first + // one that returns a value wins. Mirrors the previous CfCellHelper + // pagination probe but without the ngrx round-trip. + const healthMetric$ = defer(() => from(this.probeHealthMetric())).pipe(shareReplay(1)); + this.cellMetric$ = healthMetric$.pipe( + switchMap(metric => this.generateForMetric>(metric, true) as Observable) ); - this.healthy$ = action$.pipe( - switchMap(action => { - return this.generate(action.query.metric as CellMetrics, false); - }) + this.healthy$ = healthMetric$.pipe( + switchMap(metric => this.generate(metric)) ); } @@ -114,12 +103,13 @@ export class CloudFoundryCellService { getSeriesName: (result: IMetricMatrixResult) => `Cell ${result.metric.bosh_job_id}`, mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, mapSeriesItemValue, - metricsAction: new FetchCFCellMetricsAction( - this.cfGuid, - this.cellId, - new MetricQueryConfig(queryString + `{bosh_job_id="${this.cellId}"}`, {}), - queryRange - ), + request: { + endpointGuid: this.cfGuid, + url: CELL_METRICS_BASE_URL, + query: new MetricQueryConfig(queryString + `{bosh_job_id="${this.cellId}"}`, {}), + queryType: queryRange, + windowValue: null, + }, }; } @@ -131,32 +121,55 @@ export class CloudFoundryCellService { return lineChartConfig; } - private generate(metric: CellMetrics, isMetric = false, customAction?: FetchCFCellMetricsAction): Observable { - const action = customAction || new FetchCFCellMetricsAction( - this.cfGuid, - this.cellId, - new MetricQueryConfig(metric + `{bosh_job_id="${this.cellId}"}`, {}), - MetricQueryType.QUERY, - false - ); - return this.entityServiceFactory.create>>( - action.guid, - action, - ).waitForEntity$.pipe( - map(entityInfo => entityInfo.entity), + private generate(metric: string): Observable { + return this.generateForMetric>(metric, false) as Observable; + } + + // Single-value vector lookup. If `returnMetric` is true, returns the + // sample's `metric` label-set (used for the health-metric probe); + // otherwise returns the sample value or null. + private generateForMetric(metric: string, returnMetric: boolean): Observable { + const req: MetricsRequest = this.cellMetricRequest(metric); + return from(this.metricsDataService.fetch(req)).pipe( map(entity => { - if (!entity.data || !entity.data.result) { + const data = entity?.data as any; + if (!data || !data.result || data.result.length === 0) { return null; } - if (isMetric) { - return entity.data.result[0].metric; + if (returnMetric) { + return data.result[0].metric as IMetricCell; } - const res = entity.data.result; - return res && res.length ? entity.data.result[0].value[1] : null; + return data.result[0].value ? data.result[0].value[1] : null; }) ); } + private cellMetricRequest(metric: string): MetricsRequest { + return { + endpointGuid: this.cfGuid, + url: CELL_METRICS_BASE_URL, + query: new MetricQueryConfig(`${metric}{bosh_job_id="${this.cellId}"}`, {}), + queryType: MetricQueryType.QUERY, + windowValue: null, + }; + } + + // Picks the live health metric. Newer Diego (v2.31+) emits + // `HEALTHY` (garden_health_check_failed); older Diego emits + // `HEALTHY_DEP` (unhealthy_cell). We try the new one first and fall + // back if it returns no samples for this cell. + private async probeHealthMetric(): Promise { + const tryFetch = async (metric: CellMetrics) => { + const m = await this.metricsDataService.fetch(this.cellMetricRequest(metric)); + const result = m?.data?.result as any[] | undefined; + return result && result.length > 0; + }; + if (await tryFetch(CellMetrics.HEALTHY)) { + return CellMetrics.HEALTHY; + } + return CellMetrics.HEALTHY_DEP; + } + private generateUsage(remaining$: Observable, total$: Observable): Observable { return combineLatest([remaining$, total$]).pipe( map(([remaining, total]) => Number(total) - Number(remaining)) 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 096d7b9e67..5ca03cdca8 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ChangeDetectionStrategy, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, Signal, WritableSignal, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; @@ -95,7 +95,7 @@ export class CloudFoundryOrganizationSpaceQuotasComponent { totalPages: this.quotasConfig.view.totalPages, pageIndex: this.quotasConfig.pageIndex, pageSize: this.quotasConfig.pageSize, - isAnyLoading: computed(() => !this.quotasConfig.hasLoadedOnce()), + isAnyLoading: this.quotasConfig.loading, errorsByCnsi: signal(new Map()), columns: [ { 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 53142dfec4..3f045d921c 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ChangeDetectionStrategy, Signal, WritableSignal, computed, inject, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, Signal, WritableSignal, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; @@ -78,7 +78,7 @@ export class CloudFoundryQuotasComponent { totalPages: this.quotasConfig.view.totalPages, pageIndex: this.quotasConfig.pageIndex, pageSize: this.quotasConfig.pageSize, - isAnyLoading: computed(() => !this.quotasConfig.hasLoadedOnce()), + isAnyLoading: this.quotasConfig.loading, errorsByCnsi: signal(new Map()), columns: [ { 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 8d655461b1..b5559db9dd 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 @@ -159,6 +159,11 @@ export class CloudFoundryRoutesSignalComponent { options: this.routesConfig.orgOptions, selected: this.routesConfig.selectedOrg, }, + { + label: 'Space', + options: this.routesConfig.spaceOptions, + selected: this.routesConfig.selectedSpace, + }, ]; this.listConfig.set({ diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts index 32dcd989b8..6eb4ee46e0 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts @@ -95,6 +95,16 @@ export class CloudFoundryServicesSignalComponent implements OnInit { selected: this.instancesConfig.selectedCnsi, disabled: cnsiLocked, }, + { + label: 'Organization', + options: this.instancesConfig.orgOptions, + selected: this.instancesConfig.selectedOrg, + }, + { + label: 'Space', + options: this.instancesConfig.spaceOptions, + selected: this.instancesConfig.selectedSpace, + }, ]; const renderService = (si: StServiceInstance): string => diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html index 77367b920b..fe9cf424c7 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html @@ -1,6 +1,6 @@ (null), + selectedSpace: signal(null), viewMode: signal<'card' | 'table'>('table'), orgNameByGuid: signal(opts?.orgNames ?? new Map()).asReadonly(), spaceNameByGuid: signal(opts?.spaceNames ?? new Map()).asReadonly(), + orgOptions: signal([{ label: 'All', value: null }]).asReadonly(), + spaceOptions: signal([{ label: 'All', value: null }]).asReadonly(), hasLoadedOnce: signal(true).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 74e70b61bc..c6da16b953 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 @@ -7,6 +7,7 @@ import { SignalListCompoundSegment, SignalListComponent, SignalListConfig, + SignalListDropdown, SignalListHeaderAction, } from '@stratosui/core'; @@ -217,6 +218,18 @@ export class CloudFoundryUsersComponent { card: [6, 12, 24, 48, 96], }, nameFilter: this.usersConfig.nameFilter, + filterDropdowns: [ + { + label: 'Organization', + options: this.usersConfig.orgOptions, + selected: this.usersConfig.selectedOrg, + }, + { + label: 'Space', + options: this.usersConfig.spaceOptions, + selected: this.usersConfig.selectedSpace, + }, + ] as SignalListDropdown[], onRefresh: () => this.usersConfig.refresh(), onClear: () => this.usersConfig.clearFilters(), viewMode: this.usersConfig.viewMode, diff --git a/src/frontend/packages/cloud-foundry/src/features/services/detach-service-instance/detach-service-instance.component.ts b/src/frontend/packages/cloud-foundry/src/features/services/detach-service-instance/detach-service-instance.component.ts index 264bcefa2b..fb21eb060e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/detach-service-instance/detach-service-instance.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/services/detach-service-instance/detach-service-instance.component.ts @@ -1,6 +1,6 @@ import { DatePipe, NgClass } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { Component, Signal, signal, ChangeDetectionStrategy, computed, inject } from '@angular/core'; +import { Component, OnDestroy, Signal, signal, ChangeDetectionStrategy, computed, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { @@ -10,7 +10,8 @@ import { SteppersComponent, } from '@stratosui/core'; import { StratosJobError } from '../../../services/async-jobs/async-job.types'; -import { writeWithJob } from '../../../services/async-jobs/write-with-job'; +import { CnsiServiceBindingsSource } from '../../../services/data-sources/cnsi-service-bindings-source'; +import { EndpointDataRegistry } from '../../../services/endpoint-data/endpoint-data.registry'; import { ServiceCatalogDataService, SignalSource } from '../../../services/endpoint-data/service-catalog-data.service'; import { StServiceCredentialBinding, StServiceInstance } from '../../../services/endpoint-data/stratos-types'; import { DetachAppsComponent } from './detach-apps/detach-apps.component'; @@ -41,11 +42,17 @@ interface BindingRow { ], providers: [DatePipe] }) -export class DetachServiceInstanceComponent { +export class DetachServiceInstanceComponent implements OnDestroy { private datePipe = inject(DatePipe); private router = inject(Router); private http = inject(HttpClient); private serviceCatalog = inject(ServiceCatalogDataService); + private endpointDataRegistry = inject(EndpointDataRegistry); + + // Single source instance reused across the parallel delete calls; the + // EDS lookup happens once in the constructor so each delete patches the + // same canonical _serviceCredentialBindings list. + private bindingsSource!: CnsiServiceBindingsSource; private _instanceSource!: SignalSource; @@ -112,6 +119,12 @@ export class DetachServiceInstanceComponent { this.cfGuid = activatedRoute.snapshot.params.endpointId; const serviceInstanceId = activatedRoute.snapshot.params.serviceInstanceId; this._instanceSource = this.serviceCatalog.serviceInstance(this.cfGuid, serviceInstanceId); + const eds = this.endpointDataRegistry.acquire(this.cfGuid); + this.bindingsSource = new CnsiServiceBindingsSource(this.cfGuid, this.http, eds); + } + + ngOnDestroy() { + this.endpointDataRegistry.release(this.cfGuid); } setSelectedBindings = (selectedBindings: StServiceCredentialBinding[]) => { @@ -120,11 +133,11 @@ export class DetachServiceInstanceComponent { private async detachOne(bindingGuid: string): Promise { try { - const call = this.http.delete( - `/pp/v1/cf/service_bindings/${this.cfGuid}/${bindingGuid}`, - { observe: 'response' as const }, - ); - await writeWithJob(this.http, call); + // Route through CnsiServiceBindingsSource so the canonical + // EDS._serviceCredentialBindings list updates and the + // serviceBinding.delete cascade fires (apps/SI consumers + // re-fetch their binding rollups). + await this.bindingsSource.delete(bindingGuid); this.statusByGuid.update(prev => ({ ...prev, [bindingGuid]: 'success' })); } catch (err: unknown) { const message = err instanceof StratosJobError diff --git a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.spec.ts index fcc055d311..7bca03fb9c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.spec.ts @@ -44,10 +44,14 @@ function makeStubSignalConfigService() { view, orchestrator, selectedCnsi: signal(null), + selectedOrg: signal(null), + selectedSpace: signal(null), nameFilter: signal(''), filterField: signal('name'), viewMode: signal<'card' | 'table'>('card'), cnsiOptions: signal([allOption]).asReadonly(), + orgOptions: signal([allOption]).asReadonly(), + spaceOptions: signal([allOption]).asReadonly(), endpointNames: signal(new Map([['cnsi-1', 'CF 1']])).asReadonly(), }; } @@ -137,13 +141,15 @@ describe('ServicesWallComponent', () => { expect(serviceCol!.render!(ups)).toBe('User Provided'); }); - it('wires the CF filter dropdown into listConfig (no Org/Space)', async () => { + it('wires the CF / Organization / Space filter dropdowns into listConfig', async () => { await component.ngOnInit(); const cfg = component.listConfig(); expect(cfg).toBeDefined(); expect(cfg!.nameFilter).toBe(stubSignalConfig.nameFilter); expect(cfg!.filterDropdowns).toBeDefined(); - expect(cfg!.filterDropdowns!.map(d => d.label)).toEqual(['Cloud Foundry']); + expect(cfg!.filterDropdowns!.map(d => d.label)).toEqual(['Cloud Foundry', 'Organization', 'Space']); expect(cfg!.filterDropdowns![0].selected).toBe(stubSignalConfig.selectedCnsi); + expect(cfg!.filterDropdowns![1].selected).toBe(stubSignalConfig.selectedOrg); + expect(cfg!.filterDropdowns![2].selected).toBe(stubSignalConfig.selectedSpace); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts index f1bbe1f226..9de25ae19e 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts @@ -146,6 +146,16 @@ export class ServicesWallComponent implements OnInit { options: this.instancesConfig.cnsiOptions, selected: this.instancesConfig.selectedCnsi, }, + { + label: 'Organization', + options: this.instancesConfig.orgOptions, + selected: this.instancesConfig.selectedOrg, + }, + { + label: 'Space', + options: this.instancesConfig.spaceOptions, + selected: this.instancesConfig.selectedSpace, + }, ]; const renderService = (si: StServiceInstance): string => diff --git a/src/frontend/packages/cloud-foundry/src/integration/duplicate-url-collision.spec.ts b/src/frontend/packages/cloud-foundry/src/integration/duplicate-url-collision.spec.ts deleted file mode 100644 index 770e53835e..0000000000 --- a/src/frontend/packages/cloud-foundry/src/integration/duplicate-url-collision.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -// FWT-934 marquee integration test. Exercises the full composite-key dispatch -// path with N=3 and N=4 CF endpoints sharing a single URL (different auth -// contexts) — the scenario the 24014431d7 workaround was papering over. -// -// Why N>=3 (not just N=2): the entity-dictionary collision is pairwise, but -// race/middle-stomp patterns only surface with interleaved dispatches from -// three or more endpoints. At N=2 the "last write wins" bug hides behind -// what looks like "first → second" ordering; at N=3 you can see middle -// entities clobbered when dispatches interleave. -// -// Test posture: we capture the shim's dispatched WrapperRequestActionSuccess -// actions and assert on their entity-dictionary contents + composite keys. -// We don't run the NgRx reducer chain end-to-end — the catalog+reducer setup -// for that has unrelated environment brittleness in the cloud-foundry package -// test harness. The dispatch-contract level is the correct integration -// boundary for FWT-934: composite keys land in the entities map under the -// right IDs, the collision counter fires, and no bare-guid dispatch slips -// through. The entity-dictionary reducer itself is exercised in the store -// package's unit tests. -import { TestBed } from '@angular/core/testing'; -import { provideZonelessChangeDetection } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { EntityCatalogTestModuleManualStore, TEST_CATALOGUE_ENTITIES } from '@stratosui/store'; -import { createBasicStoreModule } from '@stratosui/store/testing'; -import { generateCFEntities } from '../cf-entity-generator'; -import { cfEntityId } from '../cf-entity-ref'; -import { StratosDiagnostics } from '../services/diagnostics/stratos-diagnostics.service'; -import { EndpointDataShim } from '../services/endpoint-data/endpoint-data.shim'; -import { StEndpointData, StOrg } from '../services/endpoint-data/stratos-types'; - -function emptyData(): StEndpointData { - return { - orgs: [], - orgCount: 0, - apps: [], - recentApps: [], - appCount: 0, - spaces: [], - routeCount: 0, - }; -} - -function makeOrg(cnsiGuid: string, bareGuid: string, name: string): StOrg { - return { - guid: bareGuid, - name, - status: 'active', - labels: {}, - annotations: {}, - createdAt: '2026-04-01T00:00:00Z', - updatedAt: '2026-04-01T00:00:00Z', - cnsiGuid, - }; -} - -// Collect all entity dictionaries dispatched under the cfOrganization key -// across multiple shim.write() calls. Simulates the flat view of the entity -// store that the pagination-reducer would produce. -function collectOrgDict(dispatched: Array<{ response: { entities: Record> } }>): Record { - const dict: Record = {}; - for (const call of dispatched) { - const orgs = call.response?.entities?.cfOrganization ?? {}; - Object.assign(dict, orgs); - } - return dict; -} - -describe('FWT-934 duplicate-URL collision (N=3-4)', () => { - let shim: EndpointDataShim; - let diagnostics: StratosDiagnostics; - let dispatched: Array<{ response: { entities: Record> } }>; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - { - ngModule: EntityCatalogTestModuleManualStore, - providers: [ - { provide: TEST_CATALOGUE_ENTITIES, useValue: generateCFEntities() }, - ], - }, - createBasicStoreModule({}), - ], - providers: [ - provideZonelessChangeDetection(), - EndpointDataShim, - ], - }); - shim = TestBed.inject(EndpointDataShim); - diagnostics = TestBed.inject(StratosDiagnostics); - diagnostics.reset(); - dispatched = []; - const store = TestBed.inject(Store); - vi.spyOn(store, 'dispatch').mockImplementation((action: { response?: { entities: Record> } } | unknown) => { - if (action && typeof action === 'object' && 'response' in action) { - dispatched.push(action as { response: { entities: Record> } }); - } - return undefined; - }); - }); - - afterEach(() => { - diagnostics.reset(); - }); - - it('N=3 CFs with shared-URL: all three composite IDs dispatched distinctly', () => { - const cnsis = ['cf-1', 'cf-2', 'cf-3']; - const sharedOrgGuid = 'org-shared'; - for (const cnsi of cnsis) { - shim.write(cnsi, { - ...emptyData(), - orgs: [makeOrg(cnsi, sharedOrgGuid, `Org from ${cnsi}`)], - orgCount: 1, - }); - } - const dict = collectOrgDict(dispatched); - for (const cnsi of cnsis) { - const id = cfEntityId({ cnsiGuid: cnsi, entityGuid: sharedOrgGuid }); - const entry = dict[id] as { entity: { cfGuid: string; name: string } }; - expect(entry).toBeDefined(); - expect(entry.entity.cfGuid).toBe(cnsi); - expect(entry.entity.name).toBe(`Org from ${cnsi}`); - } - expect(Object.keys(dict)).toHaveLength(3); - }); - - it('N=4 interleaved dispatches: every endpoint carries its own composite slot', () => { - const cnsis = ['cf-1', 'cf-2', 'cf-3', 'cf-4']; - const sharedOrgGuid = 'org-shared'; - // Fixed interleave order: cf-2, cf-4, cf-1, cf-3. Deterministic so any - // future regression has a reproducible failure mode. - const order = ['cf-2', 'cf-4', 'cf-1', 'cf-3']; - for (const cnsi of order) { - shim.write(cnsi, { - ...emptyData(), - orgs: [makeOrg(cnsi, sharedOrgGuid, `Org from ${cnsi}`)], - orgCount: 1, - }); - } - const dict = collectOrgDict(dispatched); - for (const cnsi of cnsis) { - const id = cfEntityId({ cnsiGuid: cnsi, entityGuid: sharedOrgGuid }); - const entry = dict[id] as { entity: { cfGuid: string; name: string } }; - expect(entry).toBeDefined(); - expect(entry.entity.cfGuid).toBe(cnsi); - expect(entry.entity.name).toBe(`Org from ${cnsi}`); - } - expect(Object.keys(dict)).toHaveLength(4); - }); - - it('every dispatched ID is composite (no bare-guid slips through)', () => { - const cnsis = ['cf-1', 'cf-2', 'cf-3']; - for (const cnsi of cnsis) { - shim.write(cnsi, { - ...emptyData(), - orgs: [makeOrg(cnsi, 'org-a', `Org A on ${cnsi}`)], - orgCount: 1, - apps: [{ - guid: 'app-a', - name: `App A on ${cnsi}`, - state: 'STARTED', - orgGuid: 'org-a', - spaceGuid: 'sp-a', - instances: 1, - createdAt: '', - updatedAt: '', - cnsiGuid: cnsi, - }], - appCount: 1, - spaces: [{ - guid: 'sp-a', - name: `Space A on ${cnsi}`, - orgGuid: 'org-a', - createdAt: '', - updatedAt: '', - cnsiGuid: cnsi, - }], - }); - } - for (const call of dispatched) { - for (const entityKey of Object.keys(call.response.entities)) { - for (const id of Object.keys(call.response.entities[entityKey])) { - expect(id, `dispatched entity id ${id} under ${entityKey} is not composite`).toContain(':'); - expect(id).toMatch(/^cf-\d+:/); - } - } - } - }); - - it('collision-avoided detection code runs without error across colliding writes', async () => { - // detectCollisions reads current store state via a selector — under the - // dispatch-spy harness the state never mutates, so the counter can't - // actually increment. What this test proves: the detection path executes - // cleanly across multi-CF writes without throwing. The counter's real - // increment semantics are verified in stratos-diagnostics.service.spec + - // will be re-verified against adepttech live traffic via - // window.stratosDiagnostics once the DIAGNOSTICS_ENABLED deploy lands. - // - // Note: the shim now dispatches only orgs + spaces (apps are owned by - // the app-wall's native fetch path — see shim write() comment). - const sharedOrgGuid = 'org-shared'; - shim.write('cf-1', { ...emptyData(), orgs: [makeOrg('cf-1', sharedOrgGuid, 'A')], orgCount: 1 }); - shim.write('cf-2', { ...emptyData(), orgs: [makeOrg('cf-2', sharedOrgGuid, 'B')], orgCount: 1 }); - await diagnostics.waitForFlush(); - // 2 writes × 2 dispatches each (orgs + spaces) = 4 dispatched actions. - expect(dispatched).toHaveLength(4); - }); - - it('cfEntityId composition is stable for identity', () => { - expect(cfEntityId({ cnsiGuid: 'cf-1', entityGuid: 'org-a' })).toBe('cf-1:org-a'); - expect(cfEntityId({ cnsiGuid: 'cf-2', entityGuid: 'org-a' })).toBe('cf-2:org-a'); - // Same bare guid, different cnsi → different composite → safe for the - // entity dictionary. This is the invariant FWT-934 depends on. - expect(cfEntityId({ cnsiGuid: 'cf-1', entityGuid: 'org-a' })) - .not.toBe(cfEntityId({ cnsiGuid: 'cf-2', entityGuid: 'org-a' })); - }); -}); diff --git a/src/frontend/packages/cloud-foundry/src/public_api.ts b/src/frontend/packages/cloud-foundry/src/public_api.ts index feab3c2a53..50e201b78d 100644 --- a/src/frontend/packages/cloud-foundry/src/public_api.ts +++ b/src/frontend/packages/cloud-foundry/src/public_api.ts @@ -57,7 +57,6 @@ export { organizationEntityType, quotaDefinitionEntityType, cfEventEntityType, - cfInfoEntityType, cfUserEntityType, appSummaryEntityType, appStatsEntityType, diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.spec.ts new file mode 100644 index 0000000000..be76ccf12a --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { CASCADE_RULES, cascadeFor } from './cascade-registry'; + +describe('cascade-registry', () => { + it('cascadeFor returns the registered entry', () => { + expect(cascadeFor('org.delete')).toEqual(['spaces', 'apps', 'serviceInstances', 'serviceCredentialBindings']); + }); + + it('cascadeFor("org.create") returns empty (no cascade on create)', () => { + expect(cascadeFor('org.create')).toEqual([]); + }); + + it('cascadeFor("space.delete") cascades to apps + SI + bindings (not orgs/spaces)', () => { + const list = cascadeFor('space.delete'); + expect(list).toContain('apps'); + expect(list).toContain('serviceInstances'); + expect(list).toContain('serviceCredentialBindings'); + expect(list).not.toContain('orgs'); + expect(list).not.toContain('spaces'); + }); + + it('cascadeFor("app.delete") drops only bindings (no cross-org cascade)', () => { + expect(cascadeFor('app.delete')).toEqual(['serviceCredentialBindings']); + }); + + it('cascadeFor("serviceBinding.delete") affects apps + serviceInstances', () => { + expect(cascadeFor('serviceBinding.delete')).toEqual(['apps', 'serviceInstances']); + }); + + it('cascadeFor("serviceBroker.delete") affects offerings + plans', () => { + expect(cascadeFor('serviceBroker.delete')).toEqual(['serviceOfferings', 'servicePlans']); + }); + + it('every CascadeKey has an entry in CASCADE_RULES (registry is complete)', () => { + // Compile-time exhaustiveness comes via the Record type; + // this runtime check just confirms entries are arrays. + for (const [key, list] of Object.entries(CASCADE_RULES)) { + expect(Array.isArray(list), `${key} should map to an array`).toBe(true); + } + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.ts new file mode 100644 index 0000000000..5074657e5e --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.ts @@ -0,0 +1,80 @@ +// Central declaration of which entity slices a mutation invalidates +// beyond its own slice. Sources call EndpointDataService.applyCascade(key) +// after a successful mutation; the service walks the entries here and +// flips the corresponding stale flag for each. +// +// Adding a mutation: add a new CascadeKey entry below. Keep the rule +// list tight — only entities whose server-side state is affected by +// this mutation belong here. Over-marking forces unnecessary refetches +// when the user navigates; under-marking leaves UI stale (the original +// bug class). + +export type EntityKind = + | 'orgs' + | 'apps' + | 'spaces' + | 'serviceInstances' + | 'serviceOfferings' + | 'servicePlans' + | 'serviceBrokers' + | 'serviceCredentialBindings'; + +export type CascadeKey = + | 'org.delete' + | 'org.create' + | 'org.update' + | 'space.delete' + | 'space.create' + | 'space.update' + | 'app.delete' + | 'app.create' + | 'app.update' + | 'route.delete' + | 'route.create' + | 'serviceInstance.delete' + | 'serviceInstance.create' + | 'serviceInstance.update' + | 'serviceBinding.delete' + | 'serviceBinding.create' + | 'serviceBroker.delete' + | 'serviceBroker.create'; + +export const CASCADE_RULES: Readonly> = { + // Deleting an org cascades server-side to its spaces, apps, routes, + // and service instances. All of those slices in EndpointDataService + // become potentially stale. + 'org.delete': ['spaces', 'apps', 'serviceInstances', 'serviceCredentialBindings'], + 'org.create': [], + 'org.update': [], + + // Deleting a space cascades to its apps, routes, service instances. + 'space.delete': ['apps', 'serviceInstances', 'serviceCredentialBindings'], + 'space.create': [], + 'space.update': [], + + // Apps own their service bindings; deleting an app drops bindings. + 'app.delete': ['serviceCredentialBindings'], + 'app.create': [], + 'app.update': [], + + // Routes attached to apps; deleting affects per-app route lists. + 'route.delete': ['apps'], + 'route.create': ['apps'], + + // Service instance lifecycle affects bound apps. + 'serviceInstance.delete': ['apps', 'serviceCredentialBindings'], + 'serviceInstance.create': [], + 'serviceInstance.update': [], + + // Service bindings link apps ↔ instances. + 'serviceBinding.delete': ['apps', 'serviceInstances'], + 'serviceBinding.create': ['apps', 'serviceInstances'], + + // Broker mutations invalidate offerings + plans (broker catalog). + 'serviceBroker.delete': ['serviceOfferings', 'servicePlans'], + 'serviceBroker.create': ['serviceOfferings', 'servicePlans'], +}; + +export function cascadeFor(key: CascadeKey): readonly EntityKind[] { + return CASCADE_RULES[key]; +} diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-apps-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-apps-source.ts index 0e288b35e5..5c6c8cdc81 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-apps-source.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-apps-source.ts @@ -1,11 +1,26 @@ +import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { CnsiEntitySource } from './cnsi-entity-source'; import type { StApp } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; import { writeWithJob } from '../async-jobs/write-with-job'; export class CnsiAppsSource extends CnsiEntitySource { protected readonly entityName = 'apps'; + // EDS is optional so existing call sites that haven't been threaded + // yet (cold bookmark / HMR fallback paths) keep working without it. + // When provided, mutations also patch EDS._apps and fire cascade + // markers so the cross-tab staleness model triggers. + constructor( + cnsiGuid: string, + http: HttpClient, + private readonly eds?: EndpointDataService, + pageSize: number = 100, + ) { + super(cnsiGuid, http, pageSize); + } + async delete(appGuid: string): Promise { // Route through writeWithJob so the Promise only resolves once CF's // async delete job is terminal (COMPLETE or, via thrown error, FAILED). @@ -14,16 +29,31 @@ export class CnsiAppsSource extends CnsiEntitySource { const call = this.http.delete(`/pp/v1/cf/apps/${this.cnsiGuid}/${appGuid}`, { observe: 'response' }); await writeWithJob(this.http, call); this.patchItems(items => items.filter(a => (a as { guid?: string }).guid !== appGuid)); + this.eds?.removeApp(appGuid); + this.eds?.applyCascade('app.delete'); } async update(appGuid: string, patch: Partial & Record): Promise { const updated = await firstValueFrom(this.http.patch(`/pp/v1/cf/apps/${this.cnsiGuid}/${appGuid}`, patch)); this.patchItems(items => items.map(a => (a as { guid?: string }).guid === appGuid ? { ...a, ...updated } : a)); + this.eds?.updateApp(appGuid, updated); + this.eds?.applyCascade('app.update'); + } + + async create(payload: unknown): Promise { + const created = await firstValueFrom(this.http.post(`/pp/v1/cf/apps/${this.cnsiGuid}`, payload)); + this.patchItems(items => [...items, created]); + this.eds?.addApp(created); + this.eds?.applyCascade('app.create'); + return created; } async action(appGuid: string, verb: 'start' | 'stop' | 'restart' | 'restage'): Promise { const updated = await firstValueFrom(this.http.post(`/pp/v1/cf/apps/${this.cnsiGuid}/${appGuid}/actions/${verb}`, null)); this.patchItems(items => items.map(a => (a as { guid?: string }).guid === appGuid ? { ...a, ...updated } : a)); + this.eds?.updateApp(appGuid, updated); + // Lifecycle verbs don't cascade — they only change app state, not the + // set of related entities. No applyCascade() call. } async deleteInstance(appGuid: string, index: number): Promise { @@ -32,5 +62,8 @@ export class CnsiAppsSource extends CnsiEntitySource { async assignRoute(appGuid: string, routeGuid: string): Promise { await firstValueFrom(this.http.put(`/pp/v1/cf/apps/${this.cnsiGuid}/${appGuid}/routes/${routeGuid}`, {})); + // Route binding affects the app's route mappings; cascade tells other + // surfaces (route lists, app summary route panel) to refetch. + this.eds?.applyCascade('route.create'); } } diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.spec.ts new file mode 100644 index 0000000000..cdfae6787b --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { CnsiOrgsSource } from './cnsi-orgs-source'; +import type { StOrg } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; + +function makeEds(): EndpointDataService { + return { + removeOrg: vi.fn(), + addOrg: vi.fn(), + updateOrg: vi.fn(), + applyCascade: vi.fn(), + } as unknown as EndpointDataService; +} + +describe('CnsiOrgsSource', () => { + it('delete: DELETE + writeWithJob + removeOrg + cascade("org.delete")', async () => { + const http = { + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiOrgsSource('cnsi-1', http, eds); + await src.delete('org-1'); + expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/orgs/cnsi-1/org-1', { observe: 'response' }); + expect(eds.removeOrg).toHaveBeenCalledWith('org-1'); + expect(eds.applyCascade).toHaveBeenCalledWith('org.delete'); + }); + + it('delete: does not patch EDS on HTTP failure', async () => { + const http = { + delete: vi.fn(() => { throw new Error('forbidden'); }), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiOrgsSource('cnsi-1', http, eds); + await expect(src.delete('org-1')).rejects.toThrow('forbidden'); + expect(eds.removeOrg).not.toHaveBeenCalled(); + expect(eds.applyCascade).not.toHaveBeenCalled(); + }); + + it('create: POST + addOrg + cascade("org.create")', async () => { + const newOrg: StOrg = { guid: 'org-2', name: 'new' } as unknown as StOrg; + const http = { + post: vi.fn(() => of(newOrg)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiOrgsSource('cnsi-1', http, eds); + const result = await src.create({ name: 'new' }); + expect(http.post).toHaveBeenCalledWith('/pp/v1/cf/orgs/cnsi-1', { name: 'new' }); + expect(eds.addOrg).toHaveBeenCalledWith(newOrg); + expect(eds.applyCascade).toHaveBeenCalledWith('org.create'); + expect(result).toEqual(newOrg); + }); + + it('update: PATCH + updateOrg + cascade("org.update")', async () => { + const updated: StOrg = { guid: 'org-1', name: 'renamed' } as unknown as StOrg; + const http = { + patch: vi.fn(() => of(updated)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiOrgsSource('cnsi-1', http, eds); + await src.update('org-1', { name: 'renamed' }); + expect(http.patch).toHaveBeenCalledWith('/pp/v1/cf/orgs/cnsi-1/org-1', { name: 'renamed' }); + expect(eds.updateOrg).toHaveBeenCalledWith('org-1', updated); + expect(eds.applyCascade).toHaveBeenCalledWith('org.update'); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.ts new file mode 100644 index 0000000000..0a0f59650a --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import type { StOrg } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; +import { writeWithJob } from '../async-jobs/write-with-job'; + +// Thin mutation surface for orgs. Org list state lives on +// EndpointDataService._orgs (the per-CNSI cache), not in a CnsiEntitySource +// subclass, because orgs are read by multiple unrelated pages (org list, +// org-space label service, summary cards) all sourced from one cache. This +// class is mutation-only — it issues the HTTP call, waits for the CF v3 +// async job to terminate, patches EndpointDataService's local cache, and +// fires the cascade marker so cross-entity slices (spaces / apps / SI / +// bindings) get refetched the next time they're read. +export class CnsiOrgsSource { + constructor( + readonly cnsiGuid: string, + private readonly http: HttpClient, + private readonly eds: EndpointDataService, + ) {} + + async delete(orgGuid: string): Promise { + const call = this.http.delete(`/pp/v1/cf/orgs/${this.cnsiGuid}/${orgGuid}`, { observe: 'response' }); + await writeWithJob(this.http, call); + this.eds.removeOrg(orgGuid); + this.eds.applyCascade('org.delete'); + } + + async create(payload: unknown): Promise { + const created = await firstValueFrom( + this.http.post(`/pp/v1/cf/orgs/${this.cnsiGuid}`, payload), + ); + this.eds.addOrg(created); + this.eds.applyCascade('org.create'); + return created; + } + + async update(orgGuid: string, patch: Partial & Record): Promise { + const updated = await firstValueFrom( + this.http.patch(`/pp/v1/cf/orgs/${this.cnsiGuid}/${orgGuid}`, patch), + ); + this.eds.updateOrg(orgGuid, updated); + this.eds.applyCascade('org.update'); + return updated; + } +} diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.spec.ts index 3b538e70dc..4edd29c2fb 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.spec.ts @@ -1,16 +1,41 @@ import { describe, it, expect, vi } from 'vitest'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { of } from 'rxjs'; import { CnsiRoutesSource } from './cnsi-routes-source'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; + +function makeEds(): EndpointDataService { + return { applyCascade: vi.fn() } as unknown as EndpointDataService; +} describe('CnsiRoutesSource', () => { - it('unmapApp(routeGuid, appGuid) issues DELETE /pp/v1/cf/routes/{cnsi}/{routeGuid}/apps/{appGuid}', async () => { + it('unmapApp(routeGuid, appGuid) issues DELETE /pp/v1/cf/routes/{cnsi}/{routeGuid}/apps/{appGuid} + cascade', async () => { const http = { get: vi.fn(() => of({ resources: [], pagination: { totalResults: 0, totalPages: 0, next: null, previous: null, first: { href: '' }, last: { href: '' } } })), delete: vi.fn(() => of(null)), } as unknown as HttpClient; - const src = new CnsiRoutesSource('cnsi-1', http); + const eds = makeEds(); + const src = new CnsiRoutesSource('cnsi-1', http, eds); await src.unmapApp('route-1', 'app-1'); expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/routes/cnsi-1/route-1/apps/app-1'); + expect(eds.applyCascade).toHaveBeenCalledWith('route.delete'); + }); + + it('delete(routeGuid) goes through writeWithJob + patches _items + cascade("route.delete")', async () => { + const resp = { + resources: [{ guid: 'route-1', host: 'foo' }], + pagination: { totalResults: 1, totalPages: 1, next: null, previous: null, first: { href: '' }, last: { href: '' } }, + }; + const http = { + get: vi.fn(() => of(resp)), + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiRoutesSource('cnsi-1', http, eds); + await src.load(); + await src.delete('route-1'); + expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/routes/cnsi-1/route-1', { observe: 'response' }); + expect(src.items().map(r => r.guid)).toEqual([]); + expect(eds.applyCascade).toHaveBeenCalledWith('route.delete'); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.ts index d95b0a13da..b8d8fdd237 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-routes-source.ts @@ -1,5 +1,8 @@ +import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { CnsiEntitySource } from './cnsi-entity-source'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; +import { writeWithJob } from '../async-jobs/write-with-job'; export interface StRoute { guid: string; @@ -12,7 +15,36 @@ export interface StRoute { export class CnsiRoutesSource extends CnsiEntitySource { protected readonly entityName = 'routes'; + constructor( + cnsiGuid: string, + http: HttpClient, + private readonly eds?: EndpointDataService, + pageSize: number = 100, + ) { + super(cnsiGuid, http, pageSize); + } + + async delete(routeGuid: string): Promise { + const call = this.http.delete( + `/pp/v1/cf/routes/${this.cnsiGuid}/${routeGuid}`, + { observe: 'response' }, + ); + await writeWithJob(this.http, call); + this.patchItems(items => items.filter(r => r.guid !== routeGuid)); + this.eds?.applyCascade('route.delete'); + } + + async create(payload: unknown): Promise { + const created = await firstValueFrom(this.http.post(`/pp/v1/cf/routes/${this.cnsiGuid}`, payload)); + this.patchItems(items => [...items, created]); + this.eds?.applyCascade('route.create'); + return created; + } + async unmapApp(routeGuid: string, appGuid: string): Promise { await firstValueFrom(this.http.delete(`/pp/v1/cf/routes/${this.cnsiGuid}/${routeGuid}/apps/${appGuid}`)); + // Unmapping doesn't delete the route — only the binding. Apps need + // refetching since route mappings on each app changed. + this.eds?.applyCascade('route.delete'); } } diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.spec.ts index 5db05e7b67..d09220c204 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.spec.ts @@ -1,28 +1,53 @@ import { describe, it, expect, vi } from 'vitest'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { of } from 'rxjs'; import { CnsiServiceBindingsSource } from './cnsi-service-bindings-source'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; + +function makeEds(): EndpointDataService { + return { + addServiceCredentialBinding: vi.fn(), + removeServiceCredentialBinding: vi.fn(), + applyCascade: vi.fn(), + } as unknown as EndpointDataService; +} describe('CnsiServiceBindingsSource', () => { - it('create(body) issues POST /pp/v1/cf/service_bindings/{cnsi}', async () => { + it('create(body) issues POST + patches items + cascade("serviceBinding.create")', async () => { const http = { get: vi.fn(() => of({ resources: [], pagination: { totalResults: 0, totalPages: 0, next: null, previous: null, first: { href: '' }, last: { href: '' } } })), post: vi.fn(() => of({ guid: 'b-1' })), } as unknown as HttpClient; - const src = new CnsiServiceBindingsSource('cnsi-1', http); + const eds = makeEds(); + const src = new CnsiServiceBindingsSource('cnsi-1', http, eds); const payload = { type: 'app', relationships: { app: { data: { guid: 'a' } }, service_instance: { data: { guid: 'si' } } } }; const created = await src.create(payload); expect(http.post).toHaveBeenCalledWith('/pp/v1/cf/service_bindings/cnsi-1', payload); expect((created as { guid: string }).guid).toBe('b-1'); + expect(eds.addServiceCredentialBinding).toHaveBeenCalledWith(created); + expect(eds.applyCascade).toHaveBeenCalledWith('serviceBinding.create'); }); - it('delete(bindingGuid) issues DELETE /pp/v1/cf/service_bindings/{cnsi}/{guid}', async () => { + it('delete(bindingGuid) goes through writeWithJob + patches items + cascade("serviceBinding.delete")', async () => { const http = { get: vi.fn(() => of({ resources: [], pagination: { totalResults: 0, totalPages: 0, next: null, previous: null, first: { href: '' }, last: { href: '' } } })), - delete: vi.fn(() => of(null)), + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), } as unknown as HttpClient; - const src = new CnsiServiceBindingsSource('cnsi-1', http); + const eds = makeEds(); + const src = new CnsiServiceBindingsSource('cnsi-1', http, eds); await src.delete('b-1'); - expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/service_bindings/cnsi-1/b-1'); + expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/service_bindings/cnsi-1/b-1', { observe: 'response' }); + expect(eds.removeServiceCredentialBinding).toHaveBeenCalledWith('b-1'); + expect(eds.applyCascade).toHaveBeenCalledWith('serviceBinding.delete'); + }); + + it('eds is optional — source still patches its own _items without it', async () => { + const http = { + get: vi.fn(() => of({ resources: [], pagination: { totalResults: 0, totalPages: 0, next: null, previous: null, first: { href: '' }, last: { href: '' } } })), + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const src = new CnsiServiceBindingsSource('cnsi-1', http); // no eds + await src.delete('b-1'); // does not throw + expect(http.delete).toHaveBeenCalled(); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.ts index 953f43128d..194f0cbe91 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-bindings-source.ts @@ -1,16 +1,40 @@ +import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { CnsiEntitySource } from './cnsi-entity-source'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; +import type { StServiceCredentialBinding } from '../endpoint-data/stratos-types'; +import { writeWithJob } from '../async-jobs/write-with-job'; -export interface StServiceBinding { guid: string; cnsiGuid?: string; } - -export class CnsiServiceBindingsSource extends CnsiEntitySource { +export class CnsiServiceBindingsSource extends CnsiEntitySource { protected readonly entityName = 'service_bindings'; - async create(payload: unknown): Promise { - return firstValueFrom(this.http.post(`/pp/v1/cf/service_bindings/${this.cnsiGuid}`, payload)); + constructor( + cnsiGuid: string, + http: HttpClient, + private readonly eds?: EndpointDataService, + pageSize: number = 100, + ) { + super(cnsiGuid, http, pageSize); + } + + async create(payload: unknown): Promise { + const created = await firstValueFrom( + this.http.post(`/pp/v1/cf/service_bindings/${this.cnsiGuid}`, payload), + ); + this.patchItems(items => [...items, created]); + this.eds?.addServiceCredentialBinding(created); + this.eds?.applyCascade('serviceBinding.create'); + return created; } async delete(bindingGuid: string): Promise { - await firstValueFrom(this.http.delete(`/pp/v1/cf/service_bindings/${this.cnsiGuid}/${bindingGuid}`)); + const call = this.http.delete( + `/pp/v1/cf/service_bindings/${this.cnsiGuid}/${bindingGuid}`, + { observe: 'response' }, + ); + await writeWithJob(this.http, call); + this.patchItems(items => items.filter(b => b.guid !== bindingGuid)); + this.eds?.removeServiceCredentialBinding(bindingGuid); + this.eds?.applyCascade('serviceBinding.delete'); } } diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.spec.ts new file mode 100644 index 0000000000..e7b059e582 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { CnsiServiceInstancesSource } from './cnsi-service-instances-source'; +import type { StServiceInstance } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; + +function makeEds(): EndpointDataService { + return { + addServiceInstance: vi.fn(), + removeServiceInstance: vi.fn(), + updateServiceInstance: vi.fn(), + applyCascade: vi.fn(), + } as unknown as EndpointDataService; +} + +describe('CnsiServiceInstancesSource mutations', () => { + it('delete: DELETE + writeWithJob + patchItems + removeServiceInstance + cascade("serviceInstance.delete")', async () => { + const resp = { + resources: [{ guid: 'si-1', name: 'mydb' }] as unknown as StServiceInstance[], + pagination: { totalResults: 1, totalPages: 1, next: null, previous: null, first: { href: '' }, last: { href: '' } }, + }; + const http = { + get: vi.fn(() => of(resp)), + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiServiceInstancesSource('cnsi-1', http, eds); + await src.load(); + await src.delete('si-1'); + expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/service_instances/cnsi-1/si-1', { observe: 'response' }); + expect(src.items().map(s => s.guid)).toEqual([]); + expect(eds.removeServiceInstance).toHaveBeenCalledWith('si-1'); + expect(eds.applyCascade).toHaveBeenCalledWith('serviceInstance.delete'); + }); + + it('create: POST + patchItems + addServiceInstance + cascade("serviceInstance.create")', async () => { + const created = { guid: 'si-2', name: 'cache' } as unknown as StServiceInstance; + const http = { + get: vi.fn(() => of({ resources: [], pagination: { totalResults: 0, totalPages: 0, next: null, previous: null, first: { href: '' }, last: { href: '' } } })), + post: vi.fn(() => of(created)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiServiceInstancesSource('cnsi-1', http, eds); + const result = await src.create({ name: 'cache' }); + expect(http.post).toHaveBeenCalledWith('/pp/v1/cf/service_instances/cnsi-1', { name: 'cache' }); + expect(result).toEqual(created); + expect(eds.addServiceInstance).toHaveBeenCalledWith(created); + expect(eds.applyCascade).toHaveBeenCalledWith('serviceInstance.create'); + }); + + it('update: PATCH + patchItems + updateServiceInstance + cascade("serviceInstance.update")', async () => { + const resp = { + resources: [{ guid: 'si-1', name: 'old' }] as unknown as StServiceInstance[], + pagination: { totalResults: 1, totalPages: 1, next: null, previous: null, first: { href: '' }, last: { href: '' } }, + }; + const updated = { guid: 'si-1', name: 'renamed' } as unknown as StServiceInstance; + const http = { + get: vi.fn(() => of(resp)), + patch: vi.fn(() => of(updated)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiServiceInstancesSource('cnsi-1', http, eds); + await src.load(); + await src.update('si-1', { name: 'renamed' }); + expect(http.patch).toHaveBeenCalledWith('/pp/v1/cf/service_instances/cnsi-1/si-1', { name: 'renamed' }); + expect(eds.updateServiceInstance).toHaveBeenCalledWith('si-1', updated); + expect(eds.applyCascade).toHaveBeenCalledWith('serviceInstance.update'); + }); + + it('eds optional — source still patches its own _items', async () => { + const resp = { + resources: [{ guid: 'si-1' }] as unknown as StServiceInstance[], + pagination: { totalResults: 1, totalPages: 1, next: null, previous: null, first: { href: '' }, last: { href: '' } }, + }; + const http = { + get: vi.fn(() => of(resp)), + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const src = new CnsiServiceInstancesSource('cnsi-1', http); // no eds + await src.load(); + await src.delete('si-1'); + expect(src.items().map(s => s.guid)).toEqual([]); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.ts index 1b51eb370d..75e2ad5b6d 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.ts @@ -1,15 +1,65 @@ +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; import { CnsiEntitySource } from './cnsi-entity-source'; import type { StServiceInstance } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; +import { writeWithJob } from '../async-jobs/write-with-job'; // Per-CNSI source for service instances. Reads // /pp/v1/cf/service_instances/{cnsi}, which now emits the nested-ref // StServiceInstance shape natively at every ?return= tier (no wire // adapter needed). The CnsiEntitySource base class walks pagination via // the v3 envelope's pagination links. +// +// Mutations go through writeWithJob → patchItems → EDS local-cache patch +// → applyCascade. The EDS reference is optional so existing read-only +// construction sites that haven't been threaded yet (e.g. cold bookmarks) +// still work — when omitted, the source still patches its own _items but +// no cross-tab cascade fires; callers should pass EDS once available. export class CnsiServiceInstancesSource extends CnsiEntitySource { protected readonly entityName = 'service_instances'; + constructor( + cnsiGuid: string, + http: HttpClient, + private readonly eds?: EndpointDataService, + pageSize: number = 100, + ) { + super(cnsiGuid, http, pageSize); + } + protected adaptResource(raw: unknown, cnsiGuid: string): StServiceInstance { return { ...(raw as StServiceInstance), cnsiGuid }; } + + async create(payload: unknown): Promise { + const created = await firstValueFrom( + this.http.post(`/pp/v1/cf/service_instances/${this.cnsiGuid}`, payload), + ); + this.patchItems(items => [...items, created]); + this.eds?.addServiceInstance(created); + this.eds?.applyCascade('serviceInstance.create'); + return created; + } + + async update(siGuid: string, patch: Partial & Record): Promise { + const updated = await firstValueFrom( + this.http.patch(`/pp/v1/cf/service_instances/${this.cnsiGuid}/${siGuid}`, patch), + ); + this.patchItems(items => items.map(si => si.guid === siGuid ? { ...si, ...updated } : si)); + this.eds?.updateServiceInstance(siGuid, updated); + this.eds?.applyCascade('serviceInstance.update'); + return updated; + } + + async delete(siGuid: string): Promise { + const call = this.http.delete( + `/pp/v1/cf/service_instances/${this.cnsiGuid}/${siGuid}`, + { observe: 'response' }, + ); + await writeWithJob(this.http, call); + this.patchItems(items => items.filter(si => si.guid !== siGuid)); + this.eds?.removeServiceInstance(siGuid); + this.eds?.applyCascade('serviceInstance.delete'); + } } diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.spec.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.spec.ts new file mode 100644 index 0000000000..23d5787a92 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { CnsiSpacesSource } from './cnsi-spaces-source'; +import type { StSpace } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; + +function makeEds(): EndpointDataService { + return { + removeSpace: vi.fn(), + addSpace: vi.fn(), + updateSpace: vi.fn(), + applyCascade: vi.fn(), + } as unknown as EndpointDataService; +} + +describe('CnsiSpacesSource', () => { + it('delete: DELETE + writeWithJob + removeSpace + cascade("space.delete")', async () => { + const http = { + delete: vi.fn(() => of(new HttpResponse({ status: 200, body: null }))), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiSpacesSource('cnsi-1', http, eds); + await src.delete('sp-1'); + expect(http.delete).toHaveBeenCalledWith('/pp/v1/cf/spaces/cnsi-1/sp-1', { observe: 'response' }); + expect(eds.removeSpace).toHaveBeenCalledWith('sp-1'); + expect(eds.applyCascade).toHaveBeenCalledWith('space.delete'); + }); + + it('create: POST + addSpace + cascade("space.create")', async () => { + const newSpace: StSpace = { guid: 'sp-2', name: 'new' } as unknown as StSpace; + const http = { + post: vi.fn(() => of(newSpace)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiSpacesSource('cnsi-1', http, eds); + const result = await src.create({ name: 'new' }); + expect(http.post).toHaveBeenCalledWith('/pp/v1/cf/spaces/cnsi-1', { name: 'new' }); + expect(eds.addSpace).toHaveBeenCalledWith(newSpace); + expect(eds.applyCascade).toHaveBeenCalledWith('space.create'); + expect(result).toEqual(newSpace); + }); + + it('update: PATCH + updateSpace + cascade("space.update")', async () => { + const updated: StSpace = { guid: 'sp-1', name: 'renamed' } as unknown as StSpace; + const http = { + patch: vi.fn(() => of(updated)), + } as unknown as HttpClient; + const eds = makeEds(); + const src = new CnsiSpacesSource('cnsi-1', http, eds); + await src.update('sp-1', { name: 'renamed' }); + expect(http.patch).toHaveBeenCalledWith('/pp/v1/cf/spaces/cnsi-1/sp-1', { name: 'renamed' }); + expect(eds.updateSpace).toHaveBeenCalledWith('sp-1', updated); + expect(eds.applyCascade).toHaveBeenCalledWith('space.update'); + }); +}); diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.ts new file mode 100644 index 0000000000..8d29461997 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.ts @@ -0,0 +1,42 @@ +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import type { StSpace } from '../endpoint-data/stratos-types'; +import { EndpointDataService } from '../endpoint-data/endpoint-data.service'; +import { writeWithJob } from '../async-jobs/write-with-job'; + +// Thin mutation surface for spaces. Mirrors CnsiOrgsSource — see that +// file for the architectural rationale. Spaces cache lives on +// EndpointDataService._spaces; this class patches it on success and fires +// the cascade marker for downstream slices (apps / SI / bindings). +export class CnsiSpacesSource { + constructor( + readonly cnsiGuid: string, + private readonly http: HttpClient, + private readonly eds: EndpointDataService, + ) {} + + async delete(spaceGuid: string): Promise { + const call = this.http.delete(`/pp/v1/cf/spaces/${this.cnsiGuid}/${spaceGuid}`, { observe: 'response' }); + await writeWithJob(this.http, call); + this.eds.removeSpace(spaceGuid); + this.eds.applyCascade('space.delete'); + } + + async create(payload: unknown): Promise { + const created = await firstValueFrom( + this.http.post(`/pp/v1/cf/spaces/${this.cnsiGuid}`, payload), + ); + this.eds.addSpace(created); + this.eds.applyCascade('space.create'); + return created; + } + + async update(spaceGuid: string, patch: Partial & Record): Promise { + const updated = await firstValueFrom( + this.http.patch(`/pp/v1/cf/spaces/${this.cnsiGuid}/${spaceGuid}`, patch), + ); + this.eds.updateSpace(spaceGuid, updated); + this.eds.applyCascade('space.update'); + return updated; + } +} diff --git a/src/frontend/packages/cloud-foundry/src/services/diagnostics/diagnostics.types.ts b/src/frontend/packages/cloud-foundry/src/services/diagnostics/diagnostics.types.ts index 239d2e774a..1feb245a76 100644 --- a/src/frontend/packages/cloud-foundry/src/services/diagnostics/diagnostics.types.ts +++ b/src/frontend/packages/cloud-foundry/src/services/diagnostics/diagnostics.types.ts @@ -10,6 +10,7 @@ export const DIAGNOSTIC_CODE_FAMILIES = [ 'cache-miss', 'in-flight-hit', 'buffer-overflow', + 'cascade-apply', ] as const; export type DiagnosticCode = typeof DIAGNOSTIC_CODE_FAMILIES[number]; diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.service.ts index 0bfd322f14..f73409711b 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-data.service.ts @@ -36,6 +36,16 @@ export class CfInfoDataService { private readonly diagnostics?: StratosDiagnostics, ) {} + /** + * Bypass the warm-cache short-circuit and force a fresh `/pp/v1/cf/info/{cnsi}` + * fetch. Used by the CF endpoint health-check pulse — load() would otherwise + * return the cached value forever once warm. In-flight dedup still applies. + */ + refresh(): Observable { + this._lastFetched.set(null); + return this.load(); + } + load(): Observable { this.diagnostics?.emitCounter('service-call-count', { service: 'CfInfoDataService', method: 'load' }); if (this._lastFetched() !== null && this._info() !== null) { diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-helper.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-helper.ts new file mode 100644 index 0000000000..ca9cc0c2b9 --- /dev/null +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-helper.ts @@ -0,0 +1,25 @@ +import { Injector } from '@angular/core'; + +import { CfInfoDataRegistry } from './cf-info-data.registry'; + +// Module-level injector reference captured by CloudFoundryPackageModule +// (mirrors the cf-autoscaler pattern in autoscaler-available.ts). The CF +// endpoint health-check callback is registered at entity-generator time, +// which runs outside an Angular injection context, so it reaches the +// CfInfoDataRegistry through this captured root injector. +let helperInjector: Injector | null = null; + +export function setCfInfoHelperInjector(injector: Injector): void { + helperInjector = injector; +} + +export function refreshCfInfo(cnsiGuid: string): void { + if (!helperInjector) { + // Module not yet initialized — drop the refresh on the floor. The CF + // package module constructor sets this synchronously at bootstrap, so + // this only fires in test setups that don't bring in the package. + return; + } + const registry = helperInjector.get(CfInfoDataRegistry); + registry.acquire(cnsiGuid).refresh().subscribe({ error: () => { /* errors land in service.errors() */ } }); +} diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts index 0479c9e459..9a67276e94 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.spec.ts @@ -443,4 +443,210 @@ describe('EndpointDataService', () => { httpMock.expectOne(ORGS_FULL_URL).flush({ resources: [], pagination: { totalResults: 0, totalPages: 1 } }); }); }); + + describe('staleness + refresh + cascade (Phase 1)', () => { + const mockOrgs = [{ guid: 'org-1', name: 'Org', status: 'active', labels: {}, annotations: {}, createdAt: '', updatedAt: '' }]; + const mockApps = [{ guid: 'app-1', cnsiGuid: 'test-cnsi-guid', name: 'A', state: 'STARTED', orgGuid: '', spaceGuid: '', instances: 1, createdAt: '', updatedAt: '' }]; + const mockSpaces = [{ guid: 'sp-1', cnsiGuid: 'test-cnsi-guid', name: 'sp', orgGuid: 'org-1', createdAt: '', updatedAt: '' }]; + + it('stale signals default false', () => { + expect(service.orgsStale()).toBe(false); + expect(service.appsStale()).toBe(false); + expect(service.spacesStale()).toBe(false); + expect(service.serviceInstancesStale()).toBe(false); + expect(service.serviceOfferingsStale()).toBe(false); + expect(service.servicePlansStale()).toBe(false); + expect(service.serviceBrokersStale()).toBe(false); + expect(service.serviceCredentialBindingsStale()).toBe(false); + }); + + it('markStale flips the matching slice', () => { + service.markStale('orgs'); + expect(service.orgsStale()).toBe(true); + expect(service.appsStale()).toBe(false); + service.markStale('apps'); + expect(service.appsStale()).toBe(true); + }); + + it('applyCascade("org.delete") marks spaces+apps+serviceInstances+bindings stale', () => { + service.applyCascade('org.delete'); + expect(service.spacesStale()).toBe(true); + expect(service.appsStale()).toBe(true); + expect(service.serviceInstancesStale()).toBe(true); + expect(service.serviceCredentialBindingsStale()).toBe(true); + // Orgs itself is NOT in the cascade — the source patches its own slice + expect(service.orgsStale()).toBe(false); + }); + + it('applyCascade("space.delete") marks apps+SI+bindings stale (no orgs/spaces)', () => { + service.applyCascade('space.delete'); + expect(service.appsStale()).toBe(true); + expect(service.serviceInstancesStale()).toBe(true); + expect(service.serviceCredentialBindingsStale()).toBe(true); + expect(service.orgsStale()).toBe(false); + expect(service.spacesStale()).toBe(false); + }); + + it('loadOrgs() refetches when stale even with warm cache', async () => { + // Warm the cache first + service.loadOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + expect(service.orgsLastFetched()).not.toBeNull(); + + // Mark stale — next loadOrgs() should refetch + service.markStale('orgs'); + expect(service.orgsStale()).toBe(true); + service.loadOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + // Refetch clears stale + expect(service.orgsStale()).toBe(false); + }); + + it('loadDetails() refetches when any slice is stale', async () => { + // Warm cache + service.loadDetails().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: mockApps, pagination: { totalResults: 1, totalPages: 1 } }); + httpMock.expectOne(SPACES_FULL_URL).flush({ resources: mockSpaces, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + // Mark just apps stale → loadDetails sees anyStale=true → bypasses cache. + // Inner loadX calls then re-evaluate: only apps re-fetches (the others + // remain non-stale with warm caches). + service.markStale('apps'); + service.loadDetails().subscribe(); + httpMock.expectNone(ORGS_FULL_URL); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: mockApps, pagination: { totalResults: 1, totalPages: 1 } }); + httpMock.expectNone(SPACES_FULL_URL); + await Promise.resolve(); + expect(service.appsStale()).toBe(false); + }); + + it('refreshOrgs() fires unconditionally and clears stale', async () => { + // Cold start — no prior load + service.refreshOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + expect(service.orgs()).toEqual(mockOrgs); + expect(service.orgsLastFetched()).not.toBeNull(); + + // Warm cache — refreshOrgs() still fires (no cache check) + service.refreshOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + }); + + it('refreshOrgs() clears _orgsStale on success', async () => { + service.markStale('orgs'); + expect(service.orgsStale()).toBe(true); + service.refreshOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + expect(service.orgsStale()).toBe(false); + }); + + it('refreshDetails() fires all three drains in parallel', async () => { + service.refreshDetails().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + httpMock.expectOne(APPS_FULL_URL).flush({ resources: mockApps, pagination: { totalResults: 1, totalPages: 1 } }); + httpMock.expectOne(SPACES_FULL_URL).flush({ resources: mockSpaces, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + expect(service.orgs()).toEqual(mockOrgs); + expect(service.apps()).toEqual(mockApps); + expect(service.spaces()).toEqual(mockSpaces); + }); + + it('two concurrent refreshOrgs() fire two HTTP requests (no coalesce)', () => { + service.refreshOrgs().subscribe(); + service.refreshOrgs().subscribe(); + // Two independent fetches — refresh is an explicit user action and we + // don't coalesce. Last-write-wins on the signal is acceptable. + const reqs = httpMock.match(ORGS_FULL_URL); + expect(reqs.length).toBe(2); + reqs.forEach(r => r.flush({ resources: [], pagination: { totalResults: 0, totalPages: 1 } })); + }); + + it('cache-hit path stays cache-hit when not stale (no behavior change for happy path)', async () => { + service.loadOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: mockOrgs, pagination: { totalResults: 1, totalPages: 1 } }); + await Promise.resolve(); + // Not stale → second loadOrgs() short-circuits + service.loadOrgs().subscribe(); + httpMock.expectNone(ORGS_FULL_URL); + }); + }); + + describe('local-cache patch helpers (Phase 2)', () => { + const orgA = { guid: 'a', name: 'A' } as any; + const orgB = { guid: 'b', name: 'B' } as any; + const spA = { guid: 'sp-a', name: 'sa' } as any; + const appA = { guid: 'app-a', name: 'A' } as any; + const siA = { guid: 'si-a', name: 'mydb' } as any; + + it('removeOrg drops the matching guid + decrements orgCount', async () => { + service.loadOrgs().subscribe(); + httpMock.expectOne(ORGS_FULL_URL).flush({ resources: [orgA, orgB], pagination: { totalResults: 2, totalPages: 1 } }); + await Promise.resolve(); + expect(service.orgs().length).toBe(2); + expect(service.orgCount()).toBe(2); + service.removeOrg('a'); + expect(service.orgs().map(o => o.guid)).toEqual(['b']); + expect(service.orgCount()).toBe(1); + }); + + it('removeOrg is idempotent — absent guid is a no-op', () => { + service.removeOrg('nonexistent'); // no throw + expect(service.orgs()).toEqual([]); + expect(service.orgCount()).toBe(0); + }); + + it('addOrg appends new guid; replaces existing guid', () => { + service.addOrg(orgA); + expect(service.orgs().map(o => o.guid)).toEqual(['a']); + expect(service.orgCount()).toBe(1); + service.addOrg({ guid: 'a', name: 'A renamed' } as any); + expect(service.orgs()).toHaveLength(1); + expect(service.orgs()[0].name).toBe('A renamed'); + }); + + it('updateOrg merges by guid; misses are no-op', () => { + service.addOrg(orgA); + service.updateOrg('a', { name: 'A renamed' }); + expect(service.orgs()[0].name).toBe('A renamed'); + service.updateOrg('absent', { name: 'X' }); // no throw, no change + expect(service.orgs()[0].name).toBe('A renamed'); + }); + + it('removeSpace / addSpace / updateSpace work', () => { + service.addSpace(spA); + expect(service.spaces().map(s => s.guid)).toEqual(['sp-a']); + service.updateSpace('sp-a', { name: 'sa-renamed' } as any); + expect(service.spaces()[0].name).toBe('sa-renamed'); + service.removeSpace('sp-a'); + expect(service.spaces()).toEqual([]); + }); + + it('removeApp / addApp / updateApp work + appCount tracked', () => { + service.addApp(appA); + expect(service.apps()).toHaveLength(1); + expect(service.appCount()).toBe(1); + service.updateApp('app-a', { name: 'A renamed' } as any); + expect(service.apps()[0].name).toBe('A renamed'); + service.removeApp('app-a'); + expect(service.apps()).toEqual([]); + expect(service.appCount()).toBe(0); + }); + + it('removeServiceInstance / addServiceInstance / updateServiceInstance work + count tracked', () => { + service.addServiceInstance(siA); + expect(service.serviceInstances()).toHaveLength(1); + expect(service.serviceInstancesCount()).toBe(1); + service.updateServiceInstance('si-a', { name: 'renamed' } as any); + expect(service.serviceInstances()[0].name).toBe('renamed'); + service.removeServiceInstance('si-a'); + expect(service.serviceInstances()).toEqual([]); + expect(service.serviceInstancesCount()).toBe(0); + }); + }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts index dccd1ec3b0..22b64369ed 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { signal, Signal } from '@angular/core'; import { EMPTY, firstValueFrom, from, merge, Observable, of, ReplaySubject } from 'rxjs'; import { catchError, finalize, map, mergeMap, reduce, shareReplay, switchMap, tap, timeout } from 'rxjs/operators'; +import { cascadeFor, CascadeKey, EntityKind } from '../data-sources/cascade-registry'; import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; import { EndpointDataShim } from './endpoint-data.shim'; import { @@ -71,6 +72,21 @@ export class EndpointDataService { private readonly _isLoadingServicesDetails = signal(false); private readonly _servicesDetailsLastFetched = signal(null); + // Staleness flags per entity slice. Flipped true by applyCascade(key) + // after a related mutation; cleared back to false on a successful + // refresh of that slice. loadX() and loadDetails() treat stale === true + // the same as "not cached" — next read triggers a refetch. Components + // that want a visible spinner during the refetch can read the public + // xStale Signal exposed below. + private readonly _orgsStale = signal(false); + private readonly _appsStale = signal(false); + private readonly _spacesStale = signal(false); + private readonly _serviceInstancesStale = signal(false); + private readonly _serviceOfferingsStale = signal(false); + private readonly _servicePlansStale = signal(false); + private readonly _serviceBrokersStale = signal(false); + private readonly _serviceCredentialBindingsStale = signal(false); + readonly orgs: Signal = this._orgs.asReadonly(); readonly apps: Signal = this._apps.asReadonly(); readonly recentApps: Signal = this._recentApps.asReadonly(); @@ -104,6 +120,15 @@ export class EndpointDataService { readonly isLoadingServicesDetails: Signal = this._isLoadingServicesDetails.asReadonly(); readonly servicesDetailsLastFetched: Signal = this._servicesDetailsLastFetched.asReadonly(); + readonly orgsStale: Signal = this._orgsStale.asReadonly(); + readonly appsStale: Signal = this._appsStale.asReadonly(); + readonly spacesStale: Signal = this._spacesStale.asReadonly(); + readonly serviceInstancesStale: Signal = this._serviceInstancesStale.asReadonly(); + readonly serviceOfferingsStale: Signal = this._serviceOfferingsStale.asReadonly(); + readonly servicePlansStale: Signal = this._servicePlansStale.asReadonly(); + readonly serviceBrokersStale: Signal = this._serviceBrokersStale.asReadonly(); + readonly serviceCredentialBindingsStale: Signal = this._serviceCredentialBindingsStale.asReadonly(); + // ReplaySubject(1) — late subscribers (e.g. the home card's async pipe // subscribing after the HTTP has already completed) immediately receive the // last emission so they don't hang forever on a stream that already fired. @@ -198,7 +223,8 @@ export class EndpointDataService { // fires the apps + spaces drains. loadDetails(): Observable { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'loadDetails' }); - if (this._detailsLastFetched() !== null && this._orgs().length > 0) { + const anyStale = this._orgsStale() || this._appsStale() || this._spacesStale(); + if (!anyStale && this._detailsLastFetched() !== null && this._orgs().length > 0) { this.diagnostics?.emitCounter('cache-hit', { service: 'EndpointDataService', method: 'loadDetails' }); return of(undefined); } @@ -234,7 +260,7 @@ export class EndpointDataService { // pagination strategy comment. loadOrgs(): Observable { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'loadOrgs' }); - if (this._orgsLastFetched() !== null && this._orgs().length > 0) { + if (!this._orgsStale() && this._orgsLastFetched() !== null && this._orgs().length > 0) { this.diagnostics?.emitCounter('cache-hit', { service: 'EndpointDataService', method: 'loadOrgs' }); return of(undefined); } @@ -254,6 +280,7 @@ export class EndpointDataService { finalize(() => { this._isLoadingOrgs.set(false); this._orgsLastFetched.set(new Date()); + this._orgsStale.set(false); this._inFlightLoadOrgs = null; }), shareReplay({ bufferSize: 1, refCount: false }), @@ -264,7 +291,7 @@ export class EndpointDataService { // loadApps() — see loadOrgs(). Drains /pp/v1/cf/apps/{guid}. loadApps(): Observable { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'loadApps' }); - if (this._appsLastFetched() !== null && this._apps().length > 0) { + if (!this._appsStale() && this._appsLastFetched() !== null && this._apps().length > 0) { this.diagnostics?.emitCounter('cache-hit', { service: 'EndpointDataService', method: 'loadApps' }); return of(undefined); } @@ -284,6 +311,7 @@ export class EndpointDataService { finalize(() => { this._isLoadingApps.set(false); this._appsLastFetched.set(new Date()); + this._appsStale.set(false); this._inFlightLoadApps = null; }), shareReplay({ bufferSize: 1, refCount: false }), @@ -297,7 +325,7 @@ export class EndpointDataService { // a consumer only needs orgs/apps. loadSpaces(): Observable { this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'loadSpaces' }); - if (this._spacesLastFetched() !== null && this._spaces().length > 0) { + if (!this._spacesStale() && this._spacesLastFetched() !== null && this._spaces().length > 0) { this.diagnostics?.emitCounter('cache-hit', { service: 'EndpointDataService', method: 'loadSpaces' }); return of(undefined); } @@ -314,6 +342,7 @@ export class EndpointDataService { finalize(() => { this._isLoadingSpaces.set(false); this._spacesLastFetched.set(new Date()); + this._spacesStale.set(false); this._inFlightLoadSpaces = null; }), shareReplay({ bufferSize: 1, refCount: false }), @@ -321,6 +350,201 @@ export class EndpointDataService { return this._inFlightLoadSpaces; } + // -------- Staleness + force-refresh API -------------------------------- + // Mutation sources call applyCascade(key) after a successful write; this + // walks the cascade-registry entry and flips the matching stale flags. + // loadX()/loadDetails() treat stale === true as "not cached" and will + // refetch on the next read. Components that want to surface the in-flight + // refresh (spinner overlay etc.) read the public xStale Signals. + // + // refreshX() is the explicit force path used by Refresh buttons and any + // caller that wants to bypass the cache regardless of stale state. Two + // concurrent refreshX() calls fire two HTTP requests in parallel — refresh + // is a user-initiated action and we don't coalesce; last-write-wins on the + // resulting signal is acceptable for this use case. + + // -------- Local-cache patch helpers (used by Cnsi*Source mutations) ------ + // These give the source classes a typed way to keep EndpointDataService's + // cache consistent with the server after a successful mutation. Patching + // is intentionally narrow: filter-out by guid for delete; push for create; + // merge by guid for update. Each helper is idempotent — a remove on an + // already-absent guid is a no-op; an add of an existing guid replaces. + + removeOrg(guid: string): void { + this._orgs.update(curr => curr.filter(o => (o as { guid?: string }).guid !== guid)); + this._orgCount.update(n => Math.max(0, n - 1)); + } + addOrg(org: StOrg): void { + this._orgs.update(curr => { + const idx = curr.findIndex(o => (o as { guid?: string }).guid === (org as { guid?: string }).guid); + if (idx >= 0) { const next = [...curr]; next[idx] = org; return next; } + return [...curr, org]; + }); + this._orgCount.update(n => n + 1); + } + updateOrg(guid: string, patch: Partial): void { + this._orgs.update(curr => curr.map(o => + (o as { guid?: string }).guid === guid ? { ...o, ...patch } : o)); + } + + removeSpace(guid: string): void { + this._spaces.update(curr => curr.filter(s => (s as { guid?: string }).guid !== guid)); + } + addSpace(space: StSpace): void { + this._spaces.update(curr => { + const idx = curr.findIndex(s => (s as { guid?: string }).guid === (space as { guid?: string }).guid); + if (idx >= 0) { const next = [...curr]; next[idx] = space; return next; } + return [...curr, space]; + }); + } + updateSpace(guid: string, patch: Partial): void { + this._spaces.update(curr => curr.map(s => + (s as { guid?: string }).guid === guid ? { ...s, ...patch } : s)); + } + + removeApp(guid: string): void { + this._apps.update(curr => curr.filter(a => (a as { guid?: string }).guid !== guid)); + this._appCount.update(n => Math.max(0, n - 1)); + } + addApp(app: StApp): void { + this._apps.update(curr => { + const idx = curr.findIndex(a => (a as { guid?: string }).guid === (app as { guid?: string }).guid); + if (idx >= 0) { const next = [...curr]; next[idx] = app; return next; } + return [...curr, app]; + }); + this._appCount.update(n => n + 1); + } + updateApp(guid: string, patch: Partial): void { + this._apps.update(curr => curr.map(a => + (a as { guid?: string }).guid === guid ? { ...a, ...patch } : a)); + } + + removeServiceInstance(guid: string): void { + this._serviceInstances.update(curr => curr.filter(si => (si as { guid?: string }).guid !== guid)); + this._serviceInstancesCount.update(n => Math.max(0, n - 1)); + } + addServiceInstance(si: StServiceInstance): void { + this._serviceInstances.update(curr => { + const idx = curr.findIndex(x => (x as { guid?: string }).guid === (si as { guid?: string }).guid); + if (idx >= 0) { const next = [...curr]; next[idx] = si; return next; } + return [...curr, si]; + }); + this._serviceInstancesCount.update(n => n + 1); + } + updateServiceInstance(guid: string, patch: Partial): void { + this._serviceInstances.update(curr => curr.map(si => + (si as { guid?: string }).guid === guid ? { ...si, ...patch } : si)); + } + + removeServiceCredentialBinding(guid: string): void { + this._serviceCredentialBindings.update(curr => + curr.filter(b => (b as { guid?: string }).guid !== guid)); + } + addServiceCredentialBinding(b: StServiceCredentialBinding): void { + this._serviceCredentialBindings.update(curr => { + const idx = curr.findIndex(x => (x as { guid?: string }).guid === (b as { guid?: string }).guid); + if (idx >= 0) { const next = [...curr]; next[idx] = b; return next; } + return [...curr, b]; + }); + } + + // -------- Staleness + cascade --------------------------------------------- + + markStale(entity: EntityKind): void { + switch (entity) { + case 'orgs': this._orgsStale.set(true); break; + case 'apps': this._appsStale.set(true); break; + case 'spaces': this._spacesStale.set(true); break; + case 'serviceInstances': this._serviceInstancesStale.set(true); break; + case 'serviceOfferings': this._serviceOfferingsStale.set(true); break; + case 'servicePlans': this._servicePlansStale.set(true); break; + case 'serviceBrokers': this._serviceBrokersStale.set(true); break; + case 'serviceCredentialBindings': this._serviceCredentialBindingsStale.set(true); break; + } + } + + applyCascade(key: CascadeKey): void { + this.diagnostics?.emitCounter('cascade-apply', { service: 'EndpointDataService', key }); + for (const entity of cascadeFor(key)) { + this.markStale(entity); + } + } + + refreshOrgs(): Observable { + this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'refreshOrgs' }); + this._isLoadingOrgs.set(true); + return this.drainPages(`/pp/v1/cf/orgs/${this.guid}`).pipe( + tap(resp => { + this._orgs.set(resp.resources); + this._orgCount.set(resp.totalResults); + }), + map(() => undefined as void), + catchError(err => { this.addError('orgs-full', err); return of(undefined as void); }), + finalize(() => { + this._isLoadingOrgs.set(false); + this._orgsLastFetched.set(new Date()); + this._orgsStale.set(false); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ) as Observable; + } + + refreshApps(): Observable { + this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'refreshApps' }); + this._isLoadingApps.set(true); + return this.drainPages(`/pp/v1/cf/apps/${this.guid}`).pipe( + tap(resp => { + this._apps.set(resp.resources); + this._appCount.set(resp.totalResults); + }), + map(() => undefined as void), + catchError(err => { this.addError('apps-full', err); return of(undefined as void); }), + finalize(() => { + this._isLoadingApps.set(false); + this._appsLastFetched.set(new Date()); + this._appsStale.set(false); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ) as Observable; + } + + refreshSpaces(): Observable { + this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'refreshSpaces' }); + this._isLoadingSpaces.set(true); + return this.drainPages(`/pp/v1/cf/spaces/${this.guid}`).pipe( + tap(resp => this._spaces.set(resp.resources)), + map(() => undefined as void), + catchError(err => { this.addError('spaces-full', err); return of(undefined as void); }), + finalize(() => { + this._isLoadingSpaces.set(false); + this._spacesLastFetched.set(new Date()); + this._spacesStale.set(false); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ) as Observable; + } + + refreshDetails(): Observable { + this.diagnostics?.emitCounter('service-call-count', { service: 'EndpointDataService', method: 'refreshDetails' }); + this._isLoadingDetails.set(true); + return merge( + this.refreshOrgs(), + this.refreshApps(), + this.refreshSpaces(), + ).pipe( + timeout(120_000), + finalize(() => { + this._isLoadingDetails.set(false); + this._detailsLastFetched.set(new Date()); + this.shim.write(this.guid, this.currentData()); + this.detailsLoaded$.next(); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ) as Observable; + } + + // ----------------------------------------------------------------------- + // Drain all pages for a Stratos-shape list endpoint. Page 1 inline, // pages 2..N in parallel (concurrency=4). Reads totalPages from the // StratosPagedResponse pagination meta (stratos_paging.go:68). Reduces diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.spec.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.spec.ts index 36935de1b8..a2bd3905ca 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.spec.ts @@ -1,12 +1,9 @@ import { TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { provideZonelessChangeDetection } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { EntityCatalogTestModuleManualStore, TEST_CATALOGUE_ENTITIES } from '@stratosui/store'; -import { createBasicStoreModule } from '@stratosui/store/testing'; -import { generateCFEntities } from '../../cf-entity-generator'; import { EndpointDataShim } from './endpoint-data.shim'; import { StApp, StEndpointData, StOrg, StSpace } from './stratos-types'; +import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; const org: StOrg = { guid: 'org-1', @@ -45,78 +42,29 @@ function empty(): StEndpointData { describe('EndpointDataShim', () => { let shim: EndpointDataShim; - let dispatchSpy: ReturnType; + let diagnostics: StratosDiagnostics; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ - { - ngModule: EntityCatalogTestModuleManualStore, - providers: [ - { provide: TEST_CATALOGUE_ENTITIES, useValue: generateCFEntities() }, - ], - }, - createBasicStoreModule({}), - ], providers: [ provideZonelessChangeDetection(), EndpointDataShim, + StratosDiagnostics, ], }); shim = TestBed.inject(EndpointDataShim); - const store = TestBed.inject(Store); - dispatchSpy = vi.spyOn(store, 'dispatch').mockImplementation(() => undefined) as unknown as ReturnType; + diagnostics = TestBed.inject(StratosDiagnostics); + diagnostics.reset(); }); - it('dispatches 2 actions (orgs + spaces) even when all arrays are empty (clears stale state)', () => { + it('write() emits no diagnostics when both orgs and spaces are empty', async () => { shim.write('cnsi-1', empty()); - expect(dispatchSpy).toHaveBeenCalledTimes(2); - const orgDispatch = dispatchSpy.mock.calls[0][0]; - expect(orgDispatch.response.result).toEqual([]); - expect(orgDispatch.totalResults).toBe(0); - }); - - it('dispatches an orgs WrapperRequestActionSuccess with composite entity IDs', () => { - shim.write('cnsi-1', { ...empty(), orgs: [org], orgCount: 1 }); - const action = dispatchSpy.mock.calls.find(c => c[0].apiAction.paginationKey === 'endpoint-cnsi-1')[0]; - // FWT-934: entity dictionary keys are now cnsiGuid:guid composite. - expect(action.response.entities.cfOrganization['cnsi-1:org-1']).toBeDefined(); - expect(action.response.entities.cfOrganization['cnsi-1:org-1'].entity.name).toBe('Org One'); - expect(action.response.entities.cfOrganization['cnsi-1:org-1'].metadata.guid).toBe('org-1'); - expect(action.response.result).toEqual(['cnsi-1:org-1']); - expect(action.totalResults).toBe(1); - expect(action.totalPages).toBe(1); - }); - - it('does NOT dispatch apps — the app wall manages its own multi-endpoint aggregation', () => { - // Composite keys (FWT-934) fix the entity-dictionary collision that made - // the 24014431d7 workaround necessary, but the shared 'applicationWall' - // pagination key is still last-write-wins across per-CF dispatches. The - // app wall's native fetch path is the right owner of that page. - shim.write('cnsi-1', { ...empty(), apps: [app], appCount: 1 }); - const appDispatches = dispatchSpy.mock.calls.filter(c => c[0].apiAction?.paginationKey === 'applicationWall'); - expect(appDispatches).toHaveLength(0); - }); - - it('dispatches a spaces WrapperRequestActionSuccess with composite keys under synthetic pagination key', () => { - shim.write('cnsi-1', { ...empty(), spaces: [space] }); - const action = dispatchSpy.mock.calls.find(c => c[0].apiAction.paginationKey === 'spaces-bulk-cnsi-1')[0]; - expect(action.response.entities.cfSpace['cnsi-1:space-1'].entity.organization_guid).toBe('org-1'); - }); - - it('always dispatches orgs + spaces (no apps) regardless of data presence', () => { - shim.write('cnsi-1', { ...empty(), orgs: [org], orgCount: 1, apps: [app], appCount: 1, spaces: [space] }); - expect(dispatchSpy).toHaveBeenCalledTimes(2); - const keys = dispatchSpy.mock.calls.map(c => c[0].apiAction?.paginationKey); - expect(keys).toContain('endpoint-cnsi-1'); - expect(keys).toContain('spaces-bulk-cnsi-1'); - expect(keys).not.toContain('applicationWall'); + await diagnostics.waitForFlush(); + const samples = diagnostics.snapshot().samples['entity-size-sample'] ?? []; + expect(samples).toHaveLength(0); }); - it('emits entity-size-sample per dispatched entity via StratosDiagnostics', async () => { - const { StratosDiagnostics } = await import('../diagnostics/stratos-diagnostics.service'); - const diagnostics = TestBed.inject(StratosDiagnostics); - diagnostics.reset(); + it('emits entity-size-sample per org and per space', async () => { shim.write('cnsi-1', { ...empty(), orgs: [org], orgCount: 1, apps: [app], appCount: 1, spaces: [space] }); await diagnostics.waitForFlush(); const samples = diagnostics.snapshot().samples['entity-size-sample'] ?? []; @@ -125,5 +73,13 @@ describe('EndpointDataShim', () => { expect(orgSamples).toHaveLength(1); expect(spaceSamples).toHaveLength(1); expect(orgSamples[0].value).toBeGreaterThan(0); + expect(spaceSamples[0].value).toBeGreaterThan(0); + }); + + it('does not emit samples for apps — apps stay out of the shim entirely', async () => { + shim.write('cnsi-1', { ...empty(), apps: [app], appCount: 1 }); + await diagnostics.waitForFlush(); + const samples = diagnostics.snapshot().samples['entity-size-sample'] ?? []; + expect(samples).toHaveLength(0); }); }); diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.ts index 27fd98aeba..c9e9a6d484 100644 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.ts +++ b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/endpoint-data.shim.ts @@ -1,183 +1,38 @@ import { Injectable, inject } from '@angular/core'; -import { select, Store } from '@ngrx/store'; -import { take } from 'rxjs/operators'; -import { endpointEntityType } from '@stratosui/store'; -import { APIResource, NormalizedResponse } from '../../../../store/src/types/api.types'; -import { WrapperRequestActionSuccess } from '../../../../store/src/types/request.types'; -import { IOrganization, ISpace } from '../../cf-api.types'; -import { cfEntityCatalog } from '../../cf-entity-catalog'; -import { getCFEntityKey } from '../../cf-entity-helpers'; -import { - domainEntityType, - organizationEntityType, - privateDomainsEntityType, - quotaDefinitionEntityType, - spaceEntityType, -} from '../../cf-entity-types'; -import { createEntityRelationKey, createEntityRelationPaginationKey } from '../../entity-relations/entity-relations.types'; -import { cfEntityId } from '../../cf-entity-ref'; import { StratosDiagnostics } from '../diagnostics/stratos-diagnostics.service'; import { StEndpointData, StOrg, StSpace } from './stratos-types'; -const SPACES_BULK_PAGINATION_PREFIX = 'spaces-bulk'; - -const ORG_ENTITY_KEY = getCFEntityKey(organizationEntityType); -const SPACE_ENTITY_KEY = getCFEntityKey(spaceEntityType); - -const ORG_RELATIONS = [ - createEntityRelationKey(organizationEntityType, spaceEntityType), - createEntityRelationKey(organizationEntityType, domainEntityType), - createEntityRelationKey(organizationEntityType, quotaDefinitionEntityType), - createEntityRelationKey(organizationEntityType, privateDomainsEntityType), -]; - +// W-f: the V2 ngrx dispatcher paths for organizations and spaces have been +// retired. Earlier waves removed the readers (cfEntityCatalog.org/space +// paginationStore consumers, ngrx-backed org/space chooser fetches in +// CfOrgSpaceDataService, signal-native cloudfoundry-endpoint.service.orgs$ +// reading from EndpointDataService.orgs() instead of pagination state), so +// the WrapperRequestActionSuccess writes this shim used to dispatch under +// `endpoint-{cnsi}` / `spaces-bulk-{cnsi}` pagination keys had become dead +// writes — nothing subscribed to the pagination state they populated, and +// the consumers that select individual entities by guid go through other +// code paths that don't read from the composite-key entries this shim +// produced (FWT-934). +// +// `write()` is kept so EndpointDataService can still report +// loadDetails() completion size samples for the entity-size dashboard. +// The shim file itself is retained as a thin pass-through for its consumer +// surface; consumer-side removal is a separate scope. @Injectable({ providedIn: 'root' }) export class EndpointDataShim { - private readonly store = inject(Store); private readonly diagnostics = inject(StratosDiagnostics); write(cnsiGuid: string, data: StEndpointData): void { - // No empty-array guard: "really empty" (e.g. CF with zero orgs, or all rows - // removed on refresh) is a legitimate dispatch that must clear stale - // pagination state. The service only calls this from loadDetails().finalize, - // so arrays reflect the real state of the full-list fetch at that moment. - // - // Apps are intentionally NOT dispatched here. The app wall uses a single - // shared pagination key ('applicationWall') that aggregates across all - // connected CF endpoints. Per-endpoint shim dispatch would overwrite the - // shared slot last-write-wins (totalResults + page IDs) even though FWT-934 - // composite keys protect the entity dictionary. App-wall's existing - // multi-endpoint fetch path handles aggregation correctly — leave it alone. - // The orgs + spaces shim dispatches use per-cnsi pagination keys, so they - // don't share the overwrite hazard. - this.dispatchOrgs(cnsiGuid, data.orgs); - this.dispatchSpaces(cnsiGuid, data.spaces); - } - - private dispatchOrgs(cnsiGuid: string, orgs: StOrg[]): void { - const paginationKey = createEntityRelationPaginationKey(endpointEntityType, cnsiGuid); - const action = cfEntityCatalog.org.actions.getMultiple(cnsiGuid, paginationKey, { - includeRelations: ORG_RELATIONS, - populateMissing: false, - }); - const entities: Record> = {}; - const result: string[] = []; - for (const org of orgs) { - const id = cfEntityId({ cnsiGuid, entityGuid: org.guid }); - const resource = this.toOrgResource(org, cnsiGuid); - entities[id] = resource; - result.push(id); - this.emitSize('organization', cnsiGuid, resource); + for (const org of data.orgs) { + this.emitSize('organization', cnsiGuid, org); } - this.detectCollisions('organization', ORG_ENTITY_KEY, cnsiGuid, result); - const response: NormalizedResponse = { - entities: { [ORG_ENTITY_KEY]: entities }, - result, - }; - this.store.dispatch(new WrapperRequestActionSuccess(response, action, 'fetch', orgs.length, 1)); - } - - private dispatchSpaces(cnsiGuid: string, spaces: StSpace[]): void { - const paginationKey = `${SPACES_BULK_PAGINATION_PREFIX}-${cnsiGuid}`; - const action = cfEntityCatalog.space.actions.getMultiple(cnsiGuid, paginationKey, { - includeRelations: [], - populateMissing: false, - }); - const entities: Record> = {}; - const result: string[] = []; - for (const space of spaces) { - const id = cfEntityId({ cnsiGuid, entityGuid: space.guid }); - const resource = this.toSpaceResource(space, cnsiGuid); - entities[id] = resource; - result.push(id); - this.emitSize('space', cnsiGuid, resource); + for (const space of data.spaces) { + this.emitSize('space', cnsiGuid, space); } - this.detectCollisions('space', SPACE_ENTITY_KEY, cnsiGuid, result); - const response: NormalizedResponse = { - entities: { [SPACE_ENTITY_KEY]: entities }, - result, - }; - this.store.dispatch(new WrapperRequestActionSuccess(response, action, 'fetch', spaces.length, 1)); } - private emitSize(entityType: string, cnsiGuid: string, resource: APIResource): void { - const bytes = JSON.stringify(resource).length; + private emitSize(entityType: string, cnsiGuid: string, payload: StOrg | StSpace): void { + const bytes = JSON.stringify(payload).length; this.diagnostics.emitSample('entity-size-sample', { entityType, cnsiGuid }, bytes); } - - // Counts entity-key-collision-avoided events: for each composite ID we're - // about to dispatch, check whether the current store already holds another - // composite with the same bare-guid suffix. Each such pair is a collision - // that would have silently overwritten data under the pre-FWT-934 bare-guid - // key scheme. The counter is the effectiveness metric for the namespacing - // fix; rate > 0 proves duplicate-URL scenarios are happening in practice. - private detectCollisions(entityType: string, entityKey: string, cnsiGuid: string, newIds: string[]): void { - let currentDict: Record = {}; - this.store - .pipe( - select((state: { request?: Record> }) => state?.request?.[entityKey] ?? {}), - take(1), - ) - .subscribe(dict => { - currentDict = dict; - }); - for (const newId of newIds) { - const colonIdx = newId.indexOf(':'); - if (colonIdx < 0) continue; - const bare = newId.slice(colonIdx + 1); - const suffix = `:${bare}`; - for (const existingId of Object.keys(currentDict)) { - if (existingId !== newId && existingId.endsWith(suffix)) { - this.diagnostics.emitCounter('entity-key-collision-avoided', { entityType, cnsiGuid }); - break; - } - } - } - } - - private toOrgResource(org: StOrg, cnsiGuid: string): APIResource { - return { - metadata: { - guid: org.guid, - created_at: org.createdAt || '', - updated_at: org.updatedAt || '', - url: `/v2/organizations/${org.guid}`, - }, - entity: { - name: org.name, - status: org.status, - guid: org.guid, - cfGuid: cnsiGuid, - }, - }; - } - - private toSpaceResource(space: StSpace, cnsiGuid: string): APIResource { - return { - metadata: { - guid: space.guid, - created_at: space.createdAt || '', - updated_at: space.updatedAt || '', - url: `/v2/spaces/${space.guid}`, - }, - entity: { - name: space.name, - organization_guid: space.orgGuid, - allow_ssh: false, - organization_url: `/v2/organizations/${space.orgGuid}`, - developers_url: '', - managers_url: '', - auditors_url: '', - apps_url: '', - routes_url: '', - domains_url: '', - service_instances_url: '', - app_events_url: '', - security_groups_url: '', - staging_security_groups_url: '', - cfGuid: cnsiGuid, - guid: space.guid, - }, - }; - } } diff --git a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-write.service.ts b/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-write.service.ts deleted file mode 100644 index 698df2b124..0000000000 --- a/src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-write.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; - -import { StOrg } from './stratos-types'; - -// V3 organization write body. Mirrors capi.OrganizationCreateRequest / -// OrganizationUpdateRequest: {name?, suspended?, metadata?}. Quota -// assignment is a separate relationships endpoint — see -// QuotaDataService.applyOrgQuotaToOrgs. -export interface OrgWriteBody { - name?: string; - suspended?: boolean; - metadata?: { labels?: Record; annotations?: Record }; -} - -// Singleton signal-native helper for org create + update. Reads of org -// detail/list state still go through OrgDataService / OrgDataRegistry — -// this surface is intentionally narrow to "write a new org" / "rename -// an org" without dragging in the per-org registry machinery. -@Injectable({ providedIn: 'root' }) -export class OrgWriteService { - private http = inject(HttpClient); - - createOrg(cnsiGuid: string, body: OrgWriteBody): Observable { - return this.http.post(`/pp/v1/cf/orgs/${cnsiGuid}`, body); - } - - updateOrg(cnsiGuid: string, orgGuid: string, body: OrgWriteBody): Observable { - return this.http.patch(`/pp/v1/cf/orgs/${cnsiGuid}/${orgGuid}`, body); - } -} diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.ts index ad8b71daa5..83476fe4fa 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/application-instance-chart/application-instance-chart.component.ts @@ -1,8 +1,9 @@ import { Component, Input, OnInit , ChangeDetectionStrategy } from '@angular/core'; import { MetricsChartComponent, MetricsConfig, MetricsLineChartConfig, MetricsChartHelpers, MetricsRangeSelectorComponent } from '@stratosui/core'; -import { MetricQueryConfig, IMetricMatrixResult, IMetricApplication, MetricQueryType } from '@stratosui/store'; -import { FetchApplicationMetricsAction } from '../../../actions/cf-metrics.actions'; +import { MetricQueryConfig, IMetricMatrixResult, IMetricApplication, MetricQueryType, MetricsRequest } from '@stratosui/store'; + +const APP_METRICS_BASE_URL = '/pp/v1/metrics'; @Component({ selector: 'app-application-instance-chart', @@ -46,17 +47,19 @@ export class ApplicationInstanceChartComponent implements OnInit { ngOnInit() { this.instanceChartConfig = MetricsChartHelpers.buildChartConfig(this.yAxisLabel); + const request: MetricsRequest = { + endpointGuid: this.endpointGuid, + url: `${APP_METRICS_BASE_URL}/cf/app/${this.appGuid}`, + query: new MetricQueryConfig(this.queryString), + queryType: this.queryRange ? MetricQueryType.RANGE_QUERY : MetricQueryType.QUERY, + windowValue: null, + }; this.instanceMetricConfig = { getSeriesName: result => `Instance ${result.metric.instance_index}`, mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, sort: MetricsChartHelpers.sortBySeriesName, mapSeriesItemValue: this.mapSeriesItemValue(), - metricsAction: new FetchApplicationMetricsAction( - this.appGuid, - this.endpointGuid, - new MetricQueryConfig(this.queryString), - this.queryRange ? MetricQueryType.RANGE_QUERY : MetricQueryType.QUERY - ), + request, }; } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.ts index 70286f2e2f..b9a3f5e671 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.ts @@ -37,9 +37,10 @@ export class CardCfInfoComponent implements OnInit { private autoscalerInfoData = inject(AutoscalerInfoDataService); // Signal bridges over the existing observables. The data they carry is - // already V3-native (the effect under cfEntityCatalog.cfInfo.api.get - // hits /pp/v1/cf/info/{cnsi}); these are template-side signal reads, - // not a data-path migration. + // already V3-native — `cfEndpointService.info$` is a toObservable() bridge + // over CfInfoDataService's signal, which fetches /pp/v1/cf/info/{cnsi} + // directly (W-e dropped the ngrx GetCFInfo effect intermediary). These + // are template-side signal reads, not a data-path migration. readonly endpointInfo = this.cfEndpointService.endpoint; readonly info = toSignal(this.cfEndpointService.info$, { initialValue: null as any }); readonly hasSSHAccess = toSignal(this.cfEndpointService.hasSSHAccess$, { initialValue: false }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cloud-foundry-events-list/cloud-foundry-events-list.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/cloud-foundry-events-list/cloud-foundry-events-list.component.ts index b66127d37e..4dd045d142 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cloud-foundry-events-list/cloud-foundry-events-list.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cloud-foundry-events-list/cloud-foundry-events-list.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, ChangeDetectionStrategy, Input, OnChanges, OnInit, SimpleChanges, WritableSignal, computed, inject, signal } from '@angular/core'; -import { SignalListComponent, SignalListConfig } from '@stratosui/core'; +import { SignalListComponent, SignalListConfig, SignalListDropdown } from '@stratosui/core'; import { CfAuditEventsSignalConfigService } from '../list/list-types/cf-events/cf-audit-events-signal-config.service'; import { CloudFoundryEndpointService } from '../../../features/cf/services/cloud-foundry-endpoint.service'; @@ -47,8 +47,38 @@ export class CloudFoundryEventsListComponent implements OnInit, OnChanges { constructor() { const cfGuid = this.cfEndpointService.cfGuid; this.eventsConfig.initialize(cfGuid); + } - this.listConfig.set({ + // @Input() values are not bound at constructor time. Building the + // listConfig here (with inputs guaranteed bound) lets us conditionally + // include the Org / Space dropdowns only on the foundation-wide page + // (no scope inputs). The sub-pages pin scope via basePredicate; adding + // dropdowns there would let users pick mismatched org/space values + // that the predicate then clamps — confusing UX. + ngOnInit(): void { + this.applyBasePredicate(); + this.listConfig.set(this.buildListConfig()); + void this.eventsConfig.loadAll(); + } + + private buildListConfig(): SignalListConfig { + const isFoundationWide = !this.orgGuid && !this.spaceGuid && !this.targetGuid; + const filterDropdowns: SignalListDropdown[] = isFoundationWide + ? [ + { + label: 'Organization', + options: this.eventsConfig.orgOptions, + selected: this.eventsConfig.selectedOrg, + }, + { + label: 'Space', + options: this.eventsConfig.spaceOptions, + selected: this.eventsConfig.selectedSpace, + }, + ] + : []; + + return { pagedItems: this.eventsConfig.view.pagedItems, totalFilteredResults: this.eventsConfig.view.totalFilteredResults, totalPages: this.eventsConfig.view.totalPages, @@ -102,21 +132,12 @@ export class CloudFoundryEventsListComponent implements OnInit, OnChanges { card: [6, 12, 24, 48, 96], }, nameFilter: this.eventsConfig.nameFilter, + filterDropdowns, onRefresh: () => this.eventsConfig.refresh(), onClear: () => this.eventsConfig.clearFilters(), viewMode: this.eventsConfig.viewMode, sort: this.eventsConfig.sort, - }); - } - - // @Input() values are not bound at constructor time. Setting the - // predicate here (with the inputs guaranteed bound) before triggering - // the data fetch keeps cross-org/space events from rendering during - // the initial load. Same fix shape as the per-CF tabs: scope first, - // then load. - ngOnInit(): void { - this.applyBasePredicate(); - void this.eventsConfig.loadAll(); + }; } // Re-apply the base predicate when scope inputs change (Angular diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app-instance/cf-app-instances-metrics-action.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app-instance/cf-app-instances-metrics-action.ts deleted file mode 100644 index 44cbb3b760..0000000000 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app-instance/cf-app-instances-metrics-action.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MetricQueryConfig, MetricQueryType } from '@stratosui/store'; -import { FetchApplicationMetricsAction } from '../../../../../actions/cf-metrics.actions'; - -export function createAppInstancesMetricAction(appGuid: string, cfGuid: string): FetchApplicationMetricsAction { - return new FetchApplicationMetricsAction( - appGuid, - cfGuid, - new MetricQueryConfig('firehose_container_metric_cpu_percentage'), - MetricQueryType.QUERY - ); -} diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-data-source.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-data-source.ts deleted file mode 100644 index 7283376dba..0000000000 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-data-source.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Store } from '@ngrx/store'; -import { getRowMetadata } from '@stratosui/store'; -import { Subscription } from 'rxjs'; -import { tag } from 'rxjs-spy/operators'; -import { debounceTime, delay, distinctUntilChanged, map, withLatestFrom } from 'rxjs/operators'; - -import { GetAllApplications } from '../../../../../../../cloud-foundry/src/actions/application.actions'; -import { CFAppState } from '../../../../../../../cloud-foundry/src/cf-app-state'; -import { - applicationEntityType, - organizationEntityType, - routeEntityType, - spaceEntityType, -} from '../../../../../../../cloud-foundry/src/cf-entity-types'; -import { createEntityRelationKey } from '../../../../../../../cloud-foundry/src/entity-relations/entity-relations.types'; -import { DispatchSequencer, DispatchSequencerAction } from '../../../../../../../core/src/core/dispatch-sequencer'; -import { - distinctPageUntilChanged, -} from '../../../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; -import { - ListPaginationMultiFilterChange, -} from '../../../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; -import { IListConfig } from '../../../../../../../core/src/shared/components/list/list.component.types'; -import { CreatePagination } from '../../../../../../../store/src/actions/pagination.actions'; -import { AppState } from '../../../../../../../store/src/app-state'; -import { MultiActionListEntity } from '../../../../../../../store/src/monitors/pagination-monitor'; -import { APIResource } from '../../../../../../../store/src/types/api.types'; -import { PaginationParam } from '../../../../../../../store/src/types/pagination.types'; -import { cfEntityCatalog } from '../../../../../cf-entity-catalog'; -import { cfEntityFactory } from '../../../../../cf-entity-factory'; -import { cfOrgSpaceFilter } from '../../../../../features/cf/cf.helpers'; -import { CFListDataSource } from '../../../../cf-list-data-source'; -import { createCfOrSpaceMultipleFilterFn } from '../../../../data-services/cf-org-space-service.service'; - -export class CfAppsDataSource extends CFListDataSource { - - public static paginationKey = 'applicationWall'; - public static includeRelations = [ - createEntityRelationKey(applicationEntityType, spaceEntityType), - createEntityRelationKey(spaceEntityType, organizationEntityType), - createEntityRelationKey(applicationEntityType, routeEntityType), - ]; - private subs: Subscription[]; - public declare action: GetAllApplications; - - constructor( - store: Store, - listConfig?: IListConfig, - transformEntities?: any[], - paginationKey = CfAppsDataSource.paginationKey, - seedPaginationKey = CfAppsDataSource.paginationKey, - cfGuid?: string - ) { - const syncNeeded = paginationKey !== seedPaginationKey; - const action = cfEntityCatalog.application.actions.getMultiple(cfGuid, CfAppsDataSource.paginationKey, { - includeRelations: CfAppsDataSource.includeRelations, - }); - - const dispatchSequencer = new DispatchSequencer(store); - - if (syncNeeded) { - // We do this here to ensure we sync up with main endpoint table data. - store.dispatch(new CreatePagination( - action, - paginationKey, - seedPaginationKey - )); - } - - if (!transformEntities) { - transformEntities = [{ type: 'filter', field: 'entity.name' }, cfOrgSpaceFilter]; - } - - super({ - store, - action, - schema: cfEntityFactory(applicationEntityType), - getRowUniqueId: getRowMetadata, - paginationKey, - isLocal: true, - transformEntities, - listConfig, - destroy: () => this.subs.forEach(sub => sub.unsubscribe()) - }); - - this.action = action; - - const statsSub = this.page$.pipe( - // The page observable will fire often, here we're only interested in updating the stats on actual page changes - distinctUntilChanged(distinctPageUntilChanged(this)), - // Ensure we keep pagination smooth - debounceTime(250), - // Allow maxedResults time to settle - see #3359 - delay(100), - withLatestFrom(this.maxedResults$), - map(([page, maxedResults]) => { - if (!page || maxedResults) { - return []; - } - const actions = new Array(); - page.forEach(app => { - if (app instanceof MultiActionListEntity) { - app = app.entity; - } - if (app.entity.state === 'STARTED') { - actions.push({ - id: app.metadata.guid, - action: cfEntityCatalog.appStats.actions.getMultiple(app.metadata.guid, app.entity.cfGuid) - }); - } - }); - return actions; - }), - dispatchSequencer.sequence.bind(dispatchSequencer), - tag('stat-obs') - ).subscribe(); - - this.subs = [statsSub]; - } - - public setMultiFilter(changes: ListPaginationMultiFilterChange[], params: PaginationParam) { - const filterFn = createCfOrSpaceMultipleFilterFn(this.store as Store, this.action, this.setQParam); - return filterFn(changes, params); - } - -} diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts index 75f9d6013c..25c8bfc4e1 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts @@ -4,7 +4,10 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { EndpointModel } from '@stratosui/store'; import { CnsiAppsSource } from '../../../../../services/data-sources/cnsi-apps-source'; +import { CnsiRoutesSource } from '../../../../../services/data-sources/cnsi-routes-source'; +import { CnsiServiceBindingsSource } from '../../../../../services/data-sources/cnsi-service-bindings-source'; import { MergeOrchestrator } from '../../../../../services/data-sources/merge-orchestrator'; +import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; import type { StApp, StAppRoutesResponse, StOrg, StOrgsResponse, StRoute, StServiceCredentialBinding, StServiceCredentialBindingsResponse, StSpace, StSpacesResponse } from '../../../../../services/endpoint-data/stratos-types'; import { CloudFoundryService } from '../../../../data-services/cloud-foundry.service'; @@ -150,6 +153,8 @@ export class CfAppsSignalConfigService { // dropdown — and so clearFilters() doesn't drop scope. private _lockedSpaceGuid = ''; + private readonly endpointRegistry = inject(EndpointDataRegistry); + constructor(private readonly http: HttpClient) { const cfService = inject(CloudFoundryService, { optional: true }); this.connectedEndpoints = cfService @@ -323,7 +328,7 @@ export class CfAppsSignalConfigService { // alongside the dedup sets so previously-resolved names don't bleed // across initialize() calls. this.clearResolverState(); - const sources = cnsiGuids.map(guid => new CnsiAppsSource(guid, this.http)); + const sources = cnsiGuids.map(guid => new CnsiAppsSource(guid, this.http, this.endpointRegistry.acquire(guid))); this.orchestrator = new MergeOrchestrator(sources); this.view = new ViewPipeline( this.orchestrator.allItems, @@ -848,16 +853,19 @@ export class CfAppsSignalConfigService { // Deletes a service credential binding through the async-job contract. // Managed bindings produce a 202 + polls; user-provided bindings resolve - // synchronously via the backend's 200+COMPLETE synthesis. writeWithJob - // handles both shapes uniformly. + // synchronously via the backend's 200+COMPLETE synthesis. Routed through + // CnsiServiceBindingsSource so the binding row is dropped from + // EndpointDataService._serviceCredentialBindings on success and the + // serviceBinding.delete cascade fires (marks apps + SI stale). async deleteServiceBinding(cnsiGuid: string, bindingGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/service_bindings/${cnsiGuid}/${bindingGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); + const eds = this.endpointRegistry.acquire(cnsiGuid); + const source = new CnsiServiceBindingsSource(cnsiGuid, this.http, eds); + await source.delete(bindingGuid); } - // Deletes a CF route through the async-job contract. CF v3 returns 202 + - // Location header for route deletes; writeWithJob handles the resolve / - // poll / terminal-state dance so callers just await a promise. + // Deletes a CF route through CnsiRoutesSource. The source handles + // writeWithJob, patches its own _items, and fires the route.delete + // cascade (marks apps stale so app-detail route lists refetch). // // Used by the signal-native delete stepper when the user opts to delete // attached routes alongside the app. Throws StratosJobError on FAILED @@ -865,8 +873,9 @@ export class CfAppsSignalConfigService { // (the route may fail to delete because the app delete already cascaded // through CF's reference checks). async deleteRoute(cnsiGuid: string, routeGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/routes/${cnsiGuid}/${routeGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); + const eds = this.endpointRegistry.acquire(cnsiGuid); + const source = new CnsiRoutesSource(cnsiGuid, this.http, eds); + await source.delete(routeGuid); } // Lifecycle actions. The CF v3 /v3/apps/{guid}/actions/{action} endpoints diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts index d3b43e4d2c..ee0802c5f1 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/base-cf/base-cf-list-config.ts @@ -11,7 +11,6 @@ export class BaseCfListConfig implements IListConfig { defaultView = 'cards' as ListView; cardComponent!: CardTypes; enableTextFilter = false; - showCustomTime = false; getColumns = (): ITableColumn[] => []; getGlobalActions = (): IGlobalListAction[] => []; getMultiActions = (): IMultiListAction[] => []; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-cell-apps/cf-cell-apps-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-cell-apps/cf-cell-apps-signal-config.service.ts index 35e9841466..d38131f1f8 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-cell-apps/cf-cell-apps-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-cell-apps/cf-cell-apps-signal-config.service.ts @@ -1,8 +1,9 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Injectable, Signal, WritableSignal, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { ListStateStore } from '@stratosui/core'; +import { MetricQueryConfig, MetricQueryType, MetricsDataService, MetricsRequest } from '@stratosui/store'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; import { StApp } from '../../../../../services/endpoint-data/stratos-types'; @@ -34,22 +35,13 @@ interface PrometheusVectorResult { value?: [number, string]; } -interface PrometheusResponseEnvelope { - [cnsiGuid: string]: { - status?: string; - data?: { - resultType?: string; - result?: PrometheusVectorResult[]; - }; - }; -} - // CPU-percentage metric exposes one vector per running app instance, // labelled with application_id + instance_index. Used as the source-of- // truth for "which apps are on this cell right now". HEALTHY metrics // are cell-level; this one is per-instance and is what the legacy // CfCellAppsDataSource queried as well. const METRIC_CPU = 'firehose_container_metric_cpu_percentage'; +const CELL_METRICS_BASE_URL = '/pp/v1/metrics/cf/cells'; // CF Cell Apps list config — single-CNSI, single-cell, read-only. // Drives the Apps tab on the cell-detail page. Replaces @@ -60,6 +52,7 @@ const METRIC_CPU = 'firehose_container_metric_cpu_percentage'; @Injectable({ providedIn: 'root' }) export class CfCellAppsSignalConfigService { private readonly http = inject(HttpClient); + private readonly metricsDataService = inject(MetricsDataService); private cnsiGuid = ''; private cellId = ''; @@ -141,15 +134,16 @@ export class CfCellAppsSignalConfigService { } private async fetchPlacements(): Promise<{ appGuid: string; instanceIndex: number }[] | null> { - const query = `${METRIC_CPU}{bosh_job_id="${this.cellId}"}`; - const url = `/pp/v1/metrics/cf/cells/query?query=${encodeURIComponent(query)}`; + const req: MetricsRequest = { + endpointGuid: this.cnsiGuid, + url: CELL_METRICS_BASE_URL, + query: new MetricQueryConfig(`${METRIC_CPU}{bosh_job_id="${this.cellId}"}`), + queryType: MetricQueryType.QUERY, + windowValue: null, + }; try { - const resp = await firstValueFrom( - this.http.get(url, { - headers: new HttpHeaders({ 'x-cap-cnsi-list': this.cnsiGuid }), - }), - ); - const result = resp?.[this.cnsiGuid]?.data?.result ?? []; + const metrics = await this.metricsDataService.fetch(req); + const result = (metrics?.data?.result ?? []) as PrometheusVectorResult[]; const out: { appGuid: string; instanceIndex: number }[] = []; for (const r of result) { const appGuid = r.metric?.application_id; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts index fdb2fd0732..c2a778945b 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts @@ -1,10 +1,14 @@ -import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import type { SignalListDropdownOption } from '@stratosui/core'; import { ListStateStore } from '@stratosui/core'; import { CnsiAuditEventsSource } from '../../../../../services/data-sources/cnsi-audit-events-source'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; +import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; +import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; import type { StAuditEvent } from '../../../../../services/endpoint-data/stratos-types'; // CF Audit Events list config — single-CNSI, read-only. Drives four @@ -17,9 +21,13 @@ import type { StAuditEvent } from '../../../../../services/endpoint-data/stratos export class CfAuditEventsSignalConfigService { private readonly http = inject(HttpClient); private readonly injector = inject(Injector); + private readonly registry = inject(EndpointDataRegistry); + private readonly destroyRef = inject(DestroyRef, { optional: true }); private cnsiGuid = ''; private source?: CnsiAuditEventsSource; + private endpointDataService?: EndpointDataService; + private _destroyHookRegistered = false; private readonly state = inject(ListStateStore).bind('cf-audit-events', { viewMode: 'table', @@ -33,12 +41,66 @@ export class CfAuditEventsSignalConfigService { readonly pageSize = this.state.pageSize; readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal = signal(''); + // Toolbar Org / Space narrowing on the CF-level Events page. The + // org / space / app sub-pages pin basePredicate instead and elect not + // to render these dropdowns. Selecting an org constrains the Space + // dropdown to that org's spaces (cascade rule). + readonly selectedOrg: WritableSignal = signal(null); + readonly selectedSpace: WritableSignal = signal(null); readonly viewMode = this.state.viewMode; // basePredicate is ANDed with the text filter inside the predicate // built by initialize(). The org / space / app pages set this to // restrict the foundation-wide event stream to their entity. readonly basePredicate: WritableSignal<(e: StAuditEvent) => boolean> = signal(() => true); + // Org options for the toolbar. Sourced from EDS.orgs(); "All" + // prepended. Natural-sort (numeric-aware). Sub-pages (per-org, per- + // space, per-app) ignore this dropdown. + readonly orgOptions: Signal = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const orgs = this.endpointDataService?.orgs() ?? []; + const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const o of sorted) opts.push({ label: o.name, value: o.guid }); + return opts; + }); + + // Space options — cascade-aware. + // - Org selected: list spaces in that org, label = space name. + // - Org = All: list every space, label = " - ", sorted by + // space name then org name (both natural-sort). + readonly spaceOptions: Signal = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const spaces = this.endpointDataService?.spaces() ?? []; + const org = this.selectedOrg(); + if (org) { + const sorted = spaces + .filter(s => s.orgGuid === org) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const s of sorted) opts.push({ label: s.name, value: s.guid }); + return opts; + } + const orgNameByGuid = new Map((this.endpointDataService?.orgs() ?? []).map(o => [o.guid, o.name])); + const augmented = spaces.map(s => ({ + guid: s.guid, + spaceName: s.name, + orgName: orgNameByGuid.get(s.orgGuid) ?? '', + })); + augmented.sort((a, b) => { + const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + if (bySpace !== 0) return bySpace; + return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + }); + for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); + return opts; + }); + + // Surface the underlying EDS so a consuming component can wait for + // orgs() / spaces() to populate before rendering the dropdowns. Kept + // narrow — registry access stays inside the service. + get endpointData(): EndpointDataService | undefined { + return this.endpointDataService; + } + // Mirror source.items() directly so the UI re-renders incrementally as // pages drain in. Audit events on a busy CF can span 50+ pages of 100; // awaiting full drain before paint left the page in "Loading…" for @@ -60,7 +122,21 @@ export class CfAuditEventsSignalConfigService { view!: ViewPipeline; initialize(cnsiGuid: string): void { + // Swap CNSI: release the previous EDS handle if a re-initialize() in + // the same singleton swapped foundations. Refcount-balanced — + // acquire below pairs with release here or in the destroy hook. + if (this.endpointDataService && this.cnsiGuid && this.cnsiGuid !== cnsiGuid) { + this.registry.release(this.cnsiGuid); + this.endpointDataService = undefined; + } this.cnsiGuid = cnsiGuid; + if (!this.endpointDataService) { + this.endpointDataService = this.registry.acquire(cnsiGuid); + } + // Kick off the endpoint-data load so orgs() / spaces() populate for + // the toolbar dropdowns. Errors are swallowed — empty dropdowns are + // better than a blocked page if loadDetails fails. + void firstValueFrom(this.endpointDataService.loadDetails()).catch((): void => undefined); this.source = new CnsiAuditEventsSource(cnsiGuid, this.http); this.view = new ViewPipeline( this.auditEvents, @@ -75,8 +151,12 @@ export class CfAuditEventsSignalConfigService { effect(() => { const q = this.nameFilter().trim().toLowerCase(); const base = this.basePredicate(); + const org = this.selectedOrg(); + const space = this.selectedSpace(); this.filter.set((ev: StAuditEvent) => { if (!base(ev)) return false; + if (org && ev.organizationGuid !== org) return false; + if (space && ev.spaceGuid !== space) return false; if (!q) return true; // Search across type / actorName / targetName uniformly so // the user can find events by any human-readable hook. @@ -87,7 +167,32 @@ export class CfAuditEventsSignalConfigService { ); }); }); + // Cascade rule: clear stale Space only when Org switches to a + // different specific org. When Org returns to All, the Space + // dropdown shows every space (labelled " - "), so the + // current selection remains valid and must be preserved. + effect(() => { + const org = this.selectedOrg(); + const space = this.selectedSpace(); + if (!space) return; + if (org === null) return; + const spaces = this.endpointDataService?.spaces() ?? []; + const match = spaces.find(s => s.guid === space); + if (!match || match.orgGuid !== org) { + this.selectedSpace.set(null); + } + }); }); + + if (!this._destroyHookRegistered && this.destroyRef) { + this._destroyHookRegistered = true; + this.destroyRef.onDestroy(() => { + if (this.endpointDataService && this.cnsiGuid) { + this.registry.release(this.cnsiGuid); + this.endpointDataService = undefined; + } + }); + } } // Fire-and-forget: source emits items incrementally per page, so the @@ -107,6 +212,8 @@ export class CfAuditEventsSignalConfigService { // Default sort is newest-first; clearing returns there. clearFilters(): void { this.nameFilter.set(''); + this.selectedOrg.set(null); + this.selectedSpace.set(null); this.sort.set({ field: 'createdAt', direction: 'desc' }); this.pageIndex.set(0); } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-quotas/cf-org-quotas-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-quotas/cf-org-quotas-signal-config.service.ts index 0a8962172b..0340e3c6f0 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-quotas/cf-org-quotas-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-quotas/cf-org-quotas-signal-config.service.ts @@ -41,6 +41,12 @@ export class CfOrgQuotasSignalConfigService { private readonly _hasLoadedOnce: WritableSignal = signal(false); readonly hasLoadedOnce: Signal = this._hasLoadedOnce.asReadonly(); + // True while a load / refresh request is in flight. The toolbar's + // refresh button reads this to drive its spinner — wiring straight to + // hasLoadedOnce gave a one-shot spinner on initial load only. + private readonly _loading: WritableSignal = signal(false); + readonly loading: Signal = this._loading.asReadonly(); + private readonly _sortExtractors: WritableSignal unknown>> = signal(new Map()); view!: ViewPipeline; @@ -70,16 +76,26 @@ export class CfOrgQuotasSignalConfigService { async loadAll(): Promise { if (!this.source) return; - await this.source.load(); - this._orgQuotas.set([...this.source.items()]); - this._hasLoadedOnce.set(true); + this._loading.set(true); + try { + await this.source.load(); + this._orgQuotas.set([...this.source.items()]); + this._hasLoadedOnce.set(true); + } finally { + this._loading.set(false); + } } async refresh(): Promise { if (!this.source) return; - await this.source.refresh(); - this._orgQuotas.set([...this.source.items()]); - this._hasLoadedOnce.set(true); + this._loading.set(true); + try { + await this.source.refresh(); + this._orgQuotas.set([...this.source.items()]); + this._hasLoadedOnce.set(true); + } finally { + this._loading.set(false); + } } clearFilters(): void { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-space-quotas/cf-space-quotas-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-space-quotas/cf-space-quotas-signal-config.service.ts index 01ee6aa7f1..1bf912e037 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-space-quotas/cf-space-quotas-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-space-quotas/cf-space-quotas-signal-config.service.ts @@ -44,6 +44,12 @@ export class CfSpaceQuotasSignalConfigService { private readonly _hasLoadedOnce: WritableSignal = signal(false); readonly hasLoadedOnce: Signal = this._hasLoadedOnce.asReadonly(); + // True while a load / refresh request is in flight. The toolbar's + // refresh button reads this to drive its spinner — wiring straight to + // hasLoadedOnce gave a one-shot spinner on initial load only. + private readonly _loading: WritableSignal = signal(false); + readonly loading: Signal = this._loading.asReadonly(); + private readonly _sortExtractors: WritableSignal unknown>> = signal(new Map()); view!: ViewPipeline; @@ -75,16 +81,26 @@ export class CfSpaceQuotasSignalConfigService { async loadAll(): Promise { if (!this.source) return; - await this.source.load(); - this._spaceQuotas.set([...this.source.items()]); - this._hasLoadedOnce.set(true); + this._loading.set(true); + try { + await this.source.load(); + this._spaceQuotas.set([...this.source.items()]); + this._hasLoadedOnce.set(true); + } finally { + this._loading.set(false); + } } async refresh(): Promise { if (!this.source) return; - await this.source.refresh(); - this._spaceQuotas.set([...this.source.items()]); - this._hasLoadedOnce.set(true); + this._loading.set(true); + try { + await this.source.refresh(); + this._spaceQuotas.set([...this.source.items()]); + this._hasLoadedOnce.set(true); + } finally { + this._loading.set(false); + } } clearFilters(): void { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/org/cf-orgs-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/org/cf-orgs-signal-config.service.ts index 5527f74ad4..ba9e04a4a7 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/org/cf-orgs-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/org/cf-orgs-signal-config.service.ts @@ -5,8 +5,8 @@ import { ListStateStore } from '@stratosui/core'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; +import { CnsiOrgsSource } from '../../../../../services/data-sources/cnsi-orgs-source'; import type { StOrg } from '../../../../../services/endpoint-data/stratos-types'; -import { writeWithJob } from '../../../../../services/async-jobs/write-with-job'; // Orgs list config service — single-CNSI analog to CfAppsSignalConfigService. // Unlike apps, the orgs view always lives under an explicit /cloud-foundry/:cnsi @@ -28,6 +28,7 @@ export class CfOrgsSignalConfigService { // matching fall through with the wrong page-context guid. private endpointDataService: WritableSignal = signal(undefined); private cnsiGuid = ''; + private orgsSource: CnsiOrgsSource | null = null; private readonly state = inject(ListStateStore).bind('cf-orgs', { viewMode: 'card', @@ -71,6 +72,7 @@ export class CfOrgsSignalConfigService { this.cnsiGuid = cnsiGuid; const ds = this.registry.acquire(cnsiGuid); this.endpointDataService.set(ds); + this.orgsSource = new CnsiOrgsSource(cnsiGuid, this.http, ds); // Build the view pipeline over the orgs signal; re-filter on filter // / sort changes, re-paginate on page changes. ViewPipeline already // handles the memoization layers. @@ -120,9 +122,11 @@ export class CfOrgsSignalConfigService { const ds = this.endpointDataService(); if (!ds) return; try { - await firstValueFrom(ds.loadDetails()); + // refreshOrgs() bypasses the cache guard — explicit user-driven refresh + // always re-fetches, vs loadDetails() which short-circuits on warm cache. + await firstValueFrom(ds.refreshOrgs()); } catch { - // loadDetails() surfaces errors via its own StError stream; swallowing + // refreshOrgs() surfaces errors via its own StError stream; swallowing // here keeps the Refresh button's promise from rejecting the caller. } } @@ -135,13 +139,28 @@ export class CfOrgsSignalConfigService { }); } - // Delete an org via the CF V3 async-job contract. The backend handler - // (DELETE /pp/v1/cf/orgs/:cnsi/:orgGuid) mirrors the app-delete shape — - // 202 + Location from CF, fast-path 200 from Stratos, or job handoff - // with polling via writeWithJob. - async deleteOrg(cnsiGuid: string, orgGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/orgs/${cnsiGuid}/${orgGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); - await this.refresh(); + // Delete an org via CnsiOrgsSource. The source handles writeWithJob, + // patches EndpointDataService._orgs in place, and fires the org.delete + // cascade so spaces/apps/SI/bindings get marked stale (for repaint when + // the user navigates to those tabs). + async deleteOrg(_cnsiGuid: string, orgGuid: string): Promise { + if (!this.orgsSource) { + throw new Error('CfOrgsSignalConfigService: initialize() not called before deleteOrg'); + } + await this.orgsSource.delete(orgGuid); + } + + // Create an org via CnsiOrgsSource. The source POSTs to /pp/v1/cf/orgs, + // patches EndpointDataService._orgs with the new entry, and fires the + // org.create cascade. Without going through here, callers that POST + // directly (e.g. OrgWriteService) leave the canonical _orgs cache + // stale — the new org won't appear in the list until a hard reload. + // Returns the created StOrg so the caller can chain (e.g. apply a + // quota to the new org's guid). + async createOrg(payload: unknown): Promise { + if (!this.orgsSource) { + throw new Error('CfOrgsSignalConfigService: initialize() not called before createOrg'); + } + return await this.orgsSource.create(payload); } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts index f0e7a7a100..f31f1b2606 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts @@ -6,8 +6,8 @@ import { ListStateStore } from '@stratosui/core'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; +import { CnsiRoutesSource } from '../../../../../services/data-sources/cnsi-routes-source'; import type { StRoute, StRoutesResponse } from '../../../../../services/endpoint-data/stratos-types'; -import { writeWithJob } from '../../../../../services/async-jobs/write-with-job'; // Routes list config service — single-CNSI, single-space. Analog of // CfSpacesSignalConfigService, but routes are not carried on @@ -45,10 +45,12 @@ export class CfRoutesSignalConfigService { readonly pageSize = this.state.pageSize; readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal = signal(''); - // Org filter — used by the CF-level routes page where routes across - // every org show up; empty = no org constraint. The per-space page - // doesn't populate this (it's already scoped). + // Org / Space filters — used by the CF-level routes page where routes + // across every org show up; null = no constraint. The per-space page + // doesn't populate either (it's already scoped via spaceGuid). + // Selecting an org constrains the Space dropdown to spaces in that org. readonly selectedOrg: WritableSignal = signal(null); + readonly selectedSpace: WritableSignal = signal(null); readonly viewMode = this.state.viewMode; // Raw route list as returned by the backend for this CNSI. We keep the @@ -97,16 +99,30 @@ export class CfRoutesSignalConfigService { effect(() => { const q = this.nameFilter().trim().toLowerCase(); const org = this.selectedOrg(); + const space = this.selectedSpace(); // orgGuidBySpaceGuid is a computed reading spaces(); accessing it // inside the effect re-registers the dependency so the filter // re-derives when spaces load or the user switches orgs. const orgGuidBySpaceGuid = this.orgGuidBySpaceGuid(); this.filter.set((r: StRoute) => { if (org && orgGuidBySpaceGuid.get(r.spaceGuid) !== org) return false; + if (space && r.spaceGuid !== space) return false; if (q && !((r.url ?? '').toLowerCase().includes(q))) return false; return true; }); }); + // Reset Space when Org changes — the cascade rule. Stale space + // selections in a now-hidden org would silently filter to empty. + effect(() => { + const org = this.selectedOrg(); + const space = this.selectedSpace(); + if (!space) return; + if (org === null) return; + const orgGuidBySpaceGuid = this.orgGuidBySpaceGuid(); + if (orgGuidBySpaceGuid.get(space) !== org) { + this.selectedSpace.set(null); + } + }); }); this.destroyRef.onDestroy(() => { this.registry.release(cnsiGuid); @@ -158,18 +174,49 @@ export class CfRoutesSignalConfigService { }); // Org options for the CF-level page's Organization filter dropdown. - // "All" is prepended as the null-value option. Sorted by name so the - // picker reads naturally regardless of CAPI's emission order. + // "All" is prepended as the null-value option. Sorted natural-order + // (numeric-aware) so "org-2" comes before "org-10". readonly orgOptions: Signal = computed(() => { const orgs = this.endpointDataService?.orgs() ?? []; const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; - const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name)); + const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); for (const o of sorted) { opts.push({ label: o.name, value: o.guid }); } return opts; }); + // Space options — cascade-aware. + // - Org selected: list spaces in that org, label = space name, natural sort. + // - Org = All: list every space, label = " - ", sorted by + // space name then org name (both natural-sort). Lets the user pick a + // space directly without first narrowing to an org. + readonly spaceOptions: Signal = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const spaces = this.endpointDataService?.spaces() ?? []; + const org = this.selectedOrg(); + if (org) { + const sorted = spaces + .filter(s => s.orgGuid === org) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const s of sorted) opts.push({ label: s.name, value: s.guid }); + return opts; + } + const orgNameByGuid = new Map((this.endpointDataService?.orgs() ?? []).map(o => [o.guid, o.name])); + const augmented = spaces.map(s => ({ + guid: s.guid, + spaceName: s.name, + orgName: orgNameByGuid.get(s.orgGuid) ?? '', + })); + augmented.sort((a, b) => { + const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + if (bySpace !== 0) return bySpace; + return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + }); + for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); + return opts; + }); + private async fetchRoutes(): Promise { try { const resp = await firstValueFrom( @@ -188,6 +235,7 @@ export class CfRoutesSignalConfigService { clearFilters(): void { this.nameFilter.set(''); this.selectedOrg.set(null); + this.selectedSpace.set(null); this.sort.set({ field: 'url', direction: 'asc' }); this.pageIndex.set(0); } @@ -195,9 +243,11 @@ export class CfRoutesSignalConfigService { async refresh(): Promise { await this.fetchRoutes(); // Also refresh apps so recently-mapped routes pick up new names. + // refreshApps() bypasses the cache guard — user-driven refresh always + // re-fetches. if (this.endpointDataService) { try { - await firstValueFrom(this.endpointDataService.loadDetails()); + await firstValueFrom(this.endpointDataService.refreshApps()); } catch { // As above — StError surfacing owns user-visible messaging. } @@ -213,8 +263,13 @@ export class CfRoutesSignalConfigService { } async deleteRoute(cnsiGuid: string, routeGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/routes/${cnsiGuid}/${routeGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); - await this.refresh(); + const eds = this.registry.acquire(cnsiGuid); + const source = new CnsiRoutesSource(cnsiGuid, this.http, eds); + await source.delete(routeGuid); + // The local _routes list lives on this config service (via fetchRoutes), + // not the source — the source's _items is discarded. Re-fetch the list + // so the just-deleted row leaves the view. The applyCascade in delete() + // also marks apps stale for the cross-tab UX. + await this.fetchRoutes(); } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.spec.ts index 97fcd7bf34..708f90e113 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.spec.ts @@ -47,6 +47,8 @@ type FakeDs = { isLoadingServicesDetails: () => boolean; serviceInstancesAndBrokers: () => { instances: StServiceInstance[], brokers: any[] } | null; setServiceInstancesAndBrokers: ReturnType; + orgs: () => Array<{ guid: string; name: string }>; + spaces: () => Array<{ guid: string; name: string; orgGuid: string }>; }; function makeRegistry(entries: Array & { guid: string }>): { registry: EndpointDataRegistry; fakes: Map } { @@ -57,6 +59,8 @@ function makeRegistry(entries: Array & { guid: string }>): { reg isLoadingServicesDetails: e.isLoadingServicesDetails ?? (() => false), serviceInstancesAndBrokers: e.serviceInstancesAndBrokers ?? (() => null), setServiceInstancesAndBrokers: vi.fn(), + orgs: e.orgs ?? (() => []), + spaces: e.spaces ?? (() => []), }); } const registry = { @@ -68,11 +72,14 @@ function makeRegistry(entries: Array & { guid: string }>): { reg isLoadingServicesDetails: () => false, serviceInstancesAndBrokers: () => null, setServiceInstancesAndBrokers: vi.fn(), + orgs: () => [], + spaces: () => [], }; fakes.set(guid, f); } return f as any; }), + release: vi.fn(), } as unknown as EndpointDataRegistry; return { registry, fakes }; } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts index 70d3a15220..fefeee3fb4 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; +import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { HttpClient } from '@angular/common/http'; import type { EndpointModel } from '@stratosui/store'; @@ -6,9 +6,9 @@ import { CnsiServiceInstancesSource } from '../../../../../services/data-sources import { MergeOrchestrator } from '../../../../../services/data-sources/merge-orchestrator'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; +import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; import type { StServiceInstance } from '../../../../../services/endpoint-data/stratos-types'; import { CloudFoundryService } from '../../../../data-services/cloud-foundry.service'; -import { writeWithJob } from '../../../../../services/async-jobs/write-with-job'; import type { SignalListDropdownOption } from '@stratosui/core'; import { ListStateStore } from '@stratosui/core'; @@ -68,6 +68,8 @@ export class CfServiceInstancesSignalConfigService { // Toolbar filter inputs. `null` for the dropdown = "All" (no constraint); // empty string for nameFilter = no name constraint. readonly selectedCnsi: WritableSignal = signal(null); + readonly selectedOrg: WritableSignal = signal(null); + readonly selectedSpace: WritableSignal = signal(null); readonly nameFilter: WritableSignal = signal(''); // Active filter column. Mirrors the marketplace pattern: when the // consumer registers a filter extractor for each filterable column, the @@ -89,9 +91,29 @@ export class CfServiceInstancesSignalConfigService { // CF filter dropdown options; "All" prepended as the null-value option. readonly cnsiOptions: Signal; + // Org / Space options. The wall page joins service-instance rows with + // the per-CNSI orgs() / spaces() signals from EndpointDataService. The + // per-space and per-offering callers don't render these dropdowns + // (scope is already pinned). + readonly orgOptions: Signal; + readonly spaceOptions: Signal; // endpoint guid → endpoint name, for rendering the CF column without // forcing each row to look it up. readonly endpointNames: Signal>; + // Per-CNSI EndpointDataService handles acquired in initialize* — keys + // are guid, values are the EDS reference. Held in a writable signal so + // orgOptions / spaceOptions / orgGuidBySpaceGuid recompute whenever a + // new initialize() swaps the set. Acquisitions are refcount-aware: + // each initialize() releases the previous set before acquiring the + // new one, and the host-component destroyRef releases on teardown. + // Reading EDS.orgs() / EDS.spaces() inside a computed off this signal + // is safe — those are signals themselves, no extra acquire. + private readonly _edsByCnsi: WritableSignal> = signal(new Map()); + // spaceGuid → orgGuid lookup unioned across the orchestrator's CNSIs. + // Drives the selectedOrg filter predicate (every SI carries space.guid; + // we don't get space.organization.guid on the wall payload). + private readonly _orgGuidBySpaceGuid: Signal>; + private readonly destroyRef = inject(DestroyRef, { optional: true }); // Flipped to true once the orchestrator's first load completes. Gates the // stale-selection clearer that keeps the toolbar display in sync with the @@ -127,6 +149,91 @@ export class CfServiceInstancesSignalConfigService { return m; }); + // Org options come from each CNSI's EDS orgs() signal. When the user + // selects a CF, the dropdown narrows to that CF's orgs; otherwise it + // unions across every CF the orchestrator is currently draining. + // Natural-sort by name. + this.orgOptions = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const byCnsi = this._edsByCnsi(); + const selected = this.selectedCnsi(); + const edsList: EndpointDataService[] = selected + ? (byCnsi.get(selected) ? [byCnsi.get(selected) as EndpointDataService] : []) + : Array.from(byCnsi.values()); + const seen = new Map(); + for (const eds of edsList) { + for (const o of eds.orgs()) { + if (!seen.has(o.guid)) seen.set(o.guid, o.name); + } + } + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b, undefined, { numeric: true })); + for (const [guid, label] of sorted) opts.push({ label, value: guid }); + return opts; + }); + + // Space options — cascade-aware. + // - Org selected: list spaces in that org across the in-scope CNSIs, + // label = space name, natural sort. + // - Org = All: list every space, label = " - ", sorted + // by space name then org name. Lets the user jump directly to a + // space without picking the org first. + this.spaceOptions = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const byCnsi = this._edsByCnsi(); + const selected = this.selectedCnsi(); + const edsList: EndpointDataService[] = selected + ? (byCnsi.get(selected) ? [byCnsi.get(selected) as EndpointDataService] : []) + : Array.from(byCnsi.values()); + const org = this.selectedOrg(); + if (org) { + const seen = new Map(); + for (const eds of edsList) { + for (const s of eds.spaces()) { + if (s.orgGuid !== org) continue; + if (!seen.has(s.guid)) seen.set(s.guid, s.name); + } + } + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b, undefined, { numeric: true })); + for (const [guid, label] of sorted) opts.push({ label, value: guid }); + return opts; + } + // Org = All. Build a guid → { spaceName, orgName } augmentation + // unioned across in-scope CNSIs. Org name lookup uses each EDS's + // orgs() signal (cheap to read here — it's a Signal already). + const augmented = new Map(); + for (const eds of edsList) { + const orgNameByGuid = new Map(eds.orgs().map(o => [o.guid, o.name])); + for (const s of eds.spaces()) { + if (augmented.has(s.guid)) continue; + augmented.set(s.guid, { spaceName: s.name, orgName: orgNameByGuid.get(s.orgGuid) ?? '' }); + } + } + const entries = Array.from(augmented.entries()); + entries.sort(([, a], [, b]) => { + const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + if (bySpace !== 0) return bySpace; + return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + }); + for (const [guid, { spaceName, orgName }] of entries) { + opts.push({ label: `${spaceName} - ${orgName}`, value: guid }); + } + return opts; + }); + + // Flattened space → org map for the filter predicate. StServiceInstance + // only carries space.guid; we don't get space.organization.guid on the + // wall payload, so resolving an SI's org requires the spaces() signal + // from EDS. + this._orgGuidBySpaceGuid = computed(() => { + const m = new Map(); + for (const eds of this._edsByCnsi().values()) { + for (const s of eds.spaces()) { + m.set(s.guid, s.orgGuid); + } + } + return m; + }); + // After the first load, drop any selected CF whose value no longer // appears in the options (e.g. user disconnected it mid-session). // Keeps the dropdown text consistent with what the predicate is @@ -146,15 +253,22 @@ export class CfServiceInstancesSignalConfigService { // toolbar shape. effect(() => { const cnsi = this.selectedCnsi(); + const org = this.selectedOrg(); + const space = this.selectedSpace(); const q = this.nameFilter().trim().toLowerCase(); const field = this.filterField(); const extractor = this._filterExtractors().get(field); const spaceGuid = this._spaceGuid(); const typeFilter = this._typeFilter(); const offeringGuid = this._offeringGuid(); + const orgGuidBySpaceGuid = this._orgGuidBySpaceGuid(); this.filter.set((si: StServiceInstance) => { if (cnsi && si.cnsiGuid !== cnsi) return false; if (spaceGuid && si.space.guid !== spaceGuid) return false; + // Toolbar org/space selections — only effective on the wall page; + // per-space/per-offering callers don't render the dropdowns. + if (space && si.space.guid !== space) return false; + if (org && orgGuidBySpaceGuid.get(si.space.guid) !== org) return false; if (typeFilter) { const isUps = si.type === 'user-provided'; if (typeFilter === 'user-provided' && !isUps) return false; @@ -174,6 +288,21 @@ export class CfServiceInstancesSignalConfigService { return true; }); }); + + // Cascade rule: clear stale Space only when Org switches to a + // different specific org. When Org returns to All, the Space + // dropdown lists every space (labelled " - "), so the + // current selection stays valid and must be preserved. + effect(() => { + const org = this.selectedOrg(); + const space = this.selectedSpace(); + if (!space) return; + if (org === null) return; + const orgGuidBySpaceGuid = this._orgGuidBySpaceGuid(); + if (orgGuidBySpaceGuid.get(space) !== org) { + this.selectedSpace.set(null); + } + }); } initialize(cnsiGuids: readonly string[]): void { @@ -184,6 +313,7 @@ export class CfServiceInstancesSignalConfigService { this._spaceGuid.set(''); this._typeFilter.set(undefined); this._offeringGuid.set(''); + this.swapAcquiredEds(cnsiGuids); const sources = cnsiGuids.map(guid => this.makeSource(guid)); this.orchestrator = new MergeOrchestrator(sources); this.view = new ViewPipeline( @@ -206,6 +336,7 @@ export class CfServiceInstancesSignalConfigService { this._spaceGuid.set(spaceGuid); this._typeFilter.set(typeFilter); this._offeringGuid.set(''); + this.swapAcquiredEds([cnsiGuid]); const sources = [this.makeSource(cnsiGuid)]; this.orchestrator = new MergeOrchestrator(sources); this.view = new ViewPipeline( @@ -229,6 +360,7 @@ export class CfServiceInstancesSignalConfigService { this._spaceGuid.set(''); this._typeFilter.set(undefined); this._offeringGuid.set(serviceOfferingGuid); + this.swapAcquiredEds([cnsiGuid]); const sources = [this.makeSource(cnsiGuid)]; this.orchestrator = new MergeOrchestrator(sources); this.view = new ViewPipeline( @@ -241,6 +373,39 @@ export class CfServiceInstancesSignalConfigService { ); } + // Swap the set of EDS handles tracked for toolbar dropdowns. Releases + // refcount on any guid no longer in scope, acquires for new guids, and + // updates _edsByCnsi atomically so dependent computeds see one + // consistent transition. Wires destroyRef on first call so teardown + // releases everything. + private _destroyHookRegistered = false; + private swapAcquiredEds(cnsiGuids: readonly string[]): void { + if (!this.registry) { + this._edsByCnsi.set(new Map()); + return; + } + const next = new Map(); + const previous = this._edsByCnsi(); + const incoming = new Set(cnsiGuids); + for (const guid of cnsiGuids) { + const existing = previous.get(guid); + next.set(guid, existing ?? this.registry.acquire(guid)); + } + for (const [guid] of previous) { + if (!incoming.has(guid)) this.registry.release(guid); + } + this._edsByCnsi.set(next); + if (!this._destroyHookRegistered && this.destroyRef) { + this._destroyHookRegistered = true; + this.destroyRef.onDestroy(() => { + for (const [guid] of this._edsByCnsi()) { + this.registry?.release(guid); + } + this._edsByCnsi.set(new Map()); + }); + } + } + async loadAll(): Promise { await this.orchestrator.load(); this._hasLoadedOnce.set(true); @@ -260,8 +425,8 @@ export class CfServiceInstancesSignalConfigService { // load() will fall through to its normal HTTP drain rather than risk // seeding mid-flight stale data. private makeSource(guid: string): CnsiServiceInstancesSource { - const source = new CnsiServiceInstancesSource(guid, this.http); const ds = this.registry?.acquire(guid); + const source = new CnsiServiceInstancesSource(guid, this.http, ds); if (ds && !ds.isLoadingServicesDetails()) { const bundle = ds.serviceInstancesAndBrokers(); if (bundle) source.preSeed(bundle.instances); @@ -285,6 +450,8 @@ export class CfServiceInstancesSignalConfigService { clearFilters(): void { this.selectedCnsi.set(null); + this.selectedOrg.set(null); + this.selectedSpace.set(null); this.nameFilter.set(''); this.filterField.set('name'); this.sort.set({ field: 'name', direction: 'asc' }); @@ -318,22 +485,28 @@ export class CfServiceInstancesSignalConfigService { return runInInjectionContext(this.injector, fn); } - // Delete a service instance via the CF V3 async-job contract. The - // backend handler (DELETE /pp/v1/cf/service_instances/:cnsi/:siGuid) - // mirrors the route/org delete shape: 202 + Location from CF for - // managed instances (broker call needed); user-provided instances may - // resolve immediately. writeWithJob handles both: 200 fast-path or job - // polling. After resolution we re-load the instance list so the row - // disappears. + // Delete a service instance through CnsiServiceInstancesSource. The + // source handles writeWithJob (which waits for CF's async job to + // terminate), patches its own _items, patches EndpointDataService's + // serviceInstances signal, and fires the serviceInstance.delete cascade + // (marks apps + bindings stale). Replaces the previous + // optimistic-remove + refresh hybrid, which could leave server + local + // state out of sync if the trailing refresh's page-2 refetch failed. async deleteServiceInstance(cnsiGuid: string, siGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/service_instances/${cnsiGuid}/${siGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); - // Optimistic local removal — CF's async delete + list-query roundtrip - // is slower than the page refresh would feel useful, so drop the row - // from local cache immediately. The trailing refresh confirms server - // state and brings the row back if CF rejected the delete after the - // backend's recovery returned 202 RUNNING. - this.orchestrator.removeRow(cnsiGuid, siGuid); - await this.refresh(); + const src = this.orchestrator?.sourceFor(cnsiGuid) as CnsiServiceInstancesSource | undefined; + if (src) { + await src.delete(siGuid); + // Mirror the orchestrator's aggregated view: even though the source + // patches its own _items, the orchestrator-level removeRow keeps + // the aggregated allItems Signal in sync for the merged-CNSI case. + this.orchestrator.removeRow(cnsiGuid, siGuid); + return; + } + // Orchestrator-undefined fallback (cold bookmark / HMR): instantiate + // a one-shot source for the delete. EDS is still threaded so the + // cascade fires. + const eds = this.registry.acquire(cnsiGuid); + const oneShot = new CnsiServiceInstancesSource(cnsiGuid, this.http, eds); + await oneShot.delete(siGuid); } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/space/cf-spaces-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/space/cf-spaces-signal-config.service.ts index ebdf974eca..c6f19c624a 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/space/cf-spaces-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/space/cf-spaces-signal-config.service.ts @@ -5,8 +5,9 @@ import { firstValueFrom } from 'rxjs'; import { ListStateStore } from '@stratosui/core'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; +import { CnsiSpacesSource } from '../../../../../services/data-sources/cnsi-spaces-source'; +import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import type { StSpace } from '../../../../../services/endpoint-data/stratos-types'; -import { writeWithJob } from '../../../../../services/async-jobs/write-with-job'; /** * Wire shape of /pp/v1/cf/org/{cnsi}/{org}/spaces. Mirrors @@ -38,6 +39,7 @@ interface PagedSpaces { export class CfSpacesSignalConfigService { private readonly http = inject(HttpClient); private readonly injector = inject(Injector); + private readonly endpointRegistry = inject(EndpointDataRegistry); private cnsiGuid = ''; private orgGuid = ''; @@ -121,9 +123,9 @@ export class CfSpacesSignalConfigService { } async deleteSpace(cnsiGuid: string, spaceGuid: string): Promise { - const call = this.http.delete(`/pp/v1/cf/spaces/${cnsiGuid}/${spaceGuid}`, { observe: 'response' }); - await writeWithJob(this.http, call); - await this.refresh(); + const eds = this.endpointRegistry.acquire(cnsiGuid); + const source = new CnsiSpacesSource(cnsiGuid, this.http, eds); + await source.delete(spaceGuid); } /** diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts index acc2fc0ec9..cefa2bd68b 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts @@ -1,6 +1,7 @@ import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; +import type { SignalListDropdownOption } from '@stratosui/core'; import { ListStateStore } from '@stratosui/core'; import { CfUserListDiagnosticsService } from '../../../../../services/diagnostics/cf-user-list-diagnostics.service'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; @@ -64,6 +65,13 @@ export class CfUsersSignalConfigService { readonly pageSize = this.state.pageSize; readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal = signal(''); + // Toolbar-driven Org / Space narrowing. Distinct from `_lockedOrgGuid` / + // `_lockedSpaceGuid` (URL-driven for the per-org / per-space tabs): + // these are the dropdown selections on the CF-level Users page and stack + // ON TOP of the URL locks. null = no constraint. Selecting an org + // constrains the Space dropdown to that org's spaces (cascade rule). + readonly selectedOrg: WritableSignal = signal(null); + readonly selectedSpace: WritableSignal = signal(null); readonly viewMode = this.state.viewMode; // Raw user list as returned by the backend for this CNSI. We keep the @@ -118,6 +126,57 @@ export class CfUsersSignalConfigService { return map; }); + // Org options for the CF-level Users toolbar. Natural-sort (numeric- + // aware); "All" prepended. The per-org tab pins `_lockedOrgGuid` and + // elects not to render this dropdown. + readonly orgOptions: Signal = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const orgs = this.endpointDataService?.orgs() ?? []; + const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const o of sorted) opts.push({ label: o.name, value: o.guid }); + return opts; + }); + + // Space options — cascade-aware. + // - Org selected: list spaces in that org, label = space name. + // - Org = All: list every space, label = " - ", sorted by + // space name then org name (both natural-sort). + readonly spaceOptions: Signal = computed(() => { + const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; + const spaces = this.endpointDataService?.spaces() ?? []; + const org = this.selectedOrg(); + if (org) { + const sorted = spaces + .filter(s => s.orgGuid === org) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + for (const s of sorted) opts.push({ label: s.name, value: s.guid }); + return opts; + } + const orgNameByGuid = new Map((this.endpointDataService?.orgs() ?? []).map(o => [o.guid, o.name])); + const augmented = spaces.map(s => ({ + guid: s.guid, + spaceName: s.name, + orgName: orgNameByGuid.get(s.orgGuid) ?? '', + })); + augmented.sort((a, b) => { + const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + if (bySpace !== 0) return bySpace; + return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + }); + for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); + return opts; + }); + + // spaceGuid → orgGuid lookup — drives the selectedOrg predicate (a + // user's role array doesn't carry orgGuid on every space role variant + // in all backends; flatten via the spaces() signal). + private readonly _orgGuidBySpaceGuid: Signal> = computed(() => { + const map = new Map(); + const spaces = this.endpointDataService?.spaces() ?? []; + for (const s of spaces) map.set(s.guid, s.orgGuid); + return map; + }); + // Access to the endpoint-data service for components that want to wait on // its loadDetails (e.g. to render org/space names before the buckets // resolve). Kept narrow — the service hides the registry from callers. @@ -158,11 +217,42 @@ export class CfUsersSignalConfigService { runInInjectionContext(this.injector, () => { effect(() => { const q = this.nameFilter().trim().toLowerCase(); + const org = this.selectedOrg(); + const space = this.selectedSpace(); + // orgGuidBySpaceGuid is needed when the user's space-role bucket + // is the only way to attribute them to an org (e.g., they hold + // only a space role, no org-level role). + const orgGuidBySpaceGuid = this._orgGuidBySpaceGuid(); this.filter.set((u: StUser) => { + if (org) { + const hasOrgRole = u.orgRoles.some(or => or.orgGuid === org); + const hasSpaceInOrg = u.spaceRoles.some(sr => + sr.orgGuid === org || orgGuidBySpaceGuid.get(sr.spaceGuid) === org, + ); + if (!hasOrgRole && !hasSpaceInOrg) return false; + } + if (space) { + if (!u.spaceRoles.some(sr => sr.spaceGuid === space)) return false; + } if (!q) return true; return (u.username ?? '').toLowerCase().includes(q); }); }); + // Cascade rule: clear stale Space selection only when Org switches + // to a different specific org that doesn't own this space. When Org + // returns to All, the Space dropdown shows every space across orgs + // (labelled " - "), so the current selection is still + // valid and must be preserved. + effect(() => { + const org = this.selectedOrg(); + const space = this.selectedSpace(); + if (!space) return; + if (org === null) return; + const orgGuidBySpaceGuid = this._orgGuidBySpaceGuid(); + if (orgGuidBySpaceGuid.get(space) !== org) { + this.selectedSpace.set(null); + } + }); }); this.destroyRef.onDestroy(() => { this.registry.release(cnsiGuid); @@ -224,6 +314,8 @@ export class CfUsersSignalConfigService { clearFilters(): void { this.nameFilter.set(''); + this.selectedOrg.set(null); + this.selectedSpace.set(null); this.sort.set({ field: 'username', direction: 'asc' }); this.pageIndex.set(0); } diff --git a/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-user.service.ts b/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-user.service.ts index e7bb160272..5eab159c24 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-user.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-user.service.ts @@ -16,7 +16,6 @@ import { import { CFAppState } from '../../cf-app-state'; import { cfUserEntityType, organizationEntityType, spaceEntityType } from '../../cf-entity-types'; import { createEntityRelationPaginationKey } from '../../entity-relations/entity-relations.types'; -import { getCurrentUserCFGlobalStates } from '../../store/selectors/cf-current-user-role.selectors'; import { IOrganization, ISpace } from '../../cf-api.types'; import { cfEntityCatalog } from '../../cf-entity-catalog'; import { cfEntityFactory } from '../../cf-entity-factory'; @@ -457,9 +456,9 @@ export class CfUserService { }; public isConnectedUserAdmin = (cfGuid: string): Observable => - this.store.select(getCurrentUserCFGlobalStates(cfGuid)).pipe( + this.cfRoles.cfEndpointRolesState$(cfGuid).pipe( filter(state => !!state), - map(state => state.isAdmin), + map(state => state.global.isAdmin), take(1) ); } diff --git a/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts b/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts index 4fb66f22d5..f96e6f10c7 100644 --- a/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts +++ b/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts @@ -4,12 +4,9 @@ import { GitPackageModule } from '@stratosui/git'; import { ActiveRouteCfOrgSpace } from '../features/cf/cf-page.types'; import { CloudFoundryReducersModule } from './cloud-foundry.reducers.module'; -import { AppEffects } from './effects/app.effects'; -import { CloudFoundryEffects } from './effects/cloud-foundry.effects'; import { DeployAppEffects } from './effects/deploy-app.effects'; import { CfValidateEffects } from './effects/request.effects'; import { ServiceInstanceEffects } from './effects/service-instance.effects'; -import { UpdateAppEffects } from './effects/update-app-effects'; import { UsersRolesEffects } from './effects/users-roles.effects'; import { CfEndpointRoleSyncService } from './services/cf-endpoint-role-sync.service'; @@ -18,10 +15,7 @@ import { CfEndpointRoleSyncService } from './services/cf-endpoint-role-sync.serv CloudFoundryReducersModule, EffectsModule.forFeature([ DeployAppEffects, - CloudFoundryEffects, ServiceInstanceEffects, - AppEffects, - UpdateAppEffects, CfValidateEffects, UsersRolesEffects ]), diff --git a/src/frontend/packages/cloud-foundry/src/store/effects/app.effects.ts b/src/frontend/packages/cloud-foundry/src/store/effects/app.effects.ts deleted file mode 100644 index 66747e9778..0000000000 --- a/src/frontend/packages/cloud-foundry/src/store/effects/app.effects.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ApplicationRef, Injectable, inject } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { take, defaultIfEmpty, map } from 'rxjs/operators'; - -import { endpointHasMetrics } from '../../../../core/src/features/endpoints/endpoint-helpers'; -import { EndpointOnlyAppState } from '../../../../store/src/app-state'; -import { EndpointsDataService } from '../../../../store/src/services/endpoints-data.service'; -import { APISuccessOrFailedAction } from '../../../../store/src/types/request.types'; -import { CF_APP_UPDATE_SUCCESS, UpdateExistingApplication } from '../../actions/application.actions'; -import { - createAppInstancesMetricAction, -} from '../../shared/components/list/list-types/app-instance/cf-app-instances-metrics-action'; - -@Injectable({ - providedIn: 'root' -}) -export class AppEffects { - private actions$ = inject(Actions); - private store = inject>(Store); - private appRef = inject(ApplicationRef); - private endpointsService = inject(EndpointsDataService); - - - clearCellMetrics$ = createEffect(() => this.actions$.pipe( - ofType(CF_APP_UPDATE_SUCCESS), - map(action => { - // User's can scale down instances and previous instance data is kept in store, when the user scales up again this stale data can - // be incorrectly shown straight away. In order to work around this fetch the latest metrics again when scaling up - // Note - If this happens within the metrics update time period (60 seconds) the stale one is returned again, unfortunately there's - // no way to work around this. - const updateAction: UpdateExistingApplication = action.apiAction as UpdateExistingApplication; - if (!!updateAction.existingApplication && updateAction.newApplication.instances > updateAction.existingApplication.instances) { - // First check that we have a metrics endpoint associated with this cf - endpointHasMetrics(updateAction.endpointGuid, this.endpointsService).pipe(take(1), defaultIfEmpty(false)).subscribe(hasMetrics => { - if (hasMetrics) { - this.store.dispatch(createAppInstancesMetricAction(updateAction.guid, updateAction.endpointGuid)); - } - this.appRef.tick(); - }); - } else { - this.appRef.tick(); - } - }), - ), { dispatch: false }); -} diff --git a/src/frontend/packages/cloud-foundry/src/store/effects/cloud-foundry.effects.ts b/src/frontend/packages/cloud-foundry/src/store/effects/cloud-foundry.effects.ts deleted file mode 100644 index a4d502d7e9..0000000000 --- a/src/frontend/packages/cloud-foundry/src/store/effects/cloud-foundry.effects.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { ApplicationRef, Injectable, inject } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { catchError, flatMap, mergeMap } from 'rxjs/operators'; - -import { environment } from '../../../../core/src/environments/environment.prod'; -import { entityCatalog } from '../../../../store/src/entity-catalog/entity-catalog'; -import { NormalizedResponse } from '../../../../store/src/types/api.types'; -import { - StartRequestAction, - WrapperRequestActionFailed, - WrapperRequestActionSuccess, -} from '../../../../store/src/types/request.types'; -import { GET_CF_INFO, GetCFInfo } from '../../actions/cloud-foundry.actions'; -import { CFAppState } from '../../cf-app-state'; - -// CF effects retention (wave-3 CF-effects audit, 2026-05-12): -// Retained — GetCFInfo is dispatched live via cfEntityCatalog.cfInfo -// (action-builder at cf-info.action-builders.ts:9). Active call sites -// include the CF endpoint healthCheck registration in -// cf-entity-generator.ts:295 and getEntityService in -// cloud-foundry-endpoint.service.ts:182. The handler itself is the -// V3-only Stratos-native /pp/v1/cf/info/ shim that returns the -// legacy ICfV2Info shape. -@Injectable({ - providedIn: 'root' -}) -export class CloudFoundryEffects { - private http = inject(HttpClient); - private actions$ = inject(Actions); - private store = inject>(Store); - private appRef = inject(ApplicationRef); - - proxyAPIVersion = environment.proxyAPIVersion; - - - // Hits the Stratos-native V3-only handler at GET /pp/v1/cf/info/. - // Replaces the legacy /pp/v1/proxy/v2/info passthrough so no frontend - // call reaches CF v2 directly. The response shape mirrors the legacy - // ICfV2Info wire shape (snake_case fields: api_version, - // app_ssh_endpoint, app_ssh_host_key_fingerprint, etc.) — values are - // sourced exclusively from /v3/info + the unversioned API root / - // (where SSH host_key_fingerprint and oauth_client live in - // links.app_ssh.meta). - fetchInfo$ = createEffect(() => this.actions$.pipe( - ofType(GET_CF_INFO), - flatMap(action => { - const actionType = 'fetch'; - const catalogEntity = entityCatalog.getEntity(action.endpointType, action.entityType); - const cfInfoKey = catalogEntity.entityKey; - this.store.dispatch(new StartRequestAction(action, actionType)); - const url = `/pp/${this.proxyAPIVersion}/cf/info/${action.guid}`; - return this.http - .get(url) - .pipe( - mergeMap((info: any) => { - const mappedData = { - entities: { [cfInfoKey]: {} }, - result: [] - } as NormalizedResponse; - const id = action.guid; - - mappedData.entities[cfInfoKey][id] = { - entity: info, - metadata: {} - }; - mappedData.result.push(id); - this.appRef.tick(); - return [ - new WrapperRequestActionSuccess(mappedData, action, actionType) - ]; - }), - catchError(error => { - this.appRef.tick(); - return [ - new WrapperRequestActionFailed(error.message, action, actionType, { - endpointIds: [action.guid], - url: error.url || url, - eventCode: error.status ? error.status + '' : '500', - message: 'Cloud Foundry Info request error', - error - }) - ]; - }) - ); - }) - )); -} diff --git a/src/frontend/packages/cloud-foundry/src/store/effects/update-app-effects.ts b/src/frontend/packages/cloud-foundry/src/store/effects/update-app-effects.ts deleted file mode 100644 index 31c8565052..0000000000 --- a/src/frontend/packages/cloud-foundry/src/store/effects/update-app-effects.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ApplicationRef, Injectable, inject } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { mergeMap } from 'rxjs/operators'; - -import { WrapperRequestActionSuccess } from '../../../../store/src/types/request.types'; -import { AppMetadataTypes } from '../../actions/app-metadata.actions'; -import { CF_APP_UPDATE_SUCCESS, UpdateExistingApplication } from '../../actions/application.actions'; -import { cfEntityCatalog } from '../../cf-entity-catalog'; - -@Injectable() -export class UpdateAppEffects { - private actions$ = inject(Actions); - private appRef = inject(ApplicationRef); - - - UpdateAppInStore$ = createEffect(() => this.actions$.pipe( - ofType(CF_APP_UPDATE_SUCCESS), - mergeMap((action: WrapperRequestActionSuccess): any[] => { - const updateAction = action.apiAction as UpdateExistingApplication; - const updateEntities = updateAction.updateEntities || [AppMetadataTypes.ENV_VARS, AppMetadataTypes.STATS, AppMetadataTypes.SUMMARY]; - const actions: any[] = []; - updateEntities.forEach((updateEntity: any) => { - switch (updateEntity) { - case AppMetadataTypes.ENV_VARS: - // This is done so the app metadata env vars environment_json matches that of the app - actions.push(cfEntityCatalog.appEnvVar.actions.getMultiple(action.apiAction.guid, action.apiAction.endpointGuid)); - break; - case AppMetadataTypes.STATS: { - const statsAction = cfEntityCatalog.appStats.actions.getMultiple( - action.apiAction.guid, - action.apiAction.endpointGuid as string - ); - // Application has changed and the associated app stats need to also be updated. - // Apps that are started can just make the stats call to update cached stats, however this call will fail for stopped apps. - // For those cases create a fake stats request response that should result in the same thing - if (updateAction.newApplication.state === 'STOPPED') { - actions.push(new WrapperRequestActionSuccess({ entities: {}, result: [] }, statsAction, 'fetch', 0, 0)); - } else { - actions.push(statsAction); - } - break; - } - case AppMetadataTypes.SUMMARY: - actions.push(cfEntityCatalog.appSummary.actions.get(action.apiAction.guid, action.apiAction.endpointGuid)); - break; - } - }); - - this.appRef.tick(); - return actions; - }))); -} 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 73307e95dc..704676421b 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 @@ -122,6 +122,7 @@ export class EndpointsSignalConfigService { readonly pageIndex = this.state.pageIndex; readonly nameFilter: WritableSignal = signal(''); readonly viewMode = this.state.viewMode; + readonly loading: Signal = this.endpointsData.loading; // Endpoint entries sourced from EndpointsSignalService so the // toSignal(store.select(endpointEntitiesSelector)) bridge lives in diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.html b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.html index 73b09c952f..31549f5aeb 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.html +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.html @@ -1,4 +1,8 @@ @if (listConfig(); as config) { - + + + + + } 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 61dd3b7ecd..2ffad30809 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 @@ -28,6 +28,8 @@ import { SignalListPillColor, SignalListRowAction, } from '../../../shared/components/signal-list/signal-list.component'; +import { SignalListCellTemplateDirective } from '../../../shared/components/signal-list/signal-list-cell-template.directive'; +import { TableCellEndpointAddressComponent } from '../../../shared/components/list/list-types/endpoint/table-cell-endpoint-address/table-cell-endpoint-address.component'; import { SnackBarService } from '../../../shared/services/snackbar.service'; import { TailwindDialogService } from '../../../shared/services/tailwind-dialog.service'; import { ConnectEndpointDialogComponent } from '../connect-endpoint-dialog/connect-endpoint-dialog.component'; @@ -45,7 +47,13 @@ import { EndpointsSignalConfigService } from './endpoints-signal-config.service' host: { class: 'flex flex-col flex-1 min-h-0' }, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, ListSubNavComponent, SignalListComponent], + imports: [ + CommonModule, + ListSubNavComponent, + SignalListComponent, + SignalListCellTemplateDirective, + TableCellEndpointAddressComponent, + ], }) export class EndpointsSignalListComponent { private store = inject>(Store); @@ -152,7 +160,7 @@ export class EndpointsSignalListComponent { totalPages: this.endpointsConfig.view.totalPages, pageIndex: this.endpointsConfig.pageIndex, pageSize: this.endpointsConfig.pageSize, - isAnyLoading: signal(false), + isAnyLoading: this.endpointsConfig.loading, errorsByCnsi: signal(new Map()), columns: [ { @@ -169,7 +177,12 @@ export class EndpointsSignalListComponent { }, { header: 'Address', key: 'address', sortField: addressOf, - kind: 'text', + // Cell projected via + // in this component's HTML — restores the FWT-929 duplicate-URL + // warning that the signal-list migration dropped when it + // collapsed the Address column to plain text. + kind: 'template', + templateName: 'address', render: addressOf, widthHint: '24rem', }, diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts index 1a1128c230..673ab98fca 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-config.ts @@ -1,4 +1,4 @@ -import { Action, AppState, EntitySchema, PaginatedAction, Store } from '@stratosui/store'; +import { AppState, EntitySchema, PaginatedAction, Store } from '@stratosui/store'; import { Observable, OperatorFunction } from 'rxjs'; import { IListConfig } from '../list.component.types'; @@ -92,13 +92,6 @@ export interface IListDataSourceConfig { */ refresh?: () => void; - /** - * A function that will be called instead of the default update metrics action - * - * This will only be called when metrics-range-selector component is enabled/used - */ - handleTimeWindowChange?: (action: Action) => void; - /** * A function which fetches an observable containing a specific row's state */ diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-types.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-types.ts index 7a2676437d..ef1435c069 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-types.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-types.ts @@ -7,7 +7,6 @@ import { IRequestEntityTypeState, ListFilter, ListSort, - MetricsAction, PaginatedAction, PaginationEntityState, PaginationParam, @@ -137,7 +136,6 @@ export interface IListDataSource extends ICoreListDataSource, ICoreTableLi setMultiFilter(changes: ListPaginationMultiFilterChange[], params: PaginationParam): void; refresh(): void; - updateMetricsAction(newAction: MetricsAction): void; /** * Ensure that list maxed status is ignored. This will result in all results being shown when previously ignored */ diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts index 39bb38d053..23c9c48215 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts @@ -10,7 +10,6 @@ import { ListFilter, ListSort, LocalPaginationHelpers, - MetricsAction, PaginatedAction, PaginationEntityState, PaginationMonitor, @@ -143,7 +142,6 @@ export abstract class ListDataSource extends DataSource implements private transformedEntitiesSubscription: Subscription; private seedSyncSub: Subscription; - protected metricsAction: MetricsAction; public entitySelectConfig: EntitySelectConfig; public refresh: () => void; @@ -399,7 +397,7 @@ export abstract class ListDataSource extends DataSource implements if (Array.isArray(this.action)) { this.action.forEach(action => this.store.dispatch(action)); } else { - this.store.dispatch(this.metricsAction || this.masterAction); + this.store.dispatch(this.masterAction); } }; } @@ -587,16 +585,6 @@ export abstract class ListDataSource extends DataSource implements } - public updateMetricsAction(newAction: MetricsAction) { - this.metricsAction = newAction; - - if (this.config.handleTimeWindowChange) { - this.config.handleTimeWindowChange(newAction); - } else { - this.store.dispatch(newAction); - } - } - public showAllAfterMax() { this.store.dispatch(new IgnorePaginationMaxedState( this.masterAction.entityType, diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.html b/src/frontend/packages/core/src/shared/components/list/list.component.html index cacb2bf99f..a5580de99a 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list.component.html @@ -17,17 +17,6 @@
{{ config.text?.title }}
} - - @if (config.showCustomTime && (isAddingOrSelecting$ | async) === false) { -
- - -
- } @if ((dataSource.isSelecting$ | async)) {
diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.ts b/src/frontend/packages/core/src/shared/components/list/list.component.ts index 00d2708413..9b92eec63c 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.ts @@ -96,7 +96,6 @@ import { import { CardsComponent } from './list-cards/cards.component'; import { TableComponent } from './list-table/table.component'; import { PollingIndicatorComponent } from '../polling-indicator/polling-indicator.component'; -import { MetricsRangeSelectorComponent } from '../metrics-range-selector/metrics-range-selector.component'; import { MaxListMessageComponent } from './max-list-message/max-list-message.component'; import { AppPaginatorComponent } from '../app-paginator/app-paginator.component'; @@ -112,7 +111,6 @@ import { AppPaginatorComponent } from '../app-paginator/app-paginator.component' CardsComponent, forwardRef(() => TableComponent), PollingIndicatorComponent, - MetricsRangeSelectorComponent, MaxListMessageComponent, AppPaginatorComponent, ], diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts index 30f44bc5a0..61752b1814 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts @@ -3,7 +3,6 @@ import { ActionState, ListView } from '@stratosui/store'; import { BehaviorSubject, combineLatest, Observable, of as observableOf } from 'rxjs'; import { take, filter, map, startWith, switchMap } from 'rxjs/operators'; -import { ITimeRange } from '../../services/metrics-range-selector.types'; import { ListDataSource } from './data-sources-controllers/list-data-source'; import { IListDataSource } from './data-sources-controllers/list-data-source-types'; import { CardTypes } from './list-cards/card/card.component'; @@ -102,26 +101,6 @@ export interface IListConfig { * Allow selection regardless of number or visibility of multi actions */ allowSelection?: boolean; - /** - * For metrics based data show a metrics range selector - */ - showCustomTime?: boolean; - /** - * Custom time window to show in metrics range selector - */ - customTimeWindows?: ITimeRange[]; - /** - * Custom time window validation for metrics range selector - */ - customTimeValidation?: (start: Date, end: Date) => string; - /** - * Custom time polling interval. Falsy for disabled. - */ - customTimePollingInterval?: number; - /** - * When enabled set the initial value - */ - customTimeInitialValue?: string; } // Simple list config does not need getDataSource diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.html b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.html index 2e39f7dd02..7677c5377a 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.html +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.html @@ -6,16 +6,16 @@

{{title}}

}
- @if (isRefreshing$ | async) { + @if (isRefreshing()) { } - @if (isFetching$ | async) { + @if (isFetching()) { } - @if ((isFetching$ | async) === false) { + @if (!isFetching()) {
- @if (results$ | async; as results) { + @if (results(); as results) {
@if (results.length) {
diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.spec.ts b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.spec.ts index 1355d849c3..96098dbc09 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.spec.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.spec.ts @@ -56,15 +56,6 @@ describe.skip('MetricsChartComponent', () => { component = fixture.componentInstance; component.chartConfig = new MetricsLineChartConfig(); component.chartConfig.xAxisLabel = 'Time'; - // TODO: Don't use action in another package - // component.metricsConfig = { - // metricsAction: new FetchApplicationMetricsAction( - // '1', - // '2', - // new MetricQueryConfig('test'), - // ), - // getSeriesName: () => 'test' - // }; fixture.detectChanges(); }); diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts index 4157edeb31..8ad6bd05fb 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.component.ts @@ -1,19 +1,16 @@ -import { ChangeDetectionStrategy, AfterContentInit, Component, ContentChild, Input, OnDestroy, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, AfterContentInit, Component, ContentChild, Input, OnDestroy, OnInit, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ChartConfiguration } from 'chart.js'; -import { Store } from '@ngrx/store'; import { - MetricsAction, - EntityMonitor, ChartSeries, IMetrics, MetricResultTypes, + MetricsDataService, MetricsFilterSeries, - AppState, - EntityMonitorFactory, + MetricsObservation, + MetricsRequest, } from '@stratosui/store'; -import { combineLatest, Observable, Subscription, timer } from 'rxjs'; -import { debounce, distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { BaseChartDirective } from 'ng2-charts'; import { CardWrapperComponent } from '../cards/card/card.component'; @@ -24,7 +21,7 @@ import { MetricsChartManager } from './metrics.component.manager'; const MAX_SERIES_IN_TOOLTIP = 16; export interface MetricsConfig { - metricsAction: MetricsAction; + request: MetricsRequest; getSeriesName: (item: T) => string; mapSeriesItemName?: (value: any) => string | Date; mapSeriesItemValue?: (value: any) => any; @@ -46,8 +43,7 @@ export interface MetricsConfig { changeDetection: ChangeDetectionStrategy.OnPush }) export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentInit { - private store = inject>(Store); - private entityMonitorFactory = inject(EntityMonitorFactory); + private metricsDataService = inject(MetricsDataService); @Input() public metricsConfig!: MetricsConfig; @@ -59,18 +55,49 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni @ContentChild(MetricsRangeSelectorComponent, { static: true }) public timeRangeSelector: MetricsRangeSelectorComponent; - @Input() - set metricsAction(action: MetricsAction) { - this.commitAction(action); - } - public hasMultipleInstances = false; public chartTypes = MetricsChartTypes; - private timeSelectorSub!: Subscription; + // Writable signal that drives MetricsDataService.observe(). Range + // selector (or parent range selector) writes here; chart re-fetches. + private readonly requestSignal = signal(null); + + // Read-only accessor for the parent range selector — used to merge + // new query params into each child chart's current request. + public get currentRequest(): MetricsRequest { + return this.requestSignal(); + } + + public applyRequest(req: MetricsRequest) { + this.requestSignal.set(req); + } + + private observation!: MetricsObservation; + + public results = computed[] | null>(() => { + const metrics = this.observation?.metrics(); + if (!metrics) { + return null; + } + const mapped = this.mapMetricsToChartData(metrics, this.metricsConfig); + const filtered = this.metricsConfig.filterSeries ? this.metricsConfig.filterSeries(mapped) : mapped; + if (!filtered.length) { + return []; + } + const { start, end, step } = (metrics.query.params || {}) as { start: number, end: number, step: number }; + this.hasMultipleInstances = filtered.length > 1; + return this.postFetchMiddleware(filtered, [start, end, step]); + }); + + public hasResults = computed(() => { + const r = this.results(); + return !!r && r.length > 0; + }); + + public isFetching = computed(() => this.observation ? this.observation.fetching() && !this.observation.metrics() : true); + public isRefreshing = computed(() => this.observation ? this.observation.fetching() && !!this.observation.metrics() : false); - public results$!: Observable | ChartSeries[] | null>; public chartJsData: ChartConfiguration['data'] = { labels: [], datasets: [] }; public chartOptions: ChartConfiguration['options'] = { responsive: true, @@ -98,12 +125,6 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni } }; - public metricsMonitor: EntityMonitor; - - private committedAction!: MetricsAction; - - public isRefreshing$!: Observable; - public isFetching$!: Observable; private sort(metricsArray: ChartSeries[]) { if (this.metricsConfig.sort) { const newMetricsArray = [ @@ -116,8 +137,9 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni private postFetchMiddleware(metricsArray: ChartSeries[], params: [number, number, number]) { const [start, end, step] = params; const sortedArray = this.sort(metricsArray); + let result = sortedArray; if (start && end && step) { - return MetricsChartManager.fillOutTimeOrderedChartSeries( + result = MetricsChartManager.fillOutTimeOrderedChartSeries( sortedArray, start, end, @@ -125,80 +147,27 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni this.metricsConfig, ); } - return sortedArray; + this.convertToChartJsData(result); + return result; } ngOnInit() { - this.committedAction = this.metricsConfig.metricsAction; - this.metricsMonitor = this.entityMonitorFactory.create( - this.metricsConfig.metricsAction.guid, - this.committedAction - ); - - const baseResults$ = this.metricsMonitor.entity$.pipe( - distinctUntilChanged((oldMetrics, newMetrics) => { - return oldMetrics && oldMetrics.data === newMetrics.data; - }), - - ); - - this.results$ = baseResults$.pipe( - map(metrics => { - if (!metrics) { - this.chartJsData = { labels: [], datasets: [] }; - return metrics; - } - const mapMetricsData = this.mapMetricsToChartData(metrics, this.metricsConfig); - const metricsArray = this.metricsConfig.filterSeries ? this.metricsConfig.filterSeries(mapMetricsData) : mapMetricsData; - if (!metricsArray.length) { - this.chartJsData = { labels: [], datasets: [] }; - return []; - } - - // Convert to Chart.js format - this.convertToChartJsData(metricsArray); - - const query = metrics.query; - const { start, end, step } = query.params as { start: number, end: number, step: number }; - this.hasMultipleInstances = metricsArray.length > 1; - return this.postFetchMiddleware(metricsArray, [start, end, step]); - }), - distinctUntilChanged() - ); - - this.isRefreshing$ = combineLatest( - this.results$, - this.metricsMonitor.isFetchingEntity$.pipe(startWith(true)) - ).pipe( - debounce(([_results, fetching]) => { - return !fetching ? timer(800) : timer(0); - }), - map(([results, fetching]) => results && fetching), - distinctUntilChanged() - ); - - this.isFetching$ = combineLatest( - this.results$.pipe(startWith(null)), - this.metricsMonitor.isFetchingEntity$.pipe(startWith(true)) - ).pipe( - map(([results, fetching]) => !results && fetching), - distinctUntilChanged(), - startWith(true), - ); + this.requestSignal.set(this.metricsConfig.request); + this.observation = this.metricsDataService.observe(this.requestSignal); } ngAfterContentInit() { if (this.timeRangeSelector) { - this.timeRangeSelector.baseAction = this.metricsConfig.metricsAction; - this.timeSelectorSub = this.timeRangeSelector.metricsAction.subscribe((action: MetricsAction) => { - this.commitAction(action); + this.timeRangeSelector.baseRequest = this.metricsConfig.request; + this.timeRangeSelector.request.pipe(takeUntilDestroyed()).subscribe((req: MetricsRequest) => { + this.requestSignal.set(req); }); } } ngOnDestroy() { - if (this.timeSelectorSub) { - this.timeSelectorSub.unsubscribe(); + if (this.observation) { + this.observation.stop(); } } @@ -219,11 +188,6 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni } } - private commitAction(action: MetricsAction) { - this.committedAction = action; - this.store.dispatch(action); - } - public getTooltipName(model: { name: { toLocaleString: () => any; }; }) { return model.name.toLocaleString(); } @@ -248,7 +212,6 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni return; } - // Get all unique timestamps across all series const allTimestamps = new Set(); metricsArray.forEach(series => { series.series.forEach(point => { @@ -260,7 +223,6 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); const labels = sortedTimestamps.map(timestamp => new Date(timestamp).toLocaleTimeString()); - // Convert each series to Chart.js dataset const datasets = metricsArray.map((series, index) => { const data = sortedTimestamps.map(timestamp => { const point = series.series.find(p => { @@ -284,7 +246,6 @@ export class MetricsChartComponent implements OnInit, OnDestroy, AfterContentIni this.chartJsData = { labels, datasets }; - // Update chart options with axis labels if (this.chartConfig && this.chartOptions?.scales) { if (this.chartOptions.scales.x && 'title' in this.chartOptions.scales.x && this.chartOptions.scales.x.title) { this.chartOptions.scales.x.title.text = this.chartConfig.xAxisLabel || ''; diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.types.ts b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.types.ts index b9c901cdb6..365f016ef0 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.types.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics-chart.types.ts @@ -1,7 +1,7 @@ -import { MetricsAction } from '@stratosui/store'; +import { MetricsRequest } from '@stratosui/store'; export interface IMetricsConfig { - metricsAction: MetricsAction; + request: MetricsRequest; getSeriesName: (obj: T) => string; mapSeriesItemName?: (anything: any) => any; mapSeriesItemValue?: (anything: any) => any; @@ -25,7 +25,7 @@ export class MetricsLineChartConfig implements IMetricsChartConfig { chartType = MetricsChartTypes.LINE; xAxisLabel?: string; yAxisLabel?: string; - autoScale = true; // This should be on by default + autoScale = true; yAxisTicks?: any[]; yAxisTickFormatting?: YAxisTickFormattingFunc; } diff --git a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics.component.helpers.ts b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics.component.helpers.ts index 4083e13a12..017d4e5907 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-chart/metrics.component.helpers.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-chart/metrics.component.helpers.ts @@ -1,4 +1,4 @@ -import { ChartSeries, MetricsFilterSeries, IMetricMatrixResult, MetricsAction } from '@stratosui/store'; +import { ChartSeries, MetricsFilterSeries, IMetricMatrixResult, MetricsRequest } from '@stratosui/store'; import { MetricsConfig } from './metrics-chart.component'; import { YAxisTickFormattingFunc, MetricsLineChartConfig } from './metrics-chart.types'; @@ -35,18 +35,18 @@ export enum ChartDataTypes { } export function getMetricsChartConfigBuilder(getSeriesName: (result: IMetricMatrixResult) => string) { return ( - metricsAction: MetricsAction, + request: MetricsRequest, yAxisLabel: string, dataType: ChartDataTypes | null = null, filterSeries?: MetricsFilterSeries, yAxisTickFormatter?: YAxisTickFormattingFunc, tooltipValueFormatter?: YAxisTickFormattingFunc - ) => buildMetricsChartConfig(metricsAction, yAxisLabel, getSeriesName, dataType, filterSeries, yAxisTickFormatter, + ) => buildMetricsChartConfig(request, yAxisLabel, getSeriesName, dataType, filterSeries, yAxisTickFormatter, tooltipValueFormatter); } export function buildMetricsChartConfig( - metricsAction: MetricsAction, + request: MetricsRequest, yAxisLabel: string, getSeriesName: (result: IMetricMatrixResult) => string, dataType: ChartDataTypes | null = null, @@ -63,7 +63,7 @@ export function buildMetricsChartConfig( mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, sort: MetricsChartHelpers.sortBySeriesName, mapSeriesItemValue: getServiceItemValueMapper(dataType), - metricsAction, + request, filterSeries, tooltipValueFormatter, }, @@ -74,7 +74,6 @@ export function buildMetricsChartConfig( function getServiceItemValueMapper(chartDataType: ChartDataTypes | null): ((value: any) => string) | null { switch (chartDataType) { case ChartDataTypes.BYTES: - // Megabytes - this should really be dynamic based on the value return (bytes: number) => (bytes / 1024 / 1024).toFixed(2); case ChartDataTypes.CPU_PERCENT: return (percent: string | number) => parseFloat(percent.toString()).toFixed(2); diff --git a/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.ts b/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.ts index 2c61ed0613..c0afc97fe2 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component.ts @@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common'; import { CardWrapperComponent } from '../cards/card/card.component'; import { CustomFormFieldComponent } from '../custom-form-field/custom-form-field.component'; import { CustomSelectComponent, CustomOptionComponent } from '../custom-select/custom-select.component'; -import { EntityMonitorFactory, IMetrics, MetricQueryType } from '@stratosui/store'; +import { MetricQueryType } from '@stratosui/store'; import { Subscription } from 'rxjs'; import { MetricsRangeSelectorManagerService } from '../../services/metrics-range-selector-manager.service'; @@ -30,10 +30,9 @@ import { StartEndDateComponent } from '../start-end-date/start-end-date.componen changeDetection: ChangeDetectionStrategy.OnPush }) export class MetricsParentRangeSelectorComponent implements AfterContentInit, OnDestroy { - private entityMonitorFactory = inject(EntityMonitorFactory); rangeSelectorManager = inject(MetricsRangeSelectorManagerService); - private actionSub!: Subscription; + private requestSub!: Subscription; @ContentChildren(MetricsChartComponent) private metricsCharts!: QueryList; @@ -44,33 +43,29 @@ export class MetricsParentRangeSelectorComponent implements AfterContentInit, On if (!this.metricsCharts || !this.metricsCharts.first) { return; } - const action = this.metricsCharts.first.metricsConfig.metricsAction; - const metricsMonitor = this.entityMonitorFactory.create( - action.guid, - action - ); - this.rangeSelectorManager.init(metricsMonitor, action); - this.actionSub = this.rangeSelectorManager.metricsAction$.subscribe(newAction => { - if (newAction) { + const baseRequest = this.metricsCharts.first.metricsConfig.request; + this.rangeSelectorManager.init(baseRequest); + this.requestSub = this.rangeSelectorManager.request$.subscribe(next => { + if (next) { this.metricsCharts.forEach(chart => { - const oldAction = chart.metricsConfig.metricsAction; - chart.metricsAction = { - ...oldAction, - queryType: newAction.queryType, + const oldRequest = chart.currentRequest; + chart.applyRequest({ + ...oldRequest, + queryType: next.queryType, query: { - ...oldAction.query, - params: newAction.query.params + ...oldRequest.query, + params: next.query.params, }, - windowValue: newAction.windowValue - }; + windowValue: next.windowValue, + }); }); } }); } ngOnDestroy() { - if (this.actionSub) { - this.actionSub.unsubscribe(); + if (this.requestSub) { + this.requestSub.unsubscribe(); } } diff --git a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts index 10379da0d0..1cdfdabd4b 100644 --- a/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts +++ b/src/frontend/packages/core/src/shared/components/metrics-range-selector/metrics-range-selector.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { CustomFormFieldComponent } from '../custom-form-field/custom-form-field.component'; import { CustomSelectComponent, CustomOptionComponent } from '../custom-select/custom-select.component'; -import { EntityMonitorFactory, MetricQueryType, IMetrics, MetricsAction, EntityMonitor } from '@stratosui/store'; +import { MetricQueryType, MetricsRequest } from '@stratosui/store'; import { Subscription } from 'rxjs'; import { MetricsRangeSelectorManagerService } from '../../services/metrics-range-selector-manager.service'; @@ -29,11 +29,10 @@ import { StartEndDateComponent } from '../start-end-date/start-end-date.componen changeDetection: ChangeDetectionStrategy.OnPush }) export class MetricsRangeSelectorComponent implements OnDestroy { - private entityMonitorFactory = inject(EntityMonitorFactory); rangeSelectorManager = inject(MetricsRangeSelectorManagerService); private rangeSelectorSub: Subscription; - actionSub: Subscription; + private requestSub: Subscription; constructor() { this.rangeSelectorSub = this.rangeSelectorManager.timeWindow$.subscribe(selectedTimeRangeValue => { @@ -43,37 +42,27 @@ export class MetricsRangeSelectorComponent implements OnDestroy { } } }); - this.actionSub = this.rangeSelectorManager.metricsAction$.subscribe(newAction => { - if (newAction) { - this.commitAction(newAction); + this.requestSub = this.rangeSelectorManager.request$.subscribe(next => { + if (next) { + this.request.emit(next); } }); } - public metricsMonitor!: EntityMonitor; - public rangeTypes = MetricQueryType; @Output() - public metricsAction = new EventEmitter(); + public request = new EventEmitter(); - private baseActionValue!: MetricsAction; + private baseRequestValue!: MetricsRequest; @Input() - set baseAction(action: MetricsAction) { - this.baseActionValue = action; - this.metricsMonitor = this.entityMonitorFactory.create( - action.guid, - // Look specifically for metrics entity type for the given endpoint. See #3783 - { - entityType: action.entityType, - endpointType: action.endpointType - } - ); - this.rangeSelectorManager.init(this.metricsMonitor, action); + set baseRequest(req: MetricsRequest) { + this.baseRequestValue = req; + this.rangeSelectorManager.init(req); } - get baseAction() { - return this.baseActionValue; + get baseRequest() { + return this.baseRequestValue; } @Input() @@ -109,19 +98,14 @@ export class MetricsRangeSelectorComponent implements OnDestroy { public showOverlayValue = false; - private commitAction(action: MetricsAction) { - this.metricsAction.emit(action); - } - - private tidyUp() { + ngOnDestroy() { this.rangeSelectorManager.destroy(); if (this.rangeSelectorSub) { this.rangeSelectorSub.unsubscribe(); } - } - - ngOnDestroy() { - this.tidyUp(); + if (this.requestSub) { + this.requestSub.unsubscribe(); + } } } diff --git a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts index 3e9882d801..0d0fb06fc3 100644 --- a/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts +++ b/src/frontend/packages/core/src/shared/services/metrics-range-selector-manager.service.ts @@ -1,7 +1,6 @@ import { ApplicationRef, Injectable, NgZone, inject } from '@angular/core'; -import { MetricsAction, MetricQueryType, EntityMonitor, IMetrics } from '@stratosui/store'; -import { Subject, Subscription } from 'rxjs'; -import { debounceTime, takeWhile, tap } from 'rxjs/operators'; +import { MetricsRequest, MetricQueryType } from '@stratosui/store'; +import { Subject } from 'rxjs'; import { isValid, isEqual } from 'date-fns'; import { MetricsRangeSelectorService } from './metrics-range-selector.service'; @@ -28,21 +27,17 @@ export class MetricsRangeSelectorManagerService { public times = this.metricRangeService.times; - public metricsMonitor: EntityMonitor; - private readonly startIndex = 0; private readonly endIndex = 1; public startEnd: [Date, Date] = [null, null]; - private initSub: Subscription; - public selectedTimeRangeValue: ITimeRange; - public metricsAction$ = new Subject(); + public request$ = new Subject(); - private baseAction: MetricsAction; + private baseRequest: MetricsRequest; private pollIndex: number; @@ -61,50 +56,26 @@ export class MetricsRangeSelectorManagerService { this.startEnd[index] = date; const [start, end] = this.startEnd; if (start && end) { - const action = this.metricRangeService.getNewDateRangeAction(this.baseAction, start, end); + const next = this.metricRangeService.getNewDateRangeRequest(this.baseRequest, start, end); this.commit = () => { this.committedStartEnd = [ this.startEnd[0], this.startEnd[1] ]; - this.commitAction(action); + this.commitRequest(next); }; } } - private setTimeWindowFromStore(metrics: IMetrics) { - const { timeRange, start, end } = this.metricRangeService.getDateFromStoreMetric(metrics); - const isDifferent = (!start || !end) || !isEqual(start, this.start) || !isEqual(end, this.end); - if (isDifferent) { - this.committedStartEnd = [start, end]; + public init(baseRequest: MetricsRequest) { + this.baseRequest = baseRequest; + if (!this.selectedTimeRange) { + const { timeRange } = this.metricRangeService.resolveTimeRange(baseRequest.windowValue); + this.selectedTimeRange = timeRange; } - this.selectedTimeRange = timeRange; - } - - public init(entityMonitor: EntityMonitor, baseAction: MetricsAction) { - this.baseAction = baseAction; - this.initSub = entityMonitor.entity$.pipe( - tap(metrics => { - if (metrics && !this.selectedTimeRange) { - this.setTimeWindowFromStore(metrics); - } - }), - debounceTime(0), - tap(metrics => { - // entity$ emits null first. - // If its still null after the debounce then we run setTimeWindowFromStore to get default selection - if (!metrics && !this.selectedTimeRange) { - this.setTimeWindowFromStore(metrics); - } - }), - takeWhile(metrics => !metrics) - ).subscribe(); } public destroy() { - if (this.initSub) { - this.initSub.unsubscribe(); - } this.endWindowPoll(); } @@ -145,10 +116,8 @@ export class MetricsRangeSelectorManagerService { this.ngZone.runOutsideAngular(() => { this.pollIndex = window.setInterval( () => { - if (timeWindow.value != null && this.baseAction) { - this.commitAction(this.metricRangeService.getNewTimeWindowAction(this.baseAction, timeWindow.value)); - // ZONELESS: Trigger change detection after periodic metrics update - // This runs outside Angular zone but needs to notify Angular of state changes + if (timeWindow.value != null && this.baseRequest) { + this.commitRequest(this.metricRangeService.getNewTimeWindowRequest(this.baseRequest, timeWindow.value)); this.ngZone.run(() => this.appRef.tick()); } }, @@ -168,14 +137,14 @@ export class MetricsRangeSelectorManagerService { } this.committedStartEnd = [null, null]; this.startEnd = [null, null]; - this.commitAction(this.metricRangeService.getNewTimeWindowAction(this.baseAction, timeWindow.value)); + this.commitRequest(this.metricRangeService.getNewTimeWindowRequest(this.baseRequest, timeWindow.value)); if (timeWindow.value) { this.startWindowPoll(timeWindow); } } - private commitAction(action: MetricsAction) { - this.metricsAction$.next(action); + private commitRequest(request: MetricsRequest) { + this.request$.next(request); this.commit = null; } diff --git a/src/frontend/packages/core/src/shared/services/metrics-range-selector.service.ts b/src/frontend/packages/core/src/shared/services/metrics-range-selector.service.ts index c57dc2372c..056213f655 100644 --- a/src/frontend/packages/core/src/shared/services/metrics-range-selector.service.ts +++ b/src/frontend/packages/core/src/shared/services/metrics-range-selector.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { MetricQueryType, MetricQueryConfig, MetricsAction, IMetrics } from '@stratosui/store'; +import { MetricQueryType, MetricQueryConfig, MetricsRequest } from '@stratosui/store'; import { sub, getUnixTime } from 'date-fns'; import { ITimeRange, StoreMetricTimeRange } from './metrics-range-selector.types'; @@ -44,11 +44,11 @@ export class MetricsRangeSelectorService { } ]; - private newMetricsAction(action: MetricsAction, newQuery: MetricQueryConfig): MetricsAction { + private withNewQuery(req: MetricsRequest, newQuery: MetricQueryConfig): MetricsRequest { return { - ...action, + ...req, queryType: MetricQueryType.RANGE_QUERY, - query: newQuery + query: newQuery, }; } @@ -58,7 +58,6 @@ export class MetricsRangeSelectorService { const unit = windowSplit[1]; const now = new Date(); - // Map unit string to date-fns duration object key const duration: any = {}; if (unit === 'minute') duration.minutes = amount; else if (unit === 'hour') duration.hours = amount; @@ -73,51 +72,35 @@ export class MetricsRangeSelectorService { ]; } - public getNewDateRangeAction(action: MetricsAction, start: Date, end: Date) { + public getNewDateRangeRequest(req: MetricsRequest, start: Date, end: Date): MetricsRequest { const startUnix = getUnixTime(start); const endUnix = getUnixTime(end); - return this.newMetricsAction(action, new MetricQueryConfig(action.query.metric, { - ...action.query.params, + return this.withNewQuery(req, new MetricQueryConfig(req.query.metric, { + ...req.query.params, start: startUnix, end: endUnix, step: Math.max((endUnix - startUnix) / 50, 0) })); } - public getNewTimeWindowAction(action: MetricsAction, windowValue: string) { + public getNewTimeWindowRequest(req: MetricsRequest, windowValue: string): MetricsRequest { const [start, end] = this.convertWindowToRange(windowValue); - const newAction = { ...action }; - newAction.windowValue = windowValue; - return this.getNewDateRangeAction(newAction, start, end); + const next = this.getNewDateRangeRequest(req, start, end); + return { ...next, windowValue }; } - public getDateFromStoreMetric(metrics: IMetrics, times = this.times): StoreMetricTimeRange { - if (metrics) { - if (metrics.windowValue) { - return { - timeRange: times.find(time => time.value === metrics.windowValue) - }; - } else { - return { - timeRange: metrics.query && metrics.query.params && metrics.query.params.window ? - times.find(time => time.value === metrics.query.params.window) : - this.getDefaultTimeRange(times) - }; - } - } else { - const timeRange = this.getDefaultTimeRange(times); - return { - timeRange - }; + public getDefaultTimeRange(times = this.times): ITimeRange { + if (this.defaultTimeValue) { + return times.find(time => time.value === this.defaultTimeValue) || this.times[0]; } + return times.find(time => time.value === '1:hour') || this.times[0]; } - private getDefaultTimeRange(times = this.times) { - if (this.defaultTimeValue) { - return times.find(time => time.value === this.defaultTimeValue) || this.times[0]; - } else { - return times.find(time => time.value === '1:hour') || this.times[0]; + public resolveTimeRange(windowValue: string | null | undefined, times = this.times): StoreMetricTimeRange { + if (windowValue) { + return { timeRange: times.find(time => time.value === windowValue) || this.getDefaultTimeRange(times) }; } + return { timeRange: this.getDefaultTimeRange(times) }; } } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts index d2ea966654..7817a89fa9 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts @@ -2,7 +2,6 @@ import { Schema, schema } from 'normalizr'; import { getAPIResourceGuid } from '../../../cloud-foundry/src/store/selectors/api.selectors'; import { EntitySchema } from '../../../store/src/helpers/entity-schema'; -import { metricEntityType } from '../../../store/src/helpers/stratos-entity-factory'; import { getGuidFromKubeDeploymentObj, getGuidFromKubeNamespaceObj, @@ -113,8 +112,6 @@ entityCache[analysisReportEntityType] = new KubernetesEntitySchema( { idAttribute: 'id' } ); -entityCache[metricEntityType] = new KubernetesEntitySchema(metricEntityType); - export function addKubernetesEntitySchema(key: string, newSchema: EntitySchema) { entityCache[key] = newSchema; } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts index 6b3b241a05..817e8e4551 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts @@ -18,7 +18,6 @@ import { StratosEndpointExtensionDefinition, } from '../../../store/src/entity-catalog/entity-catalog.types'; import { EndpointAuthTypeConfig, EndpointType } from '../../../store/src/extension-types'; -import { metricEntityType } from '../../../store/src/helpers/stratos-entity-factory'; import { entityFetchedWithoutError } from '../../../store/src/operators'; import { IFavoriteMetadata, UserFavorite } from '../../../store/src/types/user-favorites.types'; import { KubernetesAWSAuthFormComponent } from './auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component'; @@ -234,7 +233,6 @@ export class KubeEntityCatalog { public dashboard: StratosCatalogEntity; public analysisReport: StratosCatalogEntity; public configMap: StratosCatalogEntity; - public metrics: StratosCatalogEntity; public secrets: StratosCatalogEntity; public pvc: StratosCatalogEntity; @@ -384,7 +382,6 @@ export class KubeEntityCatalog { }, ] }); - this.metrics = this.generateMetricEntity(endpointDef); this.secrets = KubeResourceEntityHelper.generate(endpointDef, { type: 'secrets', icon: 'config_maps', @@ -593,16 +590,6 @@ export class KubeEntityCatalog { }); } - private generateMetricEntity(endpointDefinition: StratosEndpointExtensionDefinition) { - return new StratosCatalogEntity({ - type: metricEntityType, - schema: kubernetesEntityFactory(metricEntityType), - label: 'Kubernetes Metric', - labelPlural: 'Kubernetes Metrics', - endpoint: endpointDefinition, - }); - } - private jobToCompletion(spec: { completions?: number; parallelism?: number }, status: { succeeded?: number; Succeeded?: number }): string { if (spec.completions) { return status.succeeded + '/' + spec.completions; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts index 38fd7bdabe..178328ba17 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts @@ -5,9 +5,12 @@ import { CardWrapperComponent } from '../../../../../../core/src/shared/componen import { MetricsChartComponent, MetricsConfig } from '../../../../../../core/src/shared/components/metrics-chart/metrics-chart.component'; import { MetricsLineChartConfig } from '../../../../../../core/src/shared/components/metrics-chart/metrics-chart.types'; import { MetricsChartHelpers } from '../../../../../../core/src/shared/components/metrics-chart/metrics.component.helpers'; +import { MetricQueryConfig } from '../../../../../../store/src/actions/metrics.actions'; +import { MetricsRequest } from '../../../../../../store/src/services/metrics-data.service'; import { IMetricMatrixResult } from '../../../../../../store/src/types/base-metric.types'; -import { IMetricApplication } from '../../../../../../store/src/types/metric.types'; -import { FetchKubernetesMetricsAction } from '../../../store/kubernetes.actions'; +import { IMetricApplication, MetricQueryType } from '../../../../../../store/src/types/metric.types'; + +const KUBE_METRICS_BASE_URL = '/pp/v1/metrics/kubernetes'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -43,17 +46,20 @@ export class KubernetesNodeMetricsChartComponent implements OnInit { ngOnInit() { this.instanceChartConfig = MetricsChartHelpers.buildChartConfig(this.yAxisLabel); - const query = `${this.metricName}{instance="${this.nodeName}"}[1h]&time=${(new Date()).getTime() / 1000}`; + const queryString = `${this.metricName}{instance="${this.nodeName}"}[1h]&time=${(new Date()).getTime() / 1000}`; + const request: MetricsRequest = { + endpointGuid: this.endpointGuid, + url: `${KUBE_METRICS_BASE_URL}/${this.nodeName}`, + query: new MetricQueryConfig(queryString), + queryType: MetricQueryType.QUERY, + windowValue: null, + }; this.instanceMetricConfig = { getSeriesName: result => (result.metric as any).name ? (result.metric as any).name : (result.metric as any).id || result.metric.__name__ || 'unknown', mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, sort: MetricsChartHelpers.sortBySeriesName, mapSeriesItemValue: this.getmapSeriesItemValue(), - metricsAction: new FetchKubernetesMetricsAction( - this.nodeName, - this.endpointGuid, - query, - ), + request, }; } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts index 42f9702ccf..1b5edc71dd 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts @@ -10,13 +10,27 @@ import { import { MetricsParentRangeSelectorComponent } from '../../../../../core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component'; import { TileComponent } from '../../../../../core/src/shared/components/tile/tile/tile.component'; import { TileGroupComponent } from '../../../../../core/src/shared/components/tile/tile-group/tile-group.component'; +import { MetricQueryConfig } from '../../../../../store/src/actions/metrics.actions'; +import { MetricsRequest } from '../../../../../store/src/services/metrics-data.service'; import { ChartSeries, IMetricMatrixResult } from '../../../../../store/src/types/base-metric.types'; +import { MetricQueryType } from '../../../../../store/src/types/metric.types'; import { formatAxisCPUTime, formatCPUTime } from '../../kubernetes-metrics.helpers'; import { IKubernetesMetric } from '../../kubernetes-metric.types'; import { KubeNodeMetric, KubernetesNodeService } from '../../services/kubernetes-node.service'; -import { FetchKubernetesChartMetricsAction } from '../../store/kubernetes.actions'; import { KubernetesNodeMetricStatsCardComponent } from './kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component'; +const KUBE_METRICS_BASE_URL = '/pp/v1/metrics/kubernetes'; + +function buildKubeChartRequest(nodeName: string, endpointGuid: string, metric: string): MetricsRequest { + return { + endpointGuid, + url: `${KUBE_METRICS_BASE_URL}/${nodeName}`, + query: new MetricQueryConfig(metric), + queryType: MetricQueryType.RANGE_QUERY, + windowValue: null, + }; +} + @Component({ selector: 'app-kubernetes-node-metrics', templateUrl: './kubernetes-node-metrics.component.html', @@ -76,7 +90,7 @@ export class KubernetesNodeMetricsComponent implements OnInit { this.instanceMetricConfigs = [ chartConfigBuilder( - new FetchKubernetesChartMetricsAction( + buildKubeChartRequest( this.kubeNodeService.nodeName, this.kubeNodeService.kubeGuid, `${KubeNodeMetric.MEMORY}{instance="${this.kubeNodeService.nodeName}"}` @@ -93,7 +107,7 @@ export class KubernetesNodeMetricsComponent implements OnInit { (value: string) => value + ' MB' ), chartConfigBuilder( - new FetchKubernetesChartMetricsAction( + buildKubeChartRequest( this.kubeNodeService.nodeName, this.kubeNodeService.kubeGuid, `${KubeNodeMetric.CPU}{instance="${this.kubeNodeService.nodeName}"}` diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-signal-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-signal-config.service.ts index e0b5d4be87..36eaea7651 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-signal-config.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-signal-config.service.ts @@ -161,10 +161,9 @@ export class KubernetesEndpointsSignalConfigService { totalPages: this.view.totalPages, pageIndex: this.pageIndex, pageSize: this.pageSize, - // Endpoints projection is synchronous off the store — no async - // load to track. `false` keeps the toolbar's loading affordance - // off (matches the legacy behaviour, which had no spinner here). - isAnyLoading: signal(false), + // Bound to the shared EndpointsDataService loading signal so the + // toolbar refresh button swaps to the spinner during getAll(). + isAnyLoading: this.endpointsData.loading, errorsByCnsi: signal(new Map()), columns, getRowKey: (ep: EndpointModel) => ep.guid, diff --git a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts index 6edef3ab8b..a5f99fe676 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts @@ -16,8 +16,11 @@ import { } from '../../../../core/src/shared/components/metrics-parent-range-selector/metrics-parent-range-selector.component'; import { PageHeaderComponent } from '../../../../core/src/shared/components/page-header/page-header.component'; import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { MetricQueryConfig } from '../../../../store/src/actions/metrics.actions'; +import { MetricsRequest } from '../../../../store/src/services/metrics-data.service'; import { EntityInfo } from '../../../../store/src/types/api.types'; import { ChartSeries, IMetricMatrixResult } from '../../../../store/src/types/base-metric.types'; +import { MetricQueryType } from '../../../../store/src/types/metric.types'; import { kubeEntityCatalog } from '../kubernetes-entity-generator'; import { formatAxisCPUTime, formatCPUTime } from '../kubernetes-metrics.helpers'; import { IKubernetesMetric } from '../kubernetes-metric.types'; @@ -25,7 +28,18 @@ import { BaseKubeGuid } from '../kubernetes-page.types'; import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; import { KubernetesService } from '../services/kubernetes.service'; import { KubernetesPod } from '../store/kube.types'; -import { FetchKubernetesMetricsAction } from '../store/kubernetes.actions'; + +const KUBE_METRICS_BASE_URL = '/pp/v1/metrics/kubernetes'; + +function buildPodMetricRequest(podName: string, endpointGuid: string, query: string): MetricsRequest { + return { + endpointGuid, + url: `${KUBE_METRICS_BASE_URL}/${podName}`, + query: new MetricQueryConfig(query), + queryType: MetricQueryType.QUERY, + windowValue: null, + }; +} @Component({ selector: 'app-pod-metrics', @@ -84,7 +98,7 @@ export class PodMetricsComponent { ); this.instanceMetricConfigs = [ chartConfigBuilder( - new FetchKubernetesMetricsAction( + buildPodMetricRequest( this.podName, this.kubeEndpointService.kubeGuid, `container_memory_usage_bytes{pod="${this.podName}",namespace="${namespace}"}` @@ -102,7 +116,7 @@ export class PodMetricsComponent { (value: string) => value + ' MB' ), cpuChartConfigBuilder( - new FetchKubernetesMetricsAction( + buildPodMetricRequest( this.podName, this.kubeEndpointService.kubeGuid, `container_cpu_usage_seconds_total{pod="${this.podName}",namespace="${namespace}"}` @@ -119,7 +133,7 @@ export class PodMetricsComponent { (value: string) => formatCPUTime(value), ), networkChartConfigBuilder( - new FetchKubernetesMetricsAction( + buildPodMetricRequest( this.podName, this.kubeEndpointService.kubeGuid, `container_network_transmit_bytes_total{pod="${this.podName}",namespace="${namespace}"}` @@ -131,7 +145,7 @@ export class PodMetricsComponent { (value: string) => value + ' MB' ), networkChartConfigBuilder( - new FetchKubernetesMetricsAction( + buildPodMetricRequest( this.podName, this.kubeEndpointService.kubeGuid, `container_network_receive_bytes_total{pod="${this.podName}",namespace="${namespace}"}` diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts index 14b0551905..26e6259263 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts @@ -1,19 +1,19 @@ -import { Injectable, inject } from '@angular/core'; +import { Injectable, Injector, inject, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { AppState, Store } from '@stratosui/store'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { filter, map, publishReplay, refCount, take } from 'rxjs/operators'; import { getIdFromRoute } from '../../../../core/src/core/utils.service'; -import { MetricQueryConfig, MetricsAction } from '../../../../store/src/actions/metrics.actions'; -import { EntityMonitorFactory } from '../../../../store/src/monitors/entity-monitor.factory.service'; +import { MetricQueryConfig } from '../../../../store/src/actions/metrics.actions'; +import { MetricsDataService, MetricsRequest } from '../../../../store/src/services/metrics-data.service'; import { EntityInfo } from '../../../../store/src/types/api.types'; import { MetricQueryType } from '../../../../store/src/types/metric.types'; import { kubeEntityCatalog } from '../kubernetes-entity-generator'; import { KubernetesNode, MetricStatistic } from '../store/kube.types'; -import { FetchKubernetesMetricsAction } from '../store/kubernetes.actions'; import { KubernetesEndpointService } from './kubernetes-endpoint.service'; +const KUBE_METRICS_BASE_URL = '/pp/v1/metrics/kubernetes'; export enum KubeNodeMetric { CPU = 'container_cpu_usage_seconds_total', @@ -26,8 +26,8 @@ export enum KubeNodeMetric { export class KubernetesNodeService { kubeEndpointService = inject(KubernetesEndpointService); activatedRoute = inject(ActivatedRoute); - store = inject>(Store); - entityMonitorFactory = inject(EntityMonitorFactory); + private metricsDataService = inject(MetricsDataService); + private injector = inject(Injector); public nodeName: string; public kubeGuid: string; @@ -60,23 +60,23 @@ export class KubernetesNodeService { public setupMetricObservable(metric: KubeNodeMetric, metricStatistic: MetricStatistic) { const containerFilter = ',container!="POD", container!=""'; const query = `${metricStatistic}(${metricStatistic}_over_time(${metric}{kubernetes_io_hostname="${this.nodeName}"${containerFilter}}[1h]))`; - const metricsAction = new FetchKubernetesMetricsAction(this.nodeName, this.kubeGuid, query); - const metricsId = MetricsAction.buildMetricKey(this.nodeName, new MetricQueryConfig(query), true, MetricQueryType.QUERY); - const metricsMonitor = this.entityMonitorFactory.create(metricsId, metricsAction); - this.store.dispatch(metricsAction); - const pollSub = metricsMonitor.poll(30000, () => this.store.dispatch(metricsAction), - request => ({ busy: request.fetching, error: request.error, message: request.message })) - .subscribe(); - return { - entity$: metricsMonitor.entity$.pipe(filter(metrics => !!metrics), map(metrics => { - const result = metrics.data && metrics.data.result; - if (!!result && result.length === 1) { - return result[0].value[1]; - } else { - return 0; - } - })), - pollerSub: pollSub + const request: MetricsRequest = { + endpointGuid: this.kubeGuid, + url: `${KUBE_METRICS_BASE_URL}/${this.nodeName}`, + query: new MetricQueryConfig(query), + queryType: MetricQueryType.QUERY, + windowValue: null, }; + const requestSignal = signal(request); + const observation = this.metricsDataService.observe(requestSignal, { pollIntervalMs: 30000 }); + const entity$ = toObservable(observation.metrics, { injector: this.injector }).pipe( + filter(metrics => !!metrics), + map(metrics => { + const result = metrics?.data?.result; + return result && result.length === 1 ? Number(result[0].value[1]) : 0; + }) + ); + const pollerSub: Subscription = { unsubscribe: () => observation.stop() } as Subscription; + return { entity$, pollerSub }; } } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts index 2f89f53fa1..d246c19078 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts @@ -3,7 +3,6 @@ export type SortDirection = 'asc' | 'desc' | ''; import { getActions } from '@stratosui/store'; import { ApiRequestTypes } from '@stratosui/store'; -import { MetricQueryConfig, MetricsAction, MetricsChartAction } from '../../../../store/src/actions/metrics.actions'; import { getPaginationKey } from '../../../../store/src/actions/pagination.actions'; import { PaginatedAction, PaginationParam } from '../../../../store/src/types/pagination.types'; import { EntityRequestAction } from '../../../../store/src/types/request.types'; @@ -345,33 +344,3 @@ export class GetKubernetesDashboard implements KubeSingleEntityAction { guid: string; } -function getKubeMetricsAction(guid: string): string { - return `${MetricsAction.getBaseMetricsURL()}/kubernetes/${guid}`; -} - -export class FetchKubernetesMetricsAction extends MetricsAction { - constructor(guid: string, cfGuid: string, metricQuery: string) { - super( - guid, - cfGuid, - new MetricQueryConfig(metricQuery), - getKubeMetricsAction(guid), - undefined, - undefined, - undefined, - KUBERNETES_ENDPOINT_TYPE - ); - } -} - -export class FetchKubernetesChartMetricsAction extends MetricsChartAction { - constructor(guid: string, cfGuid: string, metricQuery: string) { - super( - guid, - cfGuid, - new MetricQueryConfig(metricQuery), - getKubeMetricsAction(guid), - KUBERNETES_ENDPOINT_TYPE - ); - } -} diff --git a/src/frontend/packages/store/src/actions/metrics.actions.ts b/src/frontend/packages/store/src/actions/metrics.actions.ts index ac62831049..3ad7482a35 100644 --- a/src/frontend/packages/store/src/actions/metrics.actions.ts +++ b/src/frontend/packages/store/src/actions/metrics.actions.ts @@ -1,12 +1,3 @@ -import { metricEntityType } from '../helpers/stratos-entity-factory'; -import { proxyAPIVersion } from '../jetstream'; -import { MetricQueryType } from '../types/metric.types'; -import { EntityRequestAction } from '../types/request.types'; - -export const METRICS_START = '[Metrics] Fetch Start'; -export const METRICS_START_SUCCESS = '[Metrics] Fetch Succeeded'; -export const METRICS_START_FAILED = '[Metrics] Fetch Failed'; - export interface IMetricQueryConfigParams { window?: string; [key: string]: string | number; @@ -17,7 +8,7 @@ function joinParams(queryConfig: MetricQueryConfig) { window = '', ...params } = queryConfig.params || {}; - // If the query contains it's own curly brackets don't add a new set + // Skip the auto-injected `{}` when the raw metric already has its own label selectors. const hasSquiggly = queryConfig.metric.indexOf('}') >= 0; const windowString = window && !(params.start && params.end) ? `${(hasSquiggly ? '' : '{}')}[${window}]` : ''; const paramString = Object.keys(params).reduce((accum, key) => accum + `&${key}=${params[key]}`, ''); @@ -34,55 +25,3 @@ export class MetricQueryConfig { public params?: IMetricQueryConfigParams ) { } } - -// FIXME: Final solution for Metrics - STRAT-152 -export class MetricsAction implements EntityRequestAction { - constructor( - // FIXME: This is ignored in all cases - STRAT-152 - guid: string, - public endpointGuid: string, - public query: MetricQueryConfig, - public url: string, - public windowValue: string = null, - public queryType: MetricQueryType = MetricQueryType.QUERY, - isSeries = true, - public endpointType: string) { - this.guid = MetricsAction.buildMetricKey(guid, query, isSeries, queryType, windowValue); - } - public guid: string; - - entityType = metricEntityType; - type = METRICS_START; - directApi = false; - - static getBaseMetricsURL() { - return `/pp/${proxyAPIVersion}/metrics`; - } - - // Builds the key that is used to store the metric in the app state. - static buildMetricKey(guid: string, query: MetricQueryConfig, isSeries: boolean, queryType: MetricQueryType, windowValue: string = null) { - return `${guid}:${query.metric}:${isSeries ? 'series' : 'value'}:${queryType}:${windowValue ? windowValue : ''}`; - } -} - -export class MetricsChartAction extends MetricsAction { - constructor( - guid: string, - endpointGuid: string, - query: MetricQueryConfig, - url: string, - endpointType: string - ) { - super( - guid, - endpointGuid, - query, - url, - null, - MetricQueryType.RANGE_QUERY, - true, - endpointType - ); - } -} - diff --git a/src/frontend/packages/store/src/effects/metrics.effects.ts b/src/frontend/packages/store/src/effects/metrics.effects.ts deleted file mode 100644 index 8f5379f001..0000000000 --- a/src/frontend/packages/store/src/effects/metrics.effects.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { ApplicationRef, Injectable, inject } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { catchError, map, mergeMap } from 'rxjs/operators'; - -import { - METRIC_API_FAILED, - METRIC_API_START, - MetricsAPIAction, - MetricsAPIActionSuccess, -} from '../actions/metrics-api.actions'; -import { getFullMetricQueryQuery, METRICS_START, MetricsAction } from '../actions/metrics.actions'; -import { DispatchOnlyAppState } from '../app-state'; -import { entityCatalog } from '../entity-catalog/entity-catalog'; -import { IMetricsResponse } from '../types/base-metric.types'; -import { StartRequestAction, WrapperRequestActionFailed, WrapperRequestActionSuccess } from './../types/request.types'; - -@Injectable({ - providedIn: 'root' -}) -export class MetricsEffect { - private actions$ = inject(Actions); - private httpClient = inject(HttpClient); - private store = inject>(Store); - private appRef = inject(ApplicationRef); - - - metrics$ = createEffect(() => this.actions$.pipe( - ofType(METRICS_START), - mergeMap((action: MetricsAction) => { - const fullUrl = action.directApi ? action.url : this.buildFullUrl(action); - const { guid } = action; - this.store.dispatch(new StartRequestAction(action)); - return this.httpClient.get<{ [cfguid: string]: IMetricsResponse }>(fullUrl, { - headers: { 'x-cap-cnsi-list': action.endpointGuid } - }).pipe( - map((metrics: { [cfguid: string]: IMetricsResponse }) => { - const catalogEntity = entityCatalog.getEntity(action); - const metric = metrics[action.endpointGuid]; - const metricObject = metric ? { - [guid]: { - query: action.query, - windowValue: action.windowValue, - data: metric.data - } - } : {}; - this.appRef.tick(); - return new WrapperRequestActionSuccess( - { - entities: { - [catalogEntity.entityKey]: metricObject - }, - result: [guid] - }, - action - ); - }) - ).pipe(catchError((errObservable: any) => { - this.appRef.tick(); - return [ - new WrapperRequestActionFailed( - errObservable.message, - action, - 'fetch', { - endpointIds: [action.endpointGuid], - url: errObservable.url || fullUrl, - eventCode: errObservable.status ? errObservable.status + '' : '500', - message: 'Metric request error', - } - ) - ]; - })); - }))); - - metricsAPI$ = createEffect(() => this.actions$.pipe( - ofType(METRIC_API_START), - mergeMap((action: MetricsAPIAction) => { - return this.httpClient.get<{ [cfguid: string]: IMetricsResponse }>(action.url, { - headers: { 'x-cap-cnsi-list': action.endpointGuid } - }).pipe( - map((metrics: { [cfguid: string]: IMetricsResponse }) => { - const metric = metrics[action.endpointGuid]; - this.appRef.tick(); - return new MetricsAPIActionSuccess(action.endpointGuid, metric, action.queryType); - }) - ).pipe(catchError((errObservable: any) => { - this.appRef.tick(); - return [ - { - type: METRIC_API_FAILED, - error: errObservable.message - } - ]; - })); - }))); - - private buildFullUrl(action: MetricsAction) { - return `${action.url}/${action.queryType}?query=${getFullMetricQueryQuery(action.query)}`; - } - -} - diff --git a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts index 1a3ea4f629..6ba8e3741e 100644 --- a/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts +++ b/src/frontend/packages/store/src/helpers/stratos-entity-factory.ts @@ -5,8 +5,6 @@ export const endpointEntityType = 'endpoint'; export const userProfileEntityType = 'userProfile'; export const systemInfoEntityType = 'systemInfo'; -export const metricEntityType = 'metrics'; - export const STRATOS_ENDPOINT_TYPE = 'stratos'; const entityCache: { @@ -31,9 +29,6 @@ entityCache[endpointEntityType] = EndpointSchema; const UserProfileInfoSchema = new StratosEntitySchema(userProfileEntityType, 'id'); entityCache[userProfileEntityType] = UserProfileInfoSchema; -const MetricSchema = new StratosEntitySchema(metricEntityType, 'guid'); -entityCache[metricEntityType] = MetricSchema; - export function stratosEntityFactory(key: string): EntitySchema { const entity = entityCache[key]; if (!entity) { diff --git a/src/frontend/packages/store/src/public-api.ts b/src/frontend/packages/store/src/public-api.ts index de9906d5bc..8d2b4d6f53 100644 --- a/src/frontend/packages/store/src/public-api.ts +++ b/src/frontend/packages/store/src/public-api.ts @@ -81,7 +81,6 @@ export { export { STRATOS_ENDPOINT_TYPE, endpointEntityType, - metricEntityType, stratosEntityFactory, userFavouritesEntityType, } from './helpers/stratos-entity-factory'; @@ -220,7 +219,7 @@ export { MetricResultTypes, } from './types/base-metric.types'; export { generateStratosEntities } from './stratos-entity-generator'; -export { MetricQueryConfig, MetricsAction } from './actions/metrics.actions'; +export { MetricQueryConfig } from './actions/metrics.actions'; export { defaultClientPaginationPageSize } from './reducers/pagination-reducer/pagination-reducer-reset-pagination'; export { appReducers } from './reducers.module'; export { EntityCatalogTestModule, EntityCatalogTestModuleManualStore, TEST_CATALOGUE_ENTITIES } from './entity-catalog-test.module'; @@ -259,6 +258,18 @@ export type { } from './services/endpoints-data.service'; export { EndpointDisconnectCleanupService } from './services/endpoint-disconnect-cleanup.service'; +// W-a1 — signal-native metrics data service. Replaces the V2 +// MetricsAction / EntityServiceFactory / getPaginationObservables paths +// for CF metric fetches. PR-A2 deletes cf-metrics.actions.ts + +// metrics.effects.ts once all consumers (chart component + range +// selectors + 4 call sites) migrate to this service. +export { MetricsDataService } from './services/metrics-data.service'; +export type { + MetricsFetchState, + MetricsObservation, + MetricsRequest, +} from './services/metrics-data.service'; + // W36-C Wave 1 — signal-native auth data service. Single bridge over the // legacy `auth` ngrx slice. Replaces direct `store.select(s => s.auth)` // reads in `AuthSignalService` and consolidates `VerifySession` / `RouterNav` diff --git a/src/frontend/packages/store/src/services/metrics-data.service.ts b/src/frontend/packages/store/src/services/metrics-data.service.ts new file mode 100644 index 0000000000..a47314b09d --- /dev/null +++ b/src/frontend/packages/store/src/services/metrics-data.service.ts @@ -0,0 +1,143 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable, Injector, Signal, computed, effect, inject, runInInjectionContext, signal, untracked } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import { MetricQueryConfig, getFullMetricQueryQuery } from '../actions/metrics.actions'; +import { httpErrorResponseToSafeString } from '../jetstream'; +import { IMetrics, IMetricsData, IMetricsResponse } from '../types/base-metric.types'; +import { MetricQueryType } from '../types/metric.types'; + +// Shape that captures the V2 MetricsAction's HTTP-relevant payload without +// any ngrx/entity-catalog coupling. Consumers build a MetricsRequest, hand it +// to the service, and get back the resolved IMetrics. Replaces the legacy +// FetchXxxMetricsAction family (cf-metrics.actions.ts in cloud-foundry). +// +// URL convention matches metrics.effects.ts: +// `${url}/${queryType}?query=${getFullMetricQueryQuery(query)}` +// e.g. /pp/v1/metrics/cf/cells/query?query=firehose_value_metric_rep_garden_health_check_failed +export interface MetricsRequest { + endpointGuid: string; + url: string; + query: MetricQueryConfig; + queryType: MetricQueryType; + windowValue?: string | null; +} + +export interface MetricsFetchState { + metrics: IMetrics | null; + fetching: boolean; + error: string | null; +} + +const EMPTY_STATE: MetricsFetchState = { metrics: null, fetching: false, error: null }; + +export interface MetricsObservation { + metrics: Signal | null>; + fetching: Signal; + error: Signal; + refresh: () => Promise; + stop: () => void; +} + +@Injectable({ providedIn: 'root' }) +export class MetricsDataService { + private readonly http = inject(HttpClient); + private readonly injector = inject(Injector); + + // One-shot fetch. Mirrors metrics.effects.ts:metrics$ — same URL build, + // same x-cap-cnsi-list header, same response unwrap. Returns the IMetrics + // payload for the requested endpoint (null when Jetstream returns no + // entry for that endpoint). + async fetch(req: MetricsRequest): Promise | null> { + const fullUrl = `${req.url}/${req.queryType}?query=${getFullMetricQueryQuery(req.query)}`; + const response = await firstValueFrom( + this.http.get<{ [cfguid: string]: IMetricsResponse }>(fullUrl, { + headers: { 'x-cap-cnsi-list': req.endpointGuid }, + }) + ); + const entry = response?.[req.endpointGuid]; + if (!entry) { + return null; + } + // IMetricsResponse.data is declared as IMetrics in the type file but + // the runtime payload is the inner IMetricsData ({ resultType, result }). + // The legacy MetricsEffect performs the same unwrap implicitly. Cast at + // the boundary so downstream consumers see the correct shape. + return { + query: req.query, + windowValue: req.windowValue ?? null, + data: entry.data as unknown as IMetricsData, + }; + } + + // Signal-bound observation. Tracks `request()`; whenever the request + // signal changes, a new fetch fires and the metrics/fetching/error + // signals update. Optional poll interval re-fires on a timer. The + // returned `stop()` cancels the polling timer. + // + // Caller owns the lifetime — pass a DestroyRef-bound effect cleanup + // (or call stop() explicitly) when the consumer component tears down. + observe( + request: Signal, + options: { pollIntervalMs?: Signal | number } = {}, + ): MetricsObservation { + const state = signal>(EMPTY_STATE); + let inFlightToken = 0; + let timerId: ReturnType | null = null; + + const runFetch = async (req: MetricsRequest | null) => { + if (!req) { + state.set(EMPTY_STATE); + return; + } + const token = ++inFlightToken; + state.update(s => ({ ...s, fetching: true, error: null })); + try { + const result = await this.fetch(req); + if (token !== inFlightToken) return; + state.set({ metrics: result, fetching: false, error: null }); + } catch (err) { + if (token !== inFlightToken) return; + const message = err instanceof HttpErrorResponse + ? httpErrorResponseToSafeString(err) + : (err instanceof Error ? err.message : String(err)); + state.set({ metrics: null, fetching: false, error: message }); + } + }; + + // observe() may be called from inside an Angular lifecycle hook + // (e.g. ngOnInit), which is not itself an injection context. We + // anchor effect() to the service's captured injector so the + // signal -> fetch wire works regardless of caller context. + runInInjectionContext(this.injector, () => { + effect(() => { + const req = request(); + if (timerId !== null) { + clearInterval(timerId); + timerId = null; + } + untracked(() => { void runFetch(req); }); + const interval = typeof options.pollIntervalMs === 'number' + ? options.pollIntervalMs + : options.pollIntervalMs?.(); + if (interval && interval > 0 && req) { + timerId = setInterval(() => { void runFetch(request()); }, interval); + } + }); + }); + + return { + metrics: computed(() => state().metrics), + fetching: computed(() => state().fetching), + error: computed(() => state().error), + refresh: () => runFetch(request()), + stop: () => { + if (timerId !== null) { + clearInterval(timerId); + timerId = null; + } + inFlightToken++; + }, + }; + } +} diff --git a/src/frontend/packages/store/src/store.module.ts b/src/frontend/packages/store/src/store.module.ts index fef8d192b3..c2aa732848 100644 --- a/src/frontend/packages/store/src/store.module.ts +++ b/src/frontend/packages/store/src/store.module.ts @@ -5,7 +5,6 @@ import { EffectsModule } from '@ngrx/effects'; import { APIEffect } from './effects/api.effects'; import { AuthEffect } from './effects/auth.effects'; import { EndpointApiError } from './effects/endpoint-api-errors.effects'; -import { MetricsEffect } from './effects/metrics.effects'; import { PaginationEffects } from './effects/pagination.effects'; import { PermissionsEffects } from './effects/permissions.effect'; import { RecursiveDeleteEffect } from './effects/recursive-entity-delete.effect'; @@ -38,7 +37,6 @@ import { EndpointDisconnectCleanupService } from './services/endpoint-disconnect RouterEffect, SystemEffects, SetClientFilterEffect, - MetricsEffect, UserProfileEffect, RecursiveDeleteEffect, UserFavoritesEffect, diff --git a/src/jetstream/VERSION b/src/jetstream/VERSION index bbf8627c04..8438dc1689 100644 --- a/src/jetstream/VERSION +++ b/src/jetstream/VERSION @@ -1 +1 @@ -5.0.0-dev.94+build.20260519.1cfce0b572 +5.0.0-dev.98+build.20260523.e6f43bec13 diff --git a/src/jetstream/go.mod b/src/jetstream/go.mod index cd53e0a038..5e2f38a9ee 100644 --- a/src/jetstream/go.mod +++ b/src/jetstream/go.mod @@ -250,4 +250,4 @@ require ( // the fork's go.mod declaration (which still says fivetwenty-io/capi/v3) // during resolution. Retire this once the fix lands upstream in // fivetwenty-io/capi and a tagged release is cut. -replace github.com/fivetwenty-io/capi/v3 => github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.10 +replace github.com/fivetwenty-io/capi/v3 => github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.11 diff --git a/src/jetstream/go.sum b/src/jetstream/go.sum index e287d12dff..97b8ea434c 100644 --- a/src/jetstream/go.sum +++ b/src/jetstream/go.sum @@ -366,8 +366,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.10 h1:8aYmfA1xF+axJmB8c0KZUC2g6AGIhPqiVXrTyzY3t9w= -github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.10/go.mod h1:bm8z6scaTZH6h6cECafBYds/b2NiwhnNF/qvXTLXf8I= +github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.11 h1:56q2EoD8ApSpVo+zksz0fEloD/yepeBaD9z2Wu/kPos= +github.com/norman-abramovitz/fw-capi/v3 v3.216.4-fix-apps-delete.11/go.mod h1:bm8z6scaTZH6h6cECafBYds/b2NiwhnNF/qvXTLXf8I= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=