Fix topology scope hydration and live sweep readiness

This commit is contained in:
master
2026-03-10 14:37:38 +02:00
parent b302a5a3d6
commit ec22b8ee46
4 changed files with 320 additions and 77 deletions

View File

@@ -21,6 +21,39 @@ const topologyScope = {
};
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
const STEP_TIMEOUT_MS = 30_000;
const GENERIC_TITLES = new Set(['StellaOps', 'Stella Ops Dashboard']);
const ROUTE_READINESS = [
{
path: '/setup/topology/overview',
title: 'Topology Overview - StellaOps',
markers: ['Open Promotion Paths', 'Open Agents', 'Top Hotspots'],
},
{
path: '/setup/topology/environments',
title: 'Environments - StellaOps',
markers: ['Environment Signals', 'Open Targets', 'Open Runs'],
},
{
path: '/setup/topology/environments/stage/posture',
title: 'Environment Detail - StellaOps',
markers: ['Operator Actions', 'Open Security Triage', 'Data Quality'],
},
{
path: '/setup/topology/targets',
title: 'Targets - StellaOps',
markers: ['No targets for current filters.', 'Select a target row to view its topology mapping details.'],
},
{
path: '/setup/topology/hosts',
title: 'Hosts - StellaOps',
markers: ['No hosts for current filters.', 'Select a host row to inspect runtime drift and impact.'],
},
{
path: '/setup/topology/agents',
title: 'Agent Fleet - StellaOps',
markers: ['No groups for current filters.', 'All Agents', 'View Targets'],
},
];
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
@@ -61,11 +94,16 @@ function attachRuntimeObservers(page, runtime) {
return;
}
const errorText = request.failure()?.errorText ?? 'unknown';
if (errorText === 'net::ERR_ABORTED') {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url: request.url(),
error: request.failure()?.errorText ?? 'unknown',
error: errorText,
});
});
@@ -85,7 +123,66 @@ function attachRuntimeObservers(page, runtime) {
});
}
async function settle(page) {
function routePath(routeOrPath) {
if (routeOrPath.startsWith('http://') || routeOrPath.startsWith('https://')) {
return new URL(routeOrPath).pathname;
}
return new URL(withScope(routeOrPath)).pathname;
}
function routeReadiness(pathname) {
return ROUTE_READINESS.find((entry) => entry.path === pathname) ?? null;
}
async function waitForRouteReady(page, routeOrPath) {
const expectedPath = routePath(routeOrPath);
const readiness = routeReadiness(expectedPath);
if (!readiness) {
await page.waitForFunction(
({ expectedPathValue, genericTitles }) => {
if (window.location.pathname !== expectedPathValue) {
return false;
}
const title = document.title.trim();
return title.length > 0 && !genericTitles.includes(title);
},
{
expectedPathValue: expectedPath,
genericTitles: [...GENERIC_TITLES],
},
{ timeout: 15_000 },
).catch(() => {});
return;
}
await page.waitForFunction(
({ expectedPathValue, expectedTitle, markers, genericTitles }) => {
if (window.location.pathname !== expectedPathValue) {
return false;
}
const title = document.title.trim();
if (title.length === 0 || genericTitles.includes(title) || title !== expectedTitle) {
return false;
}
const bodyText = document.body?.innerText?.replace(/\s+/g, ' ') ?? '';
return markers.some((marker) => bodyText.includes(marker));
},
{
expectedPathValue: expectedPath,
expectedTitle: readiness.title,
markers: readiness.markers,
genericTitles: [...GENERIC_TITLES],
},
{ timeout: 15_000 },
).catch(() => {});
}
async function settle(page, routeOrPath = null) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(750);
@@ -96,6 +193,11 @@ async function settle(page) {
}
await page.waitForTimeout(750);
if (routeOrPath) {
await waitForRouteReady(page, routeOrPath);
await page.waitForTimeout(250);
}
}
async function headingText(page) {
@@ -146,7 +248,7 @@ async function navigate(page, route) {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
await settle(page, route);
}
function hasExpectedQuery(urlString, expectedQuery = {}) {
@@ -184,7 +286,12 @@ async function resolveLink(page, options) {
async function clickLinkAction(page, route, options) {
await navigate(page, route);
const link = await resolveLink(page, options);
let link = await resolveLink(page, options);
if (!link) {
await page.waitForTimeout(1_000);
await settle(page, route);
link = await resolveLink(page, options);
}
if (!link) {
return {
action: options.action,
@@ -195,7 +302,8 @@ async function clickLinkAction(page, route, options) {
}
await link.click({ timeout: 10_000 });
await settle(page);
await page.waitForURL((url) => url.pathname === options.expectedPath, { timeout: 15_000 }).catch(() => {});
await settle(page, options.expectedPath);
const url = new URL(page.url());
const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery);

View File

@@ -21,6 +21,107 @@ import { TopologyTargetsPageComponent } from '../../features/topology/topology-t
const routeData$ = new BehaviorSubject<Record<string, unknown>>({});
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
const paramMap$ = new BehaviorSubject(convertToParamMap({ environmentId: 'stage' }));
const topologyRegions = [
{ regionId: 'us-east', displayName: 'US East', environmentCount: 2, targetCount: 2 },
{ regionId: 'us-west', displayName: 'US West', environmentCount: 1, targetCount: 1 },
];
const topologyEnvironments = [
{
environmentId: 'dev',
displayName: 'Development',
regionId: 'us-east',
environmentType: 'development',
targetCount: 1,
},
{
environmentId: 'stage',
displayName: 'Staging',
regionId: 'us-east',
environmentType: 'staging',
targetCount: 1,
},
{
environmentId: 'prod-us-west',
displayName: 'Production West',
regionId: 'us-west',
environmentType: 'production',
targetCount: 1,
},
];
const topologyTargets = [
{
targetId: 'target-dev',
name: 'api-dev',
regionId: 'us-east',
environmentId: 'dev',
targetType: 'vm',
healthStatus: 'healthy',
hostId: 'host-dev',
agentId: 'agent-dev',
componentVersionId: 'component-dev',
lastSyncAt: '2026-03-10T00:00:00Z',
},
{
targetId: 'target-stage',
name: 'api-stage',
regionId: 'us-east',
environmentId: 'stage',
targetType: 'vm',
healthStatus: 'healthy',
hostId: 'host-stage',
agentId: 'agent-stage',
componentVersionId: 'component-stage',
lastSyncAt: '2026-03-10T00:00:00Z',
},
{
targetId: 'target-prod',
name: 'api-prod',
regionId: 'us-west',
environmentId: 'prod-us-west',
targetType: 'vm',
healthStatus: 'healthy',
hostId: 'host-prod',
agentId: 'agent-prod',
componentVersionId: 'component-prod',
lastSyncAt: '2026-03-10T00:00:00Z',
},
];
const topologyHosts = [
{
hostId: 'host-stage',
hostName: 'host-stage',
regionId: 'us-east',
environmentId: 'stage',
runtimeType: 'containerd',
status: 'healthy',
targetCount: 1,
agentId: 'agent-stage',
lastSeenAt: '2026-03-10T00:00:00Z',
},
];
const topologyAgents = [
{
agentId: 'agent-stage',
agentName: 'agent-stage',
regionId: 'us-east',
environmentId: 'stage',
status: 'active',
assignedTargetCount: 1,
capabilities: ['deploy'],
lastHeartbeatAt: '2026-03-10T00:00:00Z',
},
];
const topologyPromotionPaths = [
{
pathId: 'path-1',
regionId: 'us-east',
sourceEnvironmentId: 'dev',
targetEnvironmentId: 'stage',
status: 'running',
requiredApprovals: 1,
gateProfileId: 'gate-1',
},
];
const mockContextStore = {
initialize: () => undefined,
@@ -31,8 +132,17 @@ const mockContextStore = {
selectedEnvironments: () => ['stage'],
regions: () => [
{ regionId: 'us-east', displayName: 'US East', sortOrder: 10, enabled: true },
{ regionId: 'us-west', displayName: 'US West', sortOrder: 20, enabled: true },
],
environments: () => [
{
environmentId: 'dev',
regionId: 'us-east',
environmentType: 'development',
displayName: 'Development',
sortOrder: 10,
enabled: true,
},
{
environmentId: 'stage',
regionId: 'us-east',
@@ -41,6 +151,14 @@ const mockContextStore = {
sortOrder: 20,
enabled: true,
},
{
environmentId: 'prod-us-west',
regionId: 'us-west',
environmentType: 'production',
displayName: 'Production West',
sortOrder: 30,
enabled: true,
},
],
};
@@ -48,71 +166,17 @@ const mockTopologyDataService = {
list: jasmine.createSpy('list').and.callFake((endpoint: string) => {
switch (endpoint) {
case '/api/v2/topology/regions':
return of([{ regionId: 'us-east', displayName: 'US East', environmentCount: 1, targetCount: 1 }]);
return of(topologyRegions);
case '/api/v2/topology/environments':
return of([
{
environmentId: 'stage',
displayName: 'Staging',
regionId: 'us-east',
environmentType: 'staging',
targetCount: 1,
},
]);
return of(topologyEnvironments);
case '/api/v2/topology/targets':
return of([
{
targetId: 'target-1',
name: 'api-web',
regionId: 'us-east',
environmentId: 'stage',
targetType: 'vm',
healthStatus: 'healthy',
hostId: 'host-1',
agentId: 'agent-1',
componentVersionId: 'component-1',
lastSyncAt: '2026-03-10T00:00:00Z',
},
]);
return of(topologyTargets);
case '/api/v2/topology/hosts':
return of([
{
hostId: 'host-1',
hostName: 'host-1',
regionId: 'us-east',
environmentId: 'stage',
runtimeType: 'containerd',
status: 'healthy',
targetCount: 1,
agentId: 'agent-1',
lastSeenAt: '2026-03-10T00:00:00Z',
},
]);
return of(topologyHosts);
case '/api/v2/topology/agents':
return of([
{
agentId: 'agent-1',
agentName: 'agent-1',
regionId: 'us-east',
environmentId: 'stage',
status: 'active',
assignedTargetCount: 1,
capabilities: ['deploy'],
lastHeartbeatAt: '2026-03-10T00:00:00Z',
},
]);
return of(topologyAgents);
case '/api/v2/topology/promotion-paths':
return of([
{
pathId: 'path-1',
regionId: 'us-east',
sourceEnvironmentId: 'dev',
targetEnvironmentId: 'stage',
status: 'running',
requiredApprovals: 1,
gateProfileId: 'gate-1',
},
]);
return of(topologyPromotionPaths);
default:
return of([]);
}
@@ -238,6 +302,27 @@ describe('Topology scope-preserving links', () => {
}
});
it('anchors topology environment operator actions to the scoped environment', () => {
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
routeData$.next({ defaultView: 'region-first' });
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
fixture.detectChanges();
fixture.detectChanges();
const component = fixture.componentInstance;
const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => ({
text: debugElement.nativeElement.textContent.trim().replace(/\s+/g, ' '),
link: debugElement.injector.get(RouterLink),
}));
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
});
it('marks promotion inventory links to merge the active query scope', () => {
configureTestingModule(TopologyPromotionPathsPageComponent);

View File

@@ -526,18 +526,11 @@ export class TopologyRegionsEnvironmentsPageComponent {
this.targets.set(targets);
this.paths.set(paths);
const selectedRegion = this.selectedRegionId();
const hasRegion = selectedRegion && regions.some((item) => item.regionId === selectedRegion);
if (!hasRegion) {
this.selectedRegionId.set(regions[0]?.regionId ?? '');
}
const nextSelectedRegion = this.resolveSelectedRegionId(regions, environments);
this.selectedRegionId.set(nextSelectedRegion);
const selectedEnv = this.selectedEnvironmentId();
const hasEnvironment = selectedEnv && environments.some((item) => item.environmentId === selectedEnv);
if (!hasEnvironment) {
const firstInRegion = environments.find((item) => item.regionId === this.selectedRegionId());
this.selectedEnvironmentId.set(firstInRegion?.environmentId ?? environments[0]?.environmentId ?? '');
}
const nextSelectedEnvironment = this.resolveSelectedEnvironmentId(environments, nextSelectedRegion);
this.selectedEnvironmentId.set(nextSelectedEnvironment);
this.loading.set(false);
},
@@ -557,6 +550,60 @@ export class TopologyRegionsEnvironmentsPageComponent {
private match(query: string, values: string[]): boolean {
return values.some((value) => value.toLowerCase().includes(query));
}
private resolveSelectedRegionId(
regions: TopologyRegion[],
environments: TopologyEnvironment[],
): string {
const current = this.selectedRegionId();
const scopedEnvironments = this.context.selectedEnvironments();
const scopedRegions = this.context.selectedRegions();
const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? '';
const preferredScopedRegion =
regionFromScopedEnvironment
|| scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId))
|| '';
if (preferredScopedRegion) {
return preferredScopedRegion;
}
if (current && regions.some((item) => item.regionId === current)) {
return current;
}
return regions[0]?.regionId ?? '';
}
private resolveSelectedEnvironmentId(
environments: TopologyEnvironment[],
selectedRegionId: string,
): string {
const current = this.selectedEnvironmentId();
const scopedEnvironments = this.context.selectedEnvironments();
const environmentsInRegion = selectedRegionId
? environments.filter((item) => item.regionId === selectedRegionId)
: environments;
const preferredScopedEnvironment =
scopedEnvironments.find((environmentId) =>
environmentsInRegion.some((item) => item.environmentId === environmentId),
)
?? scopedEnvironments.find((environmentId) =>
environments.some((item) => item.environmentId === environmentId),
)
?? '';
if (preferredScopedEnvironment) {
return preferredScopedEnvironment;
}
if (current && environmentsInRegion.some((item) => item.environmentId === current)) {
return current;
}
return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? '';
}
}