From b6d83e8b15eb169a00f0f95b2b27cb234729af14 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 20 May 2026 20:27:45 -0700 Subject: [PATCH 01/24] feat(store): add signal-native MetricsDataService (W-a1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce MetricsDataService — a signal-native HTTP client for CF metrics that replaces the V2 MetricsAction / EntityServiceFactory / EntityMonitor wire. Exposes a one-shot fetch(req) and a signal-bound observe(requestSignal, { pollIntervalMs }) that returns { metrics, fetching, error, refresh, stop } signals. Pattern-setter for B1 PR-A1: chart components + range selectors + 4 consumers move to MetricsRequest signals in subsequent commits of this wave. cf-metrics.actions.ts and metrics.effects.ts stay for now; PR-A2 deletes them once all V2 consumers are gone. --- src/frontend/packages/store/src/public-api.ts | 12 ++ .../src/services/metrics-data.service.ts | 145 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/frontend/packages/store/src/services/metrics-data.service.ts diff --git a/src/frontend/packages/store/src/public-api.ts b/src/frontend/packages/store/src/public-api.ts index de9906d5bc..8cdebde603 100644 --- a/src/frontend/packages/store/src/public-api.ts +++ b/src/frontend/packages/store/src/public-api.ts @@ -259,6 +259,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..8be3e529bd --- /dev/null +++ b/src/frontend/packages/store/src/services/metrics-data.service.ts @@ -0,0 +1,145 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { DestroyRef, Injectable, Signal, computed, effect, inject, 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); + + // 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 }); + } + }; + + effect(() => { + const req = request(); + // Restart polling on each request change. + 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); + } + }); + + inject(DestroyRef).onDestroy(() => { + if (timerId !== null) { + clearInterval(timerId); + timerId = null; + } + inFlightToken++; + }); + + 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++; + }, + }; + } +} From bdf33cbe4a36367b15d1d2b7cdb21f6761c4e5b3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Wed, 20 May 2026 22:03:36 -0700 Subject: [PATCH 02/24] feat(metrics): signal-native chart layer + 7 consumers (W-a2/W-a3) Refactor MetricsChartComponent, MetricsRangeSelectorComponent, MetricsParentRangeSelectorComponent and the range-selector services to consume Signal via MetricsDataService.observe() instead of dispatching MetricsAction objects through the V2 ngrx METRICS_START effect + EntityMonitor wire. Chart owns a writable requestSignal; ContentChild range selector emits a MetricsRequest output that the chart applies to the signal; parent range selector mutates each child chart's signal via the new chart.applyRequest() seam. Migrated 7 consumers off MetricsAction construction: - cf: metrics-tab, application-instance-chart, cloud-foundry-cell.service, cf-cell-apps-signal-config - k8s: pod-metrics, kubernetes-node-metrics, kubernetes-node-metrics-chart cloud-foundry-cell.service now picks the live health-metric (HEALTHY vs HEALTHY_DEP) by direct fetch-and-check instead of the CfCellHelper ngrx pagination probe. Also: - drop dead block from list.component (showCustomTime is set false everywhere; V2 list framework) - MetricsDataService.observe() anchors effect() to the service's captured injector via runInInjectionContext so callers from ngOnInit (not themselves in an injection context) work - update 2 inline-mocked specs (cloud-foundry-cell-charts/-summary) for the MetricsConfig.request field rename cf-metrics.actions.ts, metrics.effects.ts and kubernetes metrics actions are now dead code; PR-A2 deletes them. --- .../tabs/metrics-tab/metrics-tab.component.ts | 35 ++-- ...loud-foundry-cell-charts.component.spec.ts | 14 +- ...oud-foundry-cell-summary.component.spec.ts | 14 +- .../cloud-foundry-cell.service.ts | 113 +++++++------ .../application-instance-chart.component.ts | 19 ++- .../cf-cell-apps-signal-config.service.ts | 32 ++-- .../components/list/list.component.html | 11 -- .../shared/components/list/list.component.ts | 2 - .../metrics-chart.component.html | 8 +- .../metrics-chart/metrics-chart.component.ts | 151 +++++++----------- .../metrics-chart/metrics-chart.types.ts | 6 +- .../metrics.component.helpers.ts | 11 +- ...metrics-parent-range-selector.component.ts | 37 ++--- .../metrics-range-selector.component.ts | 48 ++---- .../metrics-range-selector-manager.service.ts | 63 ++------ .../metrics-range-selector.service.ts | 53 +++--- ...kubernetes-node-metrics-chart.component.ts | 22 ++- .../kubernetes-node-metrics.component.ts | 20 ++- .../pod-metrics/pod-metrics.component.ts | 24 ++- .../src/services/metrics-data.service.ts | 44 +++-- 20 files changed, 326 insertions(+), 401 deletions(-) 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/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..777bee43ba 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'; @@ -56,12 +55,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..cbcaf2baa3 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; @@ -71,9 +67,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 +84,18 @@ 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.healthyMetricId = `${this.cfGuid}-${this.cellId}:${metric}:value:${MetricQueryType.QUERY}:`; + return 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 +107,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 +125,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/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/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/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/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.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-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/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/store/src/services/metrics-data.service.ts b/src/frontend/packages/store/src/services/metrics-data.service.ts index 8be3e529bd..a47314b09d 100644 --- a/src/frontend/packages/store/src/services/metrics-data.service.ts +++ b/src/frontend/packages/store/src/services/metrics-data.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { DestroyRef, Injectable, Signal, computed, effect, inject, signal, untracked } from '@angular/core'; +import { Injectable, Injector, Signal, computed, effect, inject, runInInjectionContext, signal, untracked } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { MetricQueryConfig, getFullMetricQueryQuery } from '../actions/metrics.actions'; @@ -42,6 +42,7 @@ export interface MetricsObservation { @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 @@ -104,28 +105,25 @@ export class MetricsDataService { } }; - effect(() => { - const req = request(); - // Restart polling on each request change. - 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); - } - }); - - inject(DestroyRef).onDestroy(() => { - if (timerId !== null) { - clearInterval(timerId); - timerId = null; - } - inFlightToken++; + // 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 { From 987a9ea02778af3af8566f36026056fa49643794 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 00:09:00 -0700 Subject: [PATCH 03/24] chore(metrics): delete V2 ngrx metrics surface (W-a4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the chart layer + 7 consumers are off MetricsAction, strip the dead V2 wiring end-to-end: Live consumer migrations: - KubernetesNodeService.setupMetricObservable now drives the node stats cards via MetricsDataService.observe() with a 30s poll; drops MetricsAction + EntityMonitor + store.dispatch. - CloudFoundryCellBaseComponent's "wait for cell metric" gate is derived from cellMetric$ readiness (Observable) instead of LoadingPage's V2 entityId/entitySchema entity-monitor path. Effect + helper deletions: - cf-metrics.actions.ts (FetchCF*Metrics*, FetchApplication*Metrics*) - cf-cell.helpers.ts (CfCellHelper — pagination-based health-metric probe; replaced by direct fetch-and-check in cloud-foundry-cell .service) - cf-app-instances-metrics-action.ts helper - app.effects.ts:clearCellMetrics$ (post-scale-up immediate refresh; chart's 10s poll picks up the change instead — same end state ~10s later) - metrics.effects.ts (METRICS_START handler) + store.module wiring - FetchKubernetesMetricsAction + FetchKubernetesChartMetricsAction - MetricsAction + MetricsChartAction base classes + METRICS_START* constants in metrics.actions.ts (kept MetricQueryConfig and getFullMetricQueryQuery which MetricsRequest still uses) Entity-catalog cleanup — metricEntityType registrations are dead now that nothing dispatches MetricsAction: - stratos-entity-factory (const + base schema) - cf-entity-factory + cf-entity-generator.generateCFMetrics - kubernetes-entity-factory + kubernetes-entity-generator .generateMetricEntity - autoscaler-entity-factory + autoscaler-entity-generator .generateMetricEntity - public-api re-export V2 list-framework metrics path (verified dead — no consumer set showCustomTime=true): - drop metricsAction field + updateMetricsAction method from list-data-source + interface - drop showCustomTime / customTimeWindows / customTimeInitialValue / customTimePollingInterval / customTimeValidation from list config - drop handleTimeWindowChange hook Spec updates: - cell-base.spec: HttpClientTesting + override component-level CloudFoundryCellService provider with the existing mock - cell-summary.spec: drop healthyMetricId from mock - metrics-chart.spec: drop dead-code commented FetchApplication ref --- .../src/store/autoscaler-entity-factory.ts | 5 +- .../src/store/autoscaler-entity-generator.ts | 12 -- .../src/actions/cf-metrics.actions.ts | 101 ----------------- .../cloud-foundry/src/cf-entity-catalog.ts | 4 - .../cloud-foundry/src/cf-entity-factory.ts | 5 +- .../cloud-foundry/src/cf-entity-generator.ts | 17 --- .../src/cloud-foundry-test.module.ts | 2 - .../src/features/cf/cf-cell.helpers.ts | 74 ------------- .../cloud-foundry-cell-base.component.html | 2 +- .../cloud-foundry-cell-base.component.spec.ts | 22 ++-- .../cloud-foundry-cell-base.component.ts | 15 +-- ...oud-foundry-cell-summary.component.spec.ts | 1 - .../cloud-foundry-cell.service.ts | 6 +- .../cf-app-instances-metrics-action.ts | 11 -- .../list-types/base-cf/base-cf-list-config.ts | 1 - .../src/store/cloud-foundry.store.module.ts | 2 - .../src/store/effects/app.effects.ts | 46 -------- .../list-data-source-config.ts | 9 +- .../list-data-source-types.ts | 2 - .../list-data-source.ts | 14 +-- .../components/list/list.component.types.ts | 21 ---- .../metrics-chart.component.spec.ts | 9 -- .../kubernetes/kubernetes-entity-factory.ts | 3 - .../kubernetes/kubernetes-entity-generator.ts | 13 --- .../services/kubernetes-node.service.ts | 50 ++++----- .../kubernetes/store/kubernetes.actions.ts | 31 ------ .../store/src/actions/metrics.actions.ts | 63 +---------- .../store/src/effects/metrics.effects.ts | 103 ------------------ .../src/helpers/stratos-entity-factory.ts | 5 - src/frontend/packages/store/src/public-api.ts | 3 +- .../packages/store/src/store.module.ts | 2 - 31 files changed, 56 insertions(+), 598 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/actions/cf-metrics.actions.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/features/cf/cf-cell.helpers.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app-instance/cf-app-instances-metrics-action.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/store/effects/app.effects.ts delete mode 100644 src/frontend/packages/store/src/effects/metrics.effects.ts 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/cf-entity-catalog.ts b/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts index a79eb604a0..fad1faad7b 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-catalog.ts @@ -206,10 +206,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..22c6684f0e 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, @@ -92,9 +92,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..12da908841 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, @@ -443,7 +442,6 @@ export function generateCFEntities(): StratosBaseCatalogEntity[] { generateCFAppSummaryEntity(endpointDefinition), generateCFAppEnvVarEntity(endpointDefinition), generateCFQuotaDefinitionEntity(endpointDefinition), - generateCFMetrics(endpointDefinition) ]; } @@ -1422,18 +1420,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/cloud-foundry-test.module.ts b/src/frontend/packages/cloud-foundry/src/cloud-foundry-test.module.ts index 622d2d9ac4..f07ee4a931 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,7 +13,6 @@ 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'; @@ -30,7 +29,6 @@ import { UsersRolesEffects } from './store/effects/users-roles.effects'; DeployAppEffects, CloudFoundryEffects, ServiceInstanceEffects, - AppEffects, UpdateAppEffects, CfValidateEffects, UsersRolesEffects 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/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-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 777bee43ba..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 @@ -37,7 +37,6 @@ class MockCloudFoundryCellService { cellMetric$ = observableOf(null); healthy$ = observableOf(null); - healthyMetricId = null; cpus$ = observableOf(null); usageContainers$ = observableOf(null); 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 cbcaf2baa3..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 @@ -50,7 +50,6 @@ export class CloudFoundryCellService { cellMetric$!: Observable; healthy$!: Observable; - healthyMetricId!: string; cpus$!: Observable; usageContainers$!: Observable; @@ -89,10 +88,7 @@ export class CloudFoundryCellService { // pagination probe but without the ngrx round-trip. const healthMetric$ = defer(() => from(this.probeHealthMetric())).pipe(shareReplay(1)); this.cellMetric$ = healthMetric$.pipe( - switchMap(metric => { - this.healthyMetricId = `${this.cfGuid}-${this.cellId}:${metric}:value:${MetricQueryType.QUERY}:`; - return this.generateForMetric>(metric, true) as Observable; - }) + switchMap(metric => this.generateForMetric>(metric, true) as Observable) ); this.healthy$ = healthMetric$.pipe( switchMap(metric => this.generate(metric)) 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/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/store/cloud-foundry.store.module.ts b/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts index 4fb66f22d5..3d27581aeb 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,7 +4,6 @@ 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'; @@ -20,7 +19,6 @@ import { CfEndpointRoleSyncService } from './services/cf-endpoint-role-sync.serv 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/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.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.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/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/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 8cdebde603..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'; 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, From 7b6e46dfb74925910a57e45074e7e3117ca2f232 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 00:42:58 -0700 Subject: [PATCH 04/24] refactor(apps): retire CfAppsDataSource via direct signal reads (W-b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apps-wall page is already signal-native (CfAppsSignalConfigService); the V2 CfAppsDataSource only survived because two stepper components still round-tripped through ngrx pagination state to discover the wall's currently-selected cf/org/space when the user navigated "+ Create Application" or "+ Deploy Application" from the wall. CfAppsSignalConfigService is providedIn: 'root', so its selectedCnsi / selectedOrg / selectedSpace signals persist across the route change. The steppers now read those signals directly: create-application: ngOnInit deploy-application: ngOnInit else-branch (no appGuid path) Drops: - cf-apps-data-source.ts (126 lines — the V2 data source that dispatched GetAllApplications + CreatePagination + per-row appStats fetches; entire chain is dead since the wall stopped using it) - paginationStateSub + selectCfPaginationState wire in create - entityKey + selectPaginationState wire in deploy Behavior preserved one-for-one: create-application keeps its `cf && org` / `cf && org && space` precondition gating; deploy keeps its independent cf/org/space assignments. --- .../create-application.component.ts | 43 +++--- .../deploy-application.component.ts | 39 +++--- .../list-types/app/cf-apps-data-source.ts | 126 ------------------ 3 files changed, 35 insertions(+), 173 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-data-source.ts 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/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); - } - -} From 883e9f8a0642573a2eb242aadcf8144d348cb9ad Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 01:09:51 -0700 Subject: [PATCH 05/24] refactor(cf-user): swap isConnectedUserAdmin selector (W-c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf-user.service.isConnectedUserAdmin was the only direct ngrx selector consumer in this file outside the V2 list-framework pagination cache path. Swap it for the existing signal-service facade (CfCurrentUserRolesSignalService.cfEndpointRolesState$), which returns the same role-state shape without touching Store. The wider `createPaginationAction` rewrite (line ~366, selectCfPaginationState) is deferred — it has 3 live consumers (cf-admin-add-user-warning, org-base, space-base) that each switch on the returned action shape, and the V2 "is-maxed" semantics don't translate cleanly to CfUsersSignalConfigService yet. --- .../src/shared/data-services/cf-user.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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) ); } From 3f9ea6cf37b7e3cfc467a9279adacf4e66d9427c Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 01:20:43 -0700 Subject: [PATCH 06/24] refactor(update-app-effects): retire V2 effect (W-d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete UpdateAppEffects and drop its registration from the cloud-foundry store + test modules. The effect listened for CF_APP_UPDATE_SUCCESS and dispatched fanout actions (env vars, stats, summary) so the V2 ngrx app cache stayed coherent after a mutation. No live consumer dispatches UpdateExistingApplication anymore; the edit-application step now calls AppDetailDataService.update() (PATCH /pp/v1/cf/apps/:cnsi/:guid) which already refreshes the app entity, and the component itself triggers detail.refresh('stats') after a scale. Env vars / summary are owned by the same signal-native service. The post-scale-up metrics refresh that lived in app.effects.ts:clearCellMetrics was already retired in W-a4, so the entire ngrx fanout is dead code. The CF_APP_UPDATE_SUCCESS action type and UpdateExistingApplication class are left in place — they remain referenced by the entity-catalog action-builder registration (cleanup belongs to a later wave that retires the application entity action surface). --- .../src/cloud-foundry-test.module.ts | 2 - .../src/store/cloud-foundry.store.module.ts | 2 - .../src/store/effects/update-app-effects.ts | 52 ------------------- 3 files changed, 56 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/store/effects/update-app-effects.ts 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 f07ee4a931..7db0ede12c 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 @@ -17,7 +17,6 @@ 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({ @@ -29,7 +28,6 @@ import { UsersRolesEffects } from './store/effects/users-roles.effects'; DeployAppEffects, CloudFoundryEffects, ServiceInstanceEffects, - UpdateAppEffects, CfValidateEffects, UsersRolesEffects ]), 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 3d27581aeb..d97e035de9 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 @@ -8,7 +8,6 @@ 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'; @@ -19,7 +18,6 @@ import { CfEndpointRoleSyncService } from './services/cf-endpoint-role-sync.serv DeployAppEffects, CloudFoundryEffects, ServiceInstanceEffects, - UpdateAppEffects, CfValidateEffects, UsersRolesEffects ]), 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; - }))); -} From 60f194c02aba35ef42a0974f7ad1d6052be96a7c Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 01:32:00 -0700 Subject: [PATCH 07/24] refactor(cf-info): swap effect for EndpointDataService fetcher (W-e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-e of the ngrx-removal sweep. cfInfo was the only handler in CloudFoundryEffects — the V2 action listener dispatched GetCFInfo, hit /pp/v1/cf/info/{guid}, and wrapped the response in the legacy WrapperRequestActionSuccess/Failed envelope for the request reducer. The signal-native CfInfoDataService already covers the same fetch (introduced in a prior wave) and is the live source for every visible consumer (CloudFoundryEndpointService.info$, card-cf-info, endpoint summary header). The effect was kept alive only by the EndpointHealthCheck callback in cf-entity-generator dispatching cfEntityCatalog.cfInfo.api.get(endpoint.guid). Migration: * Added CfInfoDataService.refresh() — bypasses the warm-cache short-circuit so the health-check pulse still produces a fresh HTTP fetch (load() would otherwise short-circuit forever once warm). In-flight dedup still applies. * Added cf-info-helper.ts with a module-level injector capture (same pattern as autoscaler-available.ts) so the static healthCheck lambda can reach CfInfoDataRegistry without an Angular injection context. * Wired setCfInfoHelperInjector(inject(Injector)) into the CloudFoundryPackageModule constructor. * Rewired the CF endpoint healthCheck callback to call refreshCfInfo(endpoint.guid) instead of dispatching GetCFInfo. Deletions (now fully orphaned): * store/effects/cloud-foundry.effects.ts (only effect in the file) * actions/cloud-foundry.actions.ts (GET_CF_INFO + GetCFInfo only) * entity-action-builders/cf-info.action-builders.ts * generateCFInfoEntity() and the cfEntityCatalog.cfInfo registration * cfInfoEntityType constant + CFInfoSchema entry * CloudFoundryEffects registrations from cloud-foundry.store.module and cloud-foundry-test.module. * cfInfoEntityType from public_api.ts (not referenced externally). The /pp/v1/cf/info/{cnsi} wire path is unchanged; the V3-only Stratos-native handler still returns the legacy ICfV2Info shape, just now consumed directly by CfInfoDataService instead of being dispatched through the request-pipeline reducer. --- .../src/actions/cloud-foundry.actions.ts | 12 --- .../cloud-foundry/src/cf-entity-catalog.ts | 8 -- .../cloud-foundry/src/cf-entity-factory.ts | 4 - .../cloud-foundry/src/cf-entity-generator.ts | 38 ++------ .../cloud-foundry/src/cf-entity-types.ts | 1 - .../src/cloud-foundry-package.module.ts | 13 ++- .../src/cloud-foundry-test.module.ts | 2 - .../cf-info.action-builders.ts | 10 --- .../packages/cloud-foundry/src/public_api.ts | 1 - .../endpoint-data/cf-info-data.service.ts | 10 +++ .../services/endpoint-data/cf-info-helper.ts | 25 ++++++ .../card-cf-info/card-cf-info.component.ts | 7 +- .../src/store/cloud-foundry.store.module.ts | 2 - .../store/effects/cloud-foundry.effects.ts | 89 ------------------- 14 files changed, 57 insertions(+), 165 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/actions/cloud-foundry.actions.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/entity-action-builders/cf-info.action-builders.ts create mode 100644 src/frontend/packages/cloud-foundry/src/services/endpoint-data/cf-info-helper.ts delete mode 100644 src/frontend/packages/cloud-foundry/src/store/effects/cloud-foundry.effects.ts 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 fad1faad7b..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, 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 22c6684f0e..d632b8d640 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-factory.ts @@ -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; 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 12da908841..3714162127 100644 --- a/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts +++ b/src/frontend/packages/cloud-foundry/src/cf-entity-generator.ts @@ -47,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, @@ -62,7 +63,6 @@ import { IApp, IAppSummary, IBuildpack, - ICfV2Info, IDomain, IFeatureFlag, IOrganization, @@ -83,7 +83,6 @@ import { appSummaryEntityType, buildpackEntityType, cfEventEntityType, - cfInfoEntityType, cfUserEntityType, domainEntityType, featureFlagEntityType, @@ -122,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 { @@ -287,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); @@ -436,7 +436,6 @@ export function generateCFEntities(): StratosBaseCatalogEntity[] { generateCFBuildPackEntity(endpointDefinition), generateCFAppStatsEntity(endpointDefinition), generateCFUserProvidedServiceInstanceEntity(endpointDefinition), - generateCFInfoEntity(endpointDefinition), generateCFPrivateDomainEntity(endpointDefinition), generateCFSpaceQuotaEntity(endpointDefinition), generateCFAppSummaryEntity(endpointDefinition), @@ -606,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, 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 7db0ede12c..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,7 +13,6 @@ 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 { 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'; @@ -26,7 +25,6 @@ import { UsersRolesEffects } from './store/effects/users-roles.effects'; EffectsModule.forRoot([]), EffectsModule.forFeature([ DeployAppEffects, - CloudFoundryEffects, ServiceInstanceEffects, 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/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/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/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/store/cloud-foundry.store.module.ts b/src/frontend/packages/cloud-foundry/src/store/cloud-foundry.store.module.ts index d97e035de9..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,7 +4,6 @@ import { GitPackageModule } from '@stratosui/git'; import { ActiveRouteCfOrgSpace } from '../features/cf/cf-page.types'; import { CloudFoundryReducersModule } from './cloud-foundry.reducers.module'; -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'; @@ -16,7 +15,6 @@ import { CfEndpointRoleSyncService } from './services/cf-endpoint-role-sync.serv CloudFoundryReducersModule, EffectsModule.forFeature([ DeployAppEffects, - CloudFoundryEffects, ServiceInstanceEffects, CfValidateEffects, UsersRolesEffects 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 - }) - ]; - }) - ); - }) - )); -} From 8d9159d78da7eae649da4d0a44a6feae6f92fde5 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 01:28:29 -0700 Subject: [PATCH 08/24] refactor(endpoint-data.shim): retire V2 org+space paths (W-f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the WrapperRequestActionSuccess dispatch paths for orgs and spaces from EndpointDataShim. Earlier waves retired every consumer of the pagination state the shim used to populate: - cfEntityCatalog.org/space.actions.getMultiple readers are gone; cloudfoundry-endpoint.service.orgs$ now sources from EndpointDataService.orgs() (W-b path). - CfOrgSpaceDataService fetches orgs/spaces directly via HTTP into its own signal state rather than reading the entity catalog pagination store. - selectCfEntity(organizationEntityType / spaceEntityType, guid) reads by bare guid, while the shim wrote under FWT-934 composite keys (cnsiGuid:guid), so even single-entity selectors didn't pick up the shim's writes. What was removed: - dispatchOrgs(): action = cfEntityCatalog.org.actions.getMultiple + WrapperRequestActionSuccess under paginationKey 'endpoint-{cnsi}'. - dispatchSpaces(): action = cfEntityCatalog.space.actions.getMultiple + WrapperRequestActionSuccess under paginationKey 'spaces-bulk-{cnsi}'. - detectCollisions(): store.select probe of state.request[entityKey] to count entity-key-collision-avoided events; its observability value was tied to the write path being live. - Inline toOrgResource/toSpaceResource builders (StOrg->APIResource envelope is now only needed by st-org-adapter.ts for the cloudfoundry-endpoint.service bridge — kept there, not duplicated). What stays in place: - write() still emits entity-size-sample diagnostics per org and per space, now sampling the StOrg/StSpace payload directly instead of the APIResource envelope. - EndpointDataService.loadDetails().finalize() still calls shim.write() — consumer-side removal is a separate scope. Spec updated to assert the new behavior: no dispatches, diagnostics samples only, apps still stay out of the shim. --- .../endpoint-data/endpoint-data.shim.spec.ts | 82 ++------ .../endpoint-data/endpoint-data.shim.ts | 189 ++---------------- 2 files changed, 41 insertions(+), 230 deletions(-) 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, - }, - }; - } } From 1b7997d5e7719df127f50cc32e61d7a8ac2ad1b9 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 01:51:45 -0700 Subject: [PATCH 09/24] test(cf-stratos): drop duplicate-url-collision spec post-W-f Tests 1-4 of duplicate-url-collision.spec.ts asserted on dispatched WrapperRequestActionSuccess actions emitted from EndpointDataShim.write(). W-f retired all such dispatches, so those assertions can no longer pass. Test 5 (cfEntityId composition) is redundant with cf-entity-ref.spec.ts. The integration/ directory is empty after removal. --- .../duplicate-url-collision.spec.ts | 218 ------------------ 1 file changed, 218 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/integration/duplicate-url-collision.spec.ts 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' })); - }); -}); From 9f5111706f0332431c8f8bd39ddc94edf2a059d3 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 21 May 2026 10:19:13 -0700 Subject: [PATCH 10/24] fix(endpoints): restore FWT-929 duplicate-URL warning on endpoints page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signal-list migration (28d183fea3, ef8cd15b22) rebuilt the Address column as plain text and dropped the warning icon FWT-929 had wired up (cfe9fb342f). TableCellEndpointAddressComponent — with its existing isDuplicate$ logic — is still in the tree, just orphaned. Restore it via the framework's existing kind:'template' mechanism plus a projected at the consumer site. The signal-list framework stays domain-agnostic; the endpoints page owns its cell choice. If a second consumer ever needs the same duplicate-URL cell, both share the component directly — no framework growth required. --- .../endpoints-signal-list.component.html | 6 +++++- .../endpoints-signal-list.component.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) 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..1118e4ccaa 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); @@ -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', }, From 11811979ed68c21038733fd61016e2284bc2e665 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:00:43 -0700 Subject: [PATCH 11/24] chore(jetstream): bump fw-capi to v3.216.4-fix-apps-delete.11 Picks up the Location-header parse fix extended across all 9 delete handlers (orgs / spaces / domains / users / security_groups / brokers / service_instances / route_bindings / buildpacks). Stratos issues the async-job delete envelope on those resources; without the upstream fix the 202 job ref was lost in the response wrapper, causing the client to spin without a terminal state. The `replace` directive still points at norman-abramovitz/fw-capi until the fix lands on the fivetwenty-io fork's tagged release. --- src/jetstream/go.mod | 2 +- src/jetstream/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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= From d7e65d89d77b82002030bf0ebd5462a329d967c9 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:02 -0700 Subject: [PATCH 12/24] feat(cloud-foundry): EDS staleness + cascade registry foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the mutation-refresh architecture. The post-Wave-3 signal migration left every mutation's "refresh the affected pages" path on ad-hoc per-page logic — silently broken in several cases (org delete not repainting, refresh button no-op, snackbar flash). Plumbs the generic foundation here so Phase 2 (source classes) and Phase 3 (SignalListConfig wiring) have something to dispatch into. EndpointDataService gains: - _orgsStale / _spacesStale / _appsStale / etc. signals + readonly accessors. The cache-predicate in loadDetails / loadOrgs / loadApps / loadSpaces now considers staleness alongside lastFetched, so the next read after a mutation re-fetches even when the cache is warm. - 14 patch helpers (removeOrg/addOrg/updateOrg + same for spaces / apps / SI; remove/addServiceCredentialBinding) so source classes can update the canonical cache atomically with each write. - markStale(entity), applyCascade(key), refreshOrgs/refreshApps/ refreshSpaces/refreshDetails — the entry points sources call after a mutation lands. cascade-registry.ts declares the cross-entity staleness rules in one typed map (org.delete → [spaces, apps, serviceInstances, bindings]; space.delete → [apps, SI, bindings]; etc.). The "what should this mutation invalidate" question becomes a one-line lookup instead of re-deriving it at every callsite. cascadeFor(key) is the only external entry — sources call applyCascade(key) which delegates here. DIAGNOSTIC_CODE_FAMILIES gains the 'cascade-apply' enum value so applyCascade emissions surface in the diagnostics tab. --- .../data-sources/cascade-registry.spec.ts | 41 ++++ .../services/data-sources/cascade-registry.ts | 80 ++++++ .../services/diagnostics/diagnostics.types.ts | 1 + .../endpoint-data.service.spec.ts | 206 ++++++++++++++++ .../endpoint-data/endpoint-data.service.ts | 232 +++++++++++++++++- 5 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.spec.ts create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cascade-registry.ts 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/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/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 From f65e6d4b543c47d14db9305508909946fa0b4470 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:14 -0700 Subject: [PATCH 13/24] feat(cloud-foundry): CnsiOrgsSource + CnsiSpacesSource (Phase 2.a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin mutation surfaces over /pp/v1/cf/organizations/{cnsi} and /pp/v1/cf/spaces/{cnsi}. Each source extends CnsiEntitySource and exposes create/update/delete that: 1. Issues the HTTP write (delete goes through writeWithJob so CF's async 202 → /v3/jobs/{guid} terminal state is honoured). 2. Patches its own _items via patchItems (so consumers reading the source's items() signal repaint immediately). 3. Calls into EndpointDataService's matching patch helper (add/ remove/update Org or Space), keeping the canonical cache aligned. 4. Fires applyCascade('org.delete' | 'org.create' | 'space.delete' | 'space.create' | ...) to mark dependent entities stale per the cascade-registry rules. These two were missing — orgs and spaces were the entities most often mutated by the user-facing flows (Add Org, Delete Org, Add Space, Delete Space), and the Wave-3 migration retired their ngrx effects without ever wiring a replacement repaint trigger. Sources here fill that gap. --- .../data-sources/cnsi-orgs-source.spec.ts | 67 +++++++++++++++++++ .../services/data-sources/cnsi-orgs-source.ts | 46 +++++++++++++ .../data-sources/cnsi-spaces-source.spec.ts | 56 ++++++++++++++++ .../data-sources/cnsi-spaces-source.ts | 42 ++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.spec.ts create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-orgs-source.ts create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.spec.ts create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-spaces-source.ts 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-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; + } +} From 2b9e0973e2be4c659b0cd066a72241b828c28d87 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:27 -0700 Subject: [PATCH 14/24] refactor(cloud-foundry): retrofit SI/SB/Routes/Apps sources (Phase 2.b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the four pre-existing mutation sources up to the contract that the new orgs / spaces sources established: CnsiServiceInstancesSource — gains create/update/delete; existing write path moves to writeWithJob; EDS patch + applyCascade fires on terminal state. CnsiServiceBindingsSource — retrofitted to use the canonical StServiceCredentialBinding from stratos-types (had a local StServiceBinding placeholder that didn't carry type / serviceInstance / createdAt). The new EDS addServiceCredentialBinding signature would not type-check against the placeholder. CnsiRoutesSource — delete now goes through writeWithJob + patchItems + EDS patch + applyCascade('route.delete') so the apps cascade-stale fires (app-detail routes lists were the silently-broken consumer). CnsiAppsSource — applyCascade calls added for app.delete, app.update, route.create. The eds ctor arg is optional so existing tests don't break. cnsi-routes-source.spec.ts + cnsi-service-bindings-source.spec.ts updated to assert the new contract (HttpResponse mock + writeWithJob flow). cnsi-service-instances-source.spec.ts is new. --- .../services/data-sources/cnsi-apps-source.ts | 25 ++++++ .../data-sources/cnsi-routes-source.spec.ts | 31 ++++++- .../data-sources/cnsi-routes-source.ts | 25 ++++++ .../cnsi-service-bindings-source.spec.ts | 39 +++++++-- .../cnsi-service-bindings-source.ts | 36 ++++++-- .../cnsi-service-instances-source.spec.ts | 85 +++++++++++++++++++ .../cnsi-service-instances-source.ts | 50 +++++++++++ 7 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 src/frontend/packages/cloud-foundry/src/services/data-sources/cnsi-service-instances-source.spec.ts 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..d2f859a30d 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,23 @@ 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 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 +54,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-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..b048b0c0a8 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,29 @@ 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 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'); + } } From 7b5a929631f1ffc818881adbe49bd95278a4c28e Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:37 -0700 Subject: [PATCH 15/24] refactor(cloud-foundry): wire Orgs/Spaces/Apps configs to sources (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the orgs / spaces / apps SignalListConfig services off their ad-hoc mutation paths and onto the new CnsiOrgsSource / CnsiSpacesSource contracts. Each config now: - Instantiates the source once per initialize() (sharing EDS via the registry), caching it for subsequent mutations on the same CF. - Routes delete (and create/update where exposed) through the source, so the cascade fires once and EDS patches itself. - refresh() now calls EDS.refreshOrgs / refreshApps / refreshSpaces (which bypass the cache predicate) instead of loadDetails — which short-circuited on cache hit, leaving the user-facing refresh button a no-op. CloudFoundryEndpointService.fetchApps() flips from loadDetails to refreshDetails for the same reason — fetchApps is the "polling indicator clicked / page-level refresh" entry point. --- .../cloud-foundry-endpoint.service.ts | 11 ++++--- .../app/cf-apps-signal-config.service.ts | 29 ++++++++++++------- .../org/cf-orgs-signal-config.service.ts | 27 ++++++++++------- .../space/cf-spaces-signal-config.service.ts | 10 ++++--- 4 files changed, 46 insertions(+), 31 deletions(-) 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/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/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..56cce0c387 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,14 @@ 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); } } 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); } /** From 81f03ce2f2aaaaf83c694f7ddd42673d2e832834 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:45 -0700 Subject: [PATCH 16/24] fix(refresh): bind real loading signal into endpoints + k8s page toolbars isAnyLoading on both toolbars was wired to a hardcoded signal(false), so the refresh button's spinner never engaged on either page. Pipe in the real EndpointsDataService.loading signal so clicking refresh shows the spinner while getAll() drains, matching the cf-services / cf-apps behaviour. The endpoints-page config exposes the loading signal via a `loading` readonly accessor; the k8s-endpoints config reads its EndpointsDataService directly (already injected for the endpoints projection). Same change shape so the two pages stay in sync. --- .../endpoints-page/endpoints-signal-config.service.ts | 1 + .../endpoints-page/endpoints-signal-list.component.ts | 2 +- .../kubernetes-endpoints-signal-config.service.ts | 7 +++---- 3 files changed, 5 insertions(+), 5 deletions(-) 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.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-list.component.ts index 1118e4ccaa..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 @@ -160,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: [ { 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, From 724f52fca877c42259597493f2f095430c742f6f Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:01:50 -0700 Subject: [PATCH 17/24] chore(cf-summary): swap CLI Info icon to material-icons terminal The page-sub-nav CLI Info button rendered a `keyboard` glyph, which reads as "key input" rather than "open a terminal session." Material Icons' `terminal` glyph is the conventional choice for CLI affordances. One-line cosmetic. --- .../cf-summary-tab/cloud-foundry-summary-tab.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ Date: Fri, 22 May 2026 19:02:16 -0700 Subject: [PATCH 18/24] feat(cf-tabs): Org + Space filter dropdowns on Routes/Services/Users/Events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps wall already exposed CF / Org / Space dropdowns; the rest of the CF navs that have org/space membership had no parallel — users couldn't narrow Routes / Services / Users / Events without leaving the page. Each affected config service gains: - selectedOrg / selectedSpace WritableSignals (null = "All"). - orgOptions / spaceOptions computed lists, sorted natural-order (numeric-aware), "All" first. - A filter-effect clause that drops rows whose org/space don't match. - A cascade effect that clears selectedSpace when selectedOrg switches to a different specific org (the stale space is no longer reachable through that org). Org → All preserves the space selection. Space dropdown labels are cascade-aware: - Org selected → label is the space name on its own. - Org = All → label is " - " so the picker disambiguates identical space names across orgs (typical CF naming has many "dev" / "prod" spaces). Sort order: space name natural-sort, then org name natural-sort within the collision group. Per-page consumers (cf-services, cf-users, services-wall, cf-routes, cf-events-list) get the new dropdowns wired into their SignalListConfig filterDropdowns slot. cf-events-list conditionally renders them only on the foundation-wide page — the per-org / per-space / per-app sub-pages pin scope via basePredicate and elect to omit dropdowns that would let the user pick mismatched values. Service-instances config is multi-CNSI, so the new dropdowns union orgs / spaces across the in-scope CNSIs (or narrow to the selected CF when the CF dropdown is locked, as on per-CF tabs). EDS acquisition inside computed()s would refcount-leak, so initialize / initializeForX swap a tracked _edsByCnsi map through a refcount-balanced helper. Specs updated to mirror the new stub shape: services-wall expects 3 dropdowns instead of 1; cf-users stub exposes orgOptions/spaceOptions/ selectedOrg/selectedSpace; cf-service-instances spec's FakeDs gains orgs()/spaces() and the registry mock gains release(). --- .../cloud-foundry-routes-signal.component.ts | 5 + ...cloud-foundry-services-signal.component.ts | 10 + .../cloud-foundry-users.component.spec.ts | 4 + .../cf-users/cloud-foundry-users.component.ts | 13 ++ .../services-wall.component.spec.ts | 10 +- .../services-wall/services-wall.component.ts | 10 + .../cloud-foundry-events-list.component.ts | 47 ++-- .../cf-audit-events-signal-config.service.ts | 109 ++++++++- .../route/cf-routes-signal-config.service.ts | 77 ++++++- ...ce-instances-signal-config.service.spec.ts | 7 + ...service-instances-signal-config.service.ts | 211 ++++++++++++++++-- .../user/cf-users-signal-config.service.ts | 92 ++++++++ 12 files changed, 549 insertions(+), 46 deletions(-) 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-users/cloud-foundry-users.component.spec.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts index 16550a1f10..1184c0b5f5 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-users/cloud-foundry-users.component.spec.ts @@ -38,9 +38,13 @@ function makeStubSignalConfigService(opts?: { pageIndex, view, nameFilter: signal(''), + selectedOrg: signal(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/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/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/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/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/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); } From 7846e2afb1538cdd1527261bd9e5d2d99749f545 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:02:22 -0700 Subject: [PATCH 19/24] chore: bump dev.96 for adepttech deploy Drops the four-tab Org+Space filter bundle + Phase 1-3 mutation refresh + fw-capi .11 onto adepttech for browser verification. --- package.json | 2 +- src/jetstream/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a7406d78e2..379aaf834d 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.96+build.20260522.9f5111706f", "type": "module", "description": "Stratos Console", "main": "index.js", diff --git a/src/jetstream/VERSION b/src/jetstream/VERSION index bbf8627c04..40df70dfdb 100644 --- a/src/jetstream/VERSION +++ b/src/jetstream/VERSION @@ -1 +1 @@ -5.0.0-dev.94+build.20260519.1cfce0b572 +5.0.0-dev.96+build.20260522.9f5111706f From 91809308ada5980aba9c8fc59cc9d06a7eebee08 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 19:45:15 -0700 Subject: [PATCH 20/24] chore: bump dev.97 for delete-flow verification deploy Adds the all-orgs "space - org" labels + natural sort iteration on top of dev.96 so the deployed environment matches the working tree. --- package.json | 2 +- src/jetstream/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 379aaf834d..a2f67d44a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stratos", - "version": "v5.0.0-dev.96+build.20260522.9f5111706f", + "version": "v5.0.0-dev.97+build.20260523.7846e2afb1", "type": "module", "description": "Stratos Console", "main": "index.js", diff --git a/src/jetstream/VERSION b/src/jetstream/VERSION index 40df70dfdb..9d2d2c33fd 100644 --- a/src/jetstream/VERSION +++ b/src/jetstream/VERSION @@ -1 +1 @@ -5.0.0-dev.96+build.20260522.9f5111706f +5.0.0-dev.97+build.20260523.7846e2afb1 From 4d024d9561861cf83c3f468d97c12738dd7aab6a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 23:06:35 -0700 Subject: [PATCH 21/24] feat(cloud-foundry): CnsiAppsSource + CnsiRoutesSource create() Add create() to both sources so consumers can route app and route mutations through the canonical EDS-patching path instead of raw http.post. Each call patches the source items list, calls the matching EDS patch helper, and fires the cascade verb (app.create or route.create) so dependent surfaces invalidate. --- .../src/services/data-sources/cnsi-apps-source.ts | 8 ++++++++ .../src/services/data-sources/cnsi-routes-source.ts | 7 +++++++ 2 files changed, 15 insertions(+) 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 d2f859a30d..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 @@ -40,6 +40,14 @@ export class CnsiAppsSource extends CnsiEntitySource { 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)); 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 b048b0c0a8..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 @@ -34,6 +34,13 @@ export class CnsiRoutesSource extends CnsiEntitySource { 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 From 1276186fc7db3aaa3956017fda09494875474455 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 23:07:00 -0700 Subject: [PATCH 22/24] refactor(cloud-foundry): route 11 mutation steppers through source classes Migrate every remaining direct http.post/patch/delete that targeted an EDS-cached entity to its CnsiXSource so the canonical list cache patches in place and the matching cascade verb fires. Eleven steppers + a per-app detail-page PATCH now share the source-routing pattern. - create-organization-step : http.post -> CnsiOrgsSource.create - edit-organization-step : OrgWriteService.updateOrg -> CnsiOrgsSource.update - create-space-step : http.post -> CnsiSpacesSource.create - edit-space-step (name + SSH) : http.patch -> CnsiSpacesSource.update - edit-space-step (quota detach) : keeps raw http but fires space.update cascade - edit-space-step (quota attach) : keeps raw http but fires space.update cascade - create-application-step3 (app) : http.post -> CnsiAppsSource.create - create-application-step3 (route) : http.post -> CnsiRoutesSource.create - app-detail-data.update : http.patch -> CnsiAppsSource.update - detach-service-instance.detachOne : http.delete -> CnsiServiceBindingsSource.delete Steppers now acquire/release the EndpointDataService on the registry so the cache is alive for the duration of the write; for create-application two acquires (app + route) are reference-counted and released on destroy. Drops two dead routes that were already bypassed by the new sources: OrgWriteService (no remaining callers) and the unused deleteSpace on CloudFoundryOrganizationService (callers go through CfSpacesSignalConfig). --- .../applications/app-detail-data.service.ts | 15 +++++-- .../create-application-step3.component.ts | 42 +++++++++++++++---- .../create-organization-step.component.ts | 26 +++++++++--- .../create-space-step.component.ts | 27 +++++++----- .../edit-organization-step.component.ts | 21 ++++++++-- .../edit-space-step.component.ts | 29 +++++++++---- .../cloud-foundry-organization.service.ts | 9 ---- .../detach-service-instance.component.ts | 29 +++++++++---- .../endpoint-data/org-write.service.ts | 32 -------------- .../org/cf-orgs-signal-config.service.ts | 14 +++++++ 10 files changed, 155 insertions(+), 89 deletions(-) delete mode 100644 src/frontend/packages/cloud-foundry/src/services/endpoint-data/org-write.service.ts 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/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/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/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-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/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/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/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 56cce0c387..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 @@ -149,4 +149,18 @@ export class CfOrgsSignalConfigService { } 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); + } } From e6f43bec13d06abf813a9fa507a2a0327a0872b4 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 23:07:08 -0700 Subject: [PATCH 23/24] fix(quotas): wire refresh-spin animation on org + space quota pages isAnyLoading on both quota tabs was tied to !hasLoadedOnce() so the refresh-button spinner engaged only on the very first load. Add a dedicated `loading` signal to each signal-config service that flips true around load/refresh and read it directly from the components. Matches the endpoints + k8s page fix already shipped this branch. --- ...dry-organization-space-quotas.component.ts | 4 +-- .../cloud-foundry-quotas.component.ts | 4 +-- .../cf-org-quotas-signal-config.service.ts | 28 +++++++++++++++---- .../cf-space-quotas-signal-config.service.ts | 28 +++++++++++++++---- 4 files changed, 48 insertions(+), 16 deletions(-) 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/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 { From 244c21fca6db102d27027ff272d284efdd95f556 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Fri, 22 May 2026 23:30:41 -0700 Subject: [PATCH 24/24] chore: bump dev.98 for source-routing + quota-spin deploy --- package.json | 2 +- src/jetstream/VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a2f67d44a6..fd81c521db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stratos", - "version": "v5.0.0-dev.97+build.20260523.7846e2afb1", + "version": "v5.0.0-dev.98+build.20260523.e6f43bec13", "type": "module", "description": "Stratos Console", "main": "index.js", diff --git a/src/jetstream/VERSION b/src/jetstream/VERSION index 9d2d2c33fd..8438dc1689 100644 --- a/src/jetstream/VERSION +++ b/src/jetstream/VERSION @@ -1 +1 @@ -5.0.0-dev.97+build.20260523.7846e2afb1 +5.0.0-dev.98+build.20260523.e6f43bec13