diff --git a/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md index 437413a17..8a2ddff5e 100644 --- a/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md +++ b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md @@ -61,13 +61,16 @@ Completion criteria: | 2026-03-10 | First authenticated live topology sweep failed 25 actions with 0 runtime issues. Every failure traced to scope loss across topology shell tabs, CTA/drilldown links, or environment detail operator actions; two tab failures were harness selector collisions on partial `Security` and `Evidence` matches. | QA | | 2026-03-10 | Root cause analysis found two product defects: Topology links were not consistently using scope-preserving navigation, and `SecurityFindingsPageComponent.reloadFromFilters()` rewrote `/security/triage` without merge semantics, stripping the incoming topology scope. | Developer | | 2026-03-10 | Added focused navigation regressions for topology and security findings. Focused Angular run passed `6/6` assertions across `2` spec files. | Test Automation | -| 2026-03-10 | Rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and reran the same live topology sweep. Final evidence is clean with `0` failed actions and `0` runtime issues in `src/Web/StellaOps.Web/output/playwright/live-setup-topology-action-sweep.json`. Commit hash pending local commit. | QA | +| 2026-03-10 | Cold-stack replay showed the earlier sweep still had a QA harness defect: it sampled generic shell states before lazy topology routes hydrated and counted navigation-aborted XHRs as runtime failures. Hardened `live-setup-topology-action-sweep.mjs` with route-readiness gates, URL waits, and `ERR_ABORTED` filtering so Playwright only reports live defects. | QA | +| 2026-03-10 | The hardened sweep exposed one real product issue on `/setup/topology/environments`: local environment selection could drift off the scoped environment during hydration, causing `Open Targets`, `Open Agents`, and `Open Runs` to launch against `dev` or `prod-us-west` while the active scope was `stage`. Fixed `TopologyRegionsEnvironmentsPageComponent` to reconcile region/environment selection from the active context first, added a focused regression to `topology-scope-links.component.spec.ts`, rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and reran the live topology sweep clean with `0` failed actions and `0` runtime issues in `src/Web/StellaOps.Web/output/playwright/live-setup-topology-action-sweep.json`. Commit hash pending local commit. | Developer | ## Decisions & Risks - Decision: treat scope preservation as a correctness requirement in topology because the active platform context changes the data surface on every page and drilldown. - Risk: `TabbedNavComponent` is shared across multiple shells. If topology needs scope-preserving shell tabs, prefer an opt-in contract instead of a silent repo-wide behavior change. - Decision: `TabItem` now supports opt-in `queryParamsHandling`, and Topology explicitly sets `merge` on its shell tabs. This preserves scope without changing every other shared tabbed navigation surface. - Decision: fixing the destination rewrite in `/security/triage` is mandatory. Accepting a scoped entry link is not sufficient if the landing page immediately discards the topology context. +- Decision: topology inventory pages must reconcile local selection state from the active platform context before falling back to the first loaded row. Otherwise operator actions can silently target the wrong environment during cold-start hydration. +- Decision: live Playwright sweeps on the SPA must wait for route-specific readiness, not just `domcontentloaded`, because the shell can render before the lazy child route and produce false QA failures on a healthy stack. ## Next Checkpoints - Capture the first failing topology live sweep. diff --git a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs index 149c92020..7d29f291b 100644 --- a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs @@ -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); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts index a70bdc7d6..24051f5a7 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts @@ -21,6 +21,107 @@ import { TopologyTargetsPageComponent } from '../../features/topology/topology-t const routeData$ = new BehaviorSubject>({}); 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); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts index 790595194..d7c4d44bf 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts @@ -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 ?? ''; + } }