Fix release health multi-scope evidence contracts

This commit is contained in:
master
2026-03-07 05:13:36 +02:00
parent afa23fc504
commit b70457712b
15 changed files with 842 additions and 36 deletions

View File

@@ -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')) {

View File

@@ -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([]);
});
});

View File

@@ -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&regions=apac,eu-west,us-east,us-west&region=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h');
request.flush({ items: [] });
});
});

View File

@@ -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';
}
}

View File

@@ -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)))];
}

View File

@@ -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);