Fix topology scope hydration and live sweep readiness
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user