diff --git a/docs/implplan/SPRINT_20260307_016_FE_mission_control_scope_preserving_actions.md b/docs/implplan/SPRINT_20260307_016_FE_mission_control_scope_preserving_actions.md index fc239fa26..763582615 100644 --- a/docs/implplan/SPRINT_20260307_016_FE_mission_control_scope_preserving_actions.md +++ b/docs/implplan/SPRINT_20260307_016_FE_mission_control_scope_preserving_actions.md @@ -21,7 +21,7 @@ ## Delivery Tracker ### FE-MISSION-001 - Reproduce scope loss from mission action links -Status: DOING +Status: DONE Dependency: none Owners: QA Task description: @@ -29,11 +29,11 @@ Task description: - Capture which links drop `tenant`, `regions`, and `environments` when leaving mission-control surfaces. Completion criteria: -- [ ] Live Playwright evidence shows the exact mission actions that lose active scope. -- [ ] The defect is reduced to a specific link-contract pattern instead of a generic downstream page issue. +- [x] Live Playwright evidence shows the exact mission actions that lose active scope. +- [x] The defect is reduced to a specific link-contract pattern instead of a generic downstream page issue. ### FE-MISSION-002 - Make mission action links explicitly preserve active scope -Status: TODO +Status: DONE Dependency: FE-MISSION-001 Owners: Developer Task description: @@ -41,11 +41,11 @@ Task description: - Keep the fix scoped to the action layer so downstream pages receive the already-selected global context without route-specific patches. Completion criteria: -- [ ] Mission-board summary, alert, activity, and cross-domain action links preserve active scope during navigation. -- [ ] Mission-control alerts and activity action links preserve active scope during navigation. +- [x] Mission-board summary, alert, activity, and cross-domain action links preserve active scope during navigation. +- [x] Mission-control alerts and activity action links preserve active scope during navigation. ### FE-MISSION-003 - Add focused Angular coverage for scope-preserving links -Status: TODO +Status: DONE Dependency: FE-MISSION-002 Owners: Test Automation Task description: @@ -53,11 +53,11 @@ Task description: - Keep the coverage under `src/app/core/testing` so it remains in the focused Angular test include set. Completion criteria: -- [ ] Focused Angular tests assert the relevant mission links use `queryParamsHandling="merge"`. -- [ ] The tests fail before the fix and pass after it. +- [x] Focused Angular tests assert the relevant mission links use `queryParamsHandling="merge"`. +- [x] The tests fail before the fix and pass after it. ### FE-MISSION-004 - Replay mission actions live after the patch -Status: TODO +Status: DONE Dependency: FE-MISSION-003 Owners: QA Task description: @@ -65,17 +65,23 @@ Task description: - Confirm the downstream pages keep the original tenant/region/environment scope after navigation. Completion criteria: -- [ ] Live Playwright confirms approvals, disposition, data-integrity, and release-runs actions retain the scoped query string. -- [ ] Live Playwright confirms the fixed links no longer depend on destination-route behavior to keep context. +- [x] Live Playwright confirms approvals, disposition, data-integrity, and release-runs actions retain the scoped query string. +- [x] Live Playwright confirms the fixed links no longer depend on destination-route behavior to keep context. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created after live Playwright from `/mission-control/alerts` and `/mission-control/activity` showed several actions dropping `tenant=demo-prod®ions=us-east&environments=stage` on navigation, while others kept it only by route-specific accident. | QA | +| 2026-03-07 | Updated mission-board plus dedicated mission alerts/activity links to use explicit `queryParamsHandling=\"merge\"` and added focused coverage in `src/app/core/testing/mission-scope-links.component.spec.ts`; focused Angular run passed for `dashboard-v3`, mission-scope-links, and `platform-context.store`. | Developer | +| 2026-03-07 | Live Playwright on the rebuilt stack confirmed the original broken mission links now keep `tenant`, `regions`, and `environments`, but a deeper race remained: fresh authenticated navigations could still collapse scoped URLs back to tenant-only during global context initialization. | QA | +| 2026-03-07 | Fixed the context hydration race in `PlatformContextStore` by retaining the latest pending scope query until initialization completes, then added a focused store regression covering late-arriving route scope during bootstrap. | Developer | +| 2026-03-07 | Rebuilt the Web bundle, synced `dist/stellaops-web/browser` into the live `compose_console-dist` volume, and re-ran live Playwright on `/mission-control/board`, `/mission-control/alerts`, and `/mission-control/activity` with `timeWindow=7d`. Source URLs and representative actions now retain `tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d` through downstream routes such as `/releases/approvals`, `/security/disposition`, and `/releases/runs`. | QA | ## Decisions & Risks - Decision: fix scope preservation at the mission-link layer with explicit query-param merge semantics rather than modifying every downstream route that currently receives an empty or partial query string. - Risk: the main dashboard uses the same static mission links, so the fix must cover both the dedicated mission-control pages and the mission-board surface in the same iteration. +- Decision: treat the later tenant-only URL collapse as a context-initialization defect in `PlatformContextStore`, not another mission-page routing issue, because the scoped route arrived before context initialization completed and the store only remembered the first query override. +- Decision: keep the default `24h` time window omission from `scopeQueryPatch()` unchanged; live verification used a non-default `7d` value to prove that explicit time-window scope now survives initialization and navigation. ## Next Checkpoints - 2026-03-07: land the scope-preserving mission action patch and focused Angular coverage. diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts index d2bbb94c3..0d20ec247 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts @@ -118,4 +118,63 @@ describe('PlatformContextStore', () => { expect(store.loading()).toBe(false); expect(store.error()).toBeNull(); }); + + it('keeps the latest route scope when query params arrive before initialization finishes', () => { + store.initialize(); + + const regionsReq = httpMock.expectOne('/api/v2/context/regions'); + regionsReq.flush([ + { + regionId: 'us-east', + displayName: 'US East', + sortOrder: 10, + enabled: true, + }, + { + regionId: 'eu-west', + displayName: 'EU West', + sortOrder: 20, + enabled: true, + }, + ]); + + store.applyScopeQueryParams({ + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', + }); + + const preferencesReq = httpMock.expectOne('/api/v2/context/preferences'); + preferencesReq.flush({ + tenantId: null, + actorId: 'context-tests', + regions: [], + environments: [], + timeWindow: '24h', + updatedAt: '2026-03-07T00:00:00Z', + updatedBy: 'context-tests', + }); + + const environmentsReq = httpMock.expectOne((request) => + request.url === '/api/v2/context/environments' + && request.params.get('regions') === 'us-east' + ); + environmentsReq.flush([ + { + environmentId: 'stage', + regionId: 'us-east', + environmentType: 'staging', + displayName: 'Staging', + sortOrder: 10, + enabled: true, + }, + ]); + + expect(store.tenantId()).toBe('demo-prod'); + expect(store.selectedRegions()).toEqual(['us-east']); + expect(store.selectedEnvironments()).toEqual(['stage']); + expect(store.timeWindow()).toBe('7d'); + expect(store.error()).toBeNull(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts index 291715117..ed8ac1c50 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts @@ -59,7 +59,7 @@ export class PlatformContextStore { private readonly authSession = inject(AuthSessionStore); private persistPaused = false; private readonly apiDisabled = this.shouldDisableApiCalls(); - private readonly initialQueryOverride = this.readScopeQueryFromLocation(); + private pendingQueryOverride: PlatformContextQueryState | null = this.readScopeQueryFromLocation(); readonly regions = signal([]); readonly environments = signal([]); @@ -116,7 +116,7 @@ export class PlatformContextStore { } if (this.apiDisabled) { - this.tenantId.set(this.initialQueryOverride?.tenantId ?? null); + this.tenantId.set(this.pendingQueryOverride?.tenantId ?? null); this.loading.set(false); this.error.set(null); this.initialized.set(true); @@ -227,15 +227,17 @@ export class PlatformContextStore { } applyScopeQueryParams(queryParams: Record): void { + const queryState = this.parseScopeQueryState(queryParams); if (!this.initialized()) { + this.pendingQueryOverride = queryState; return; } - const queryState = this.parseScopeQueryState(queryParams); if (!queryState) { return; } + this.pendingQueryOverride = queryState; const nextTenantId = this.normalizeTenantId(queryState.tenantId); const allowedRegions = this.regions().map((item) => item.regionId); const nextRegions = this.normalizeIds(queryState.regions, allowedRegions); @@ -300,7 +302,7 @@ export class PlatformContextStore { timeWindow: (prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW, stage: (prefs?.stage ?? DEFAULT_STAGE).trim().toLowerCase() || DEFAULT_STAGE, }; - const hydrated = this.mergeWithInitialQueryOverride(preferenceState); + const hydrated = this.mergeWithPendingQueryOverride(preferenceState); const preferredRegions = this.normalizeIds( hydrated.regions, this.regions().map((item) => item.regionId), @@ -313,7 +315,7 @@ export class PlatformContextStore { }, error: () => { // Preferences are optional; continue with default empty context. - const fallbackState = this.mergeWithInitialQueryOverride({ + const fallbackState = this.mergeWithPendingQueryOverride({ tenantId: null, regions: [], environments: [], @@ -419,8 +421,8 @@ export class PlatformContextStore { this.persistPaused = false; } - private mergeWithInitialQueryOverride(baseState: PlatformContextQueryState): PlatformContextQueryState { - const override = this.initialQueryOverride; + private mergeWithPendingQueryOverride(baseState: PlatformContextQueryState): PlatformContextQueryState { + const override = this.pendingQueryOverride; if (!override) { return baseState; } diff --git a/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts new file mode 100644 index 000000000..39f4cc751 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts @@ -0,0 +1,70 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterLink, provideRouter } from '@angular/router'; +import { By } from '@angular/platform-browser'; + +import { PlatformContextStore } from '../context/platform-context.store'; +import { DashboardV3Component } from '../../features/dashboard-v3/dashboard-v3.component'; +import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component'; +import { MissionActivityPageComponent } from '../../features/mission-control/mission-activity-page.component'; + +function routerLinksFor(component: T): RouterLink[] { + const fixture = TestBed.createComponent(component as never); + fixture.detectChanges(); + return fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => debugElement.injector.get(RouterLink)); +} + +describe('Mission scope-preserving links', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DashboardV3Component, MissionAlertsPageComponent, MissionActivityPageComponent], + providers: [ + provideRouter([]), + { + provide: PlatformContextStore, + useValue: { + initialize: () => undefined, + regions: () => [ + { regionId: 'eu-west', displayName: 'EU West' }, + { regionId: 'us-east', displayName: 'US East' }, + ], + environments: () => [ + { + environmentId: 'dev', + regionId: 'eu-west', + environmentType: 'development', + displayName: 'Development EU West', + }, + { + environmentId: 'stage', + regionId: 'us-east', + environmentType: 'staging', + displayName: 'Staging US East', + }, + ], + }, + }, + ], + }); + }); + + it('marks every mission alerts link to merge the active query scope', () => { + const links = routerLinksFor(MissionAlertsPageComponent); + + expect(links.length).toBe(3); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + + it('marks every mission activity link to merge the active query scope', () => { + const links = routerLinksFor(MissionActivityPageComponent); + + expect(links.length).toBe(3); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + + it('marks dashboard mission navigation links to merge the active query scope', () => { + const links = routerLinksFor(DashboardV3Component); + + expect(links.length).toBeGreaterThan(0); + expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index ae6fac111..df48f01c7 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -101,19 +101,19 @@ interface MissionSummary {
{{ summary().activePromotions }}
Active Promotions
- View all + View all
{{ summary().blockedPromotions }}
Blocked Promotions
- Review + Review
{{ summary().highestRiskEnv }}
Highest Risk Environment
- Risk detail + Risk detail
Data Integrity
- Ops detail + Ops detail
@@ -131,7 +131,7 @@ interface MissionSummary {

Regional Pipeline

- All environments + All environments
@@ -213,7 +213,7 @@ interface MissionSummary {

Environments at Risk

- Open environments + Open environments
@if (riskEnvironments().length === 0) { @@ -266,7 +266,7 @@ interface MissionSummary {

SBOM Findings Snapshot

- View SBOM + View SBOM
@@ -288,8 +288,8 @@ interface MissionSummary { }
@@ -297,7 +297,7 @@ interface MissionSummary {
@@ -328,7 +328,7 @@ interface MissionSummary {

@@ -336,7 +336,7 @@ interface MissionSummary {

Nightly Ops Signals

- Open Data Integrity + Open Data Integrity
@for (signal of nightlyOpsSignals(); track signal.id) { @@ -350,7 +350,7 @@ interface MissionSummary { }
@@ -362,9 +362,9 @@ interface MissionSummary {
@@ -378,40 +378,40 @@ interface MissionSummary {

Release Runs

Latest standard and hotfix promotions with gate checkpoints.

- Open Runs + Open Runs