Fix release health multi-scope evidence contracts
This commit is contained in:
@@ -25,13 +25,15 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
|
||||
params = params.set('regions', regions.join(','));
|
||||
params = params.set('region', regions[0]);
|
||||
const regionFilter = regions.join(',');
|
||||
params = params.set('regions', regionFilter);
|
||||
params = params.set('region', regionFilter);
|
||||
}
|
||||
|
||||
if (environments.length > 0 && !params.has('environments') && !params.has('environment')) {
|
||||
params = params.set('environments', environments.join(','));
|
||||
params = params.set('environment', environments[0]);
|
||||
const environmentFilter = environments.join(',');
|
||||
params = params.set('environments', environmentFilter);
|
||||
params = params.set('environment', environmentFilter);
|
||||
}
|
||||
|
||||
if (timeWindow && !params.has('timeWindow')) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { EnvironmentPosturePageComponent } from '../../features/topology/environment-posture-page.component';
|
||||
|
||||
describe('EnvironmentPosturePageComponent', () => {
|
||||
const paramMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
paramMap$.next(convertToParamMap({}));
|
||||
queryParamMap$.next(convertToParamMap({}));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EnvironmentPosturePageComponent],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMap$.asObservable(),
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
initialize: () => undefined,
|
||||
regionSummary: () => '4 regions',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('falls back to synced query environment context when the route has no environmentId param', () => {
|
||||
queryParamMap$.next(convertToParamMap({ environments: 'dev' }));
|
||||
|
||||
const fixture = TestBed.createComponent(EnvironmentPosturePageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
const inventoryReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/topology/environments' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const runsReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/releases/activity' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const findingsReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/security/findings' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const capsulesReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/evidence/packs' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
|
||||
inventoryReq.flush({
|
||||
items: [
|
||||
{
|
||||
environmentId: 'dev',
|
||||
displayName: 'Development',
|
||||
regionId: 'us-east',
|
||||
status: 'healthy',
|
||||
},
|
||||
{
|
||||
environmentId: 'dev',
|
||||
displayName: 'Development',
|
||||
regionId: 'eu-west',
|
||||
status: 'healthy',
|
||||
},
|
||||
],
|
||||
});
|
||||
runsReq.flush({ items: [] });
|
||||
findingsReq.flush({ items: [] });
|
||||
capsulesReq.flush({ items: [] });
|
||||
|
||||
expect(component.environmentId()).toBe('dev');
|
||||
expect(component.environmentLabel()).toBe('Development');
|
||||
expect(component.regionLabel()).toBe('4 regions');
|
||||
expect(component.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an explicit guidance message when no environment context is available', () => {
|
||||
const fixture = TestBed.createComponent(EnvironmentPosturePageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.environmentId()).toBe('');
|
||||
expect(component.error()).toBe('Select an environment from Mission Control or Topology to view release health.');
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.runRows()).toEqual([]);
|
||||
expect(component.findingRows()).toEqual([]);
|
||||
expect(component.capsuleRows()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HTTP_INTERCEPTORS, HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { GlobalContextHttpInterceptor } from '../context/global-context-http.interceptor';
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
|
||||
describe('GlobalContextHttpInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: GlobalContextHttpInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
tenantId: () => 'demo-prod',
|
||||
selectedRegions: () => ['apac', 'eu-west', 'us-east', 'us-west'],
|
||||
selectedEnvironments: () => ['dev', 'stage'],
|
||||
timeWindow: () => '24h',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('propagates comma-delimited region and environment scope instead of collapsing to the first selection', () => {
|
||||
http.get('/api/v2/releases/activity').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/releases/activity?tenant=demo-prod&tenantId=demo-prod®ions=apac,eu-west,us-east,us-west®ion=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h');
|
||||
request.flush({ items: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { combineLatest, forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, take } from 'rxjs/operators';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { summarizeEnvironmentScope } from './environment-scope-summary';
|
||||
|
||||
interface PlatformListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
@@ -12,6 +15,7 @@ interface EnvironmentInventoryRow {
|
||||
environmentId: string;
|
||||
displayName: string;
|
||||
regionId: string;
|
||||
environmentType?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -98,8 +102,12 @@ interface EvidenceCapsuleRow {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EnvironmentPosturePageComponent {
|
||||
private static readonly MissingEnvironmentMessage =
|
||||
'Select an environment from Mission Control or Topology to view release health.';
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -147,12 +155,30 @@ export class EnvironmentPosturePageComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const id = params.get('environmentId') ?? '';
|
||||
this.environmentId.set(id);
|
||||
if (id) {
|
||||
this.reload(id);
|
||||
this.context.initialize();
|
||||
|
||||
combineLatest([this.route.paramMap, this.route.queryParamMap]).subscribe(([params, queryParams]) => {
|
||||
const id = this.resolveEnvironmentId(
|
||||
params.get('environmentId'),
|
||||
queryParams.get('environment'),
|
||||
queryParams.get('environments'),
|
||||
queryParams.get('env'),
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
this.environmentId.set('');
|
||||
this.environmentLabel.set('Environment');
|
||||
this.regionLabel.set('region');
|
||||
this.runRows.set([]);
|
||||
this.findingRows.set([]);
|
||||
this.capsuleRows.set([]);
|
||||
this.loading.set(false);
|
||||
this.error.set(EnvironmentPosturePageComponent.MissingEnvironmentMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.environmentId.set(id);
|
||||
this.reload(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,12 +186,12 @@ export class EnvironmentPosturePageComponent {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const envParams = new HttpParams().set('limit', '1').set('offset', '0').set('environment', environmentId);
|
||||
const envParams = new HttpParams().set('limit', '200').set('offset', '0').set('environment', environmentId);
|
||||
const inventory$ = this.http
|
||||
.get<PlatformListResponse<EnvironmentInventoryRow>>('/api/v2/topology/environments', { params: envParams })
|
||||
.pipe(
|
||||
map((response) => response.items?.[0] ?? null),
|
||||
catchError(() => of(null)),
|
||||
map((response) => response.items ?? []),
|
||||
catchError(() => of([] as EnvironmentInventoryRow[])),
|
||||
);
|
||||
|
||||
const runs$ = this.http
|
||||
@@ -193,8 +219,14 @@ export class EnvironmentPosturePageComponent {
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: ({ inventory, runs, findings, capsules }) => {
|
||||
this.environmentLabel.set(inventory?.displayName ?? environmentId);
|
||||
this.regionLabel.set(inventory?.regionId ?? 'unknown-region');
|
||||
const scopeSummary = summarizeEnvironmentScope(
|
||||
inventory,
|
||||
environmentId,
|
||||
this.resolveScopeRegionLabel(),
|
||||
);
|
||||
|
||||
this.environmentLabel.set(scopeSummary.environmentLabel);
|
||||
this.regionLabel.set(scopeSummary.regionLabel);
|
||||
this.runRows.set(runs);
|
||||
this.findingRows.set(findings);
|
||||
this.capsuleRows.set(capsules);
|
||||
@@ -209,6 +241,40 @@ export class EnvironmentPosturePageComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveEnvironmentId(
|
||||
routeEnvironmentId: string | null,
|
||||
queryEnvironmentId: string | null,
|
||||
queryEnvironments: string | null,
|
||||
legacyEnvironmentId: string | null,
|
||||
): string {
|
||||
const candidate =
|
||||
routeEnvironmentId
|
||||
?? queryEnvironmentId
|
||||
?? this.parseFirstEnvironment(queryEnvironments)
|
||||
?? legacyEnvironmentId
|
||||
?? '';
|
||||
|
||||
return candidate.trim();
|
||||
}
|
||||
|
||||
private parseFirstEnvironment(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
|
||||
return first ?? null;
|
||||
}
|
||||
|
||||
private resolveScopeRegionLabel(): string {
|
||||
const summary = this.context.regionSummary();
|
||||
return summary.trim().length > 0 ? summary : 'current scope';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface EnvironmentScopeSummarySource {
|
||||
displayName?: string | null;
|
||||
regionId?: string | null;
|
||||
environmentType?: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentScopeSummary {
|
||||
environmentLabel: string;
|
||||
regionLabel: string;
|
||||
environmentTypeLabel: string;
|
||||
hasMatch: boolean;
|
||||
}
|
||||
|
||||
export function summarizeEnvironmentScope(
|
||||
rows: readonly EnvironmentScopeSummarySource[],
|
||||
fallbackEnvironmentLabel: string,
|
||||
fallbackRegionLabel: string,
|
||||
fallbackEnvironmentTypeLabel = 'unknown-type',
|
||||
): EnvironmentScopeSummary {
|
||||
const displayNames = uniqueNonEmpty(rows.map((row) => row.displayName));
|
||||
const regions = uniqueNonEmpty(rows.map((row) => row.regionId));
|
||||
const environmentTypes = uniqueNonEmpty(rows.map((row) => row.environmentType));
|
||||
|
||||
return {
|
||||
environmentLabel: displayNames.length === 1 ? displayNames[0] : fallbackEnvironmentLabel,
|
||||
regionLabel: regions.length === 1 ? regions[0] : fallbackRegionLabel,
|
||||
environmentTypeLabel: environmentTypes.length === 1 ? environmentTypes[0] : fallbackEnvironmentTypeLabel,
|
||||
hasMatch: rows.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: ReadonlyArray<string | null | undefined>): string[] {
|
||||
return [...new Set(values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value && value.length > 0)))];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { catchError, forkJoin, of, take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TopologyDataService } from './topology-data.service';
|
||||
import { summarizeEnvironmentScope } from './environment-scope-summary';
|
||||
import {
|
||||
EvidenceCapsuleRow,
|
||||
PlatformListResponse,
|
||||
@@ -539,10 +540,15 @@ export class TopologyEnvironmentDetailPageComponent {
|
||||
),
|
||||
}).subscribe({
|
||||
next: ({ environmentRows, targets, agents, runs, findings, capsules }) => {
|
||||
const environment = environmentRows[0];
|
||||
this.environmentLabel.set(environment?.displayName ?? environmentId);
|
||||
this.regionLabel.set(environment?.regionId ?? 'unknown-region');
|
||||
this.environmentTypeLabel.set(environment?.environmentType ?? 'unknown-type');
|
||||
const scopeSummary = summarizeEnvironmentScope(
|
||||
environmentRows,
|
||||
environmentId,
|
||||
this.context.regionSummary(),
|
||||
);
|
||||
|
||||
this.environmentLabel.set(scopeSummary.environmentLabel);
|
||||
this.regionLabel.set(scopeSummary.regionLabel);
|
||||
this.environmentTypeLabel.set(scopeSummary.environmentTypeLabel);
|
||||
|
||||
this.targetRows.set(targets);
|
||||
this.agentRows.set(agents);
|
||||
|
||||
Reference in New Issue
Block a user