Preserve mission control scope through context hydration

This commit is contained in:
master
2026-03-07 17:03:46 +02:00
parent 407318d81e
commit 1fa2e69032
7 changed files with 186 additions and 49 deletions

View File

@@ -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&regions=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&regions=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.

View File

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

View File

@@ -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<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]);
@@ -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<string, unknown>): 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;
}

View File

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

View File

@@ -101,19 +101,19 @@ interface MissionSummary {
<div class="summary-card" [class.warning]="summary().blockedPromotions > 0">
<div class="summary-value">{{ summary().activePromotions }}</div>
<div class="summary-label">Active Promotions</div>
<a routerLink="/releases/runs" class="summary-link">View all</a>
<a routerLink="/releases/runs" queryParamsHandling="merge" class="summary-link">View all</a>
</div>
<div class="summary-card" [class.critical]="summary().blockedPromotions > 0">
<div class="summary-value">{{ summary().blockedPromotions }}</div>
<div class="summary-label">Blocked Promotions</div>
<a routerLink="/releases/approvals" class="summary-link">Review</a>
<a routerLink="/releases/approvals" queryParamsHandling="merge" class="summary-link">Review</a>
</div>
<div class="summary-card">
<div class="summary-value env-name">{{ summary().highestRiskEnv }}</div>
<div class="summary-label">Highest Risk Environment</div>
<a routerLink="/security" class="summary-link">Risk detail</a>
<a routerLink="/security" queryParamsHandling="merge" class="summary-link">Risk detail</a>
</div>
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'"
@@ -123,7 +123,7 @@ interface MissionSummary {
{{ summary().dataIntegrityStatus | titlecase }}
</div>
<div class="summary-label">Data Integrity</div>
<a routerLink="/ops/operations/data-integrity" class="summary-link">Ops detail</a>
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="summary-link">Ops detail</a>
</div>
</section>
@@ -131,7 +131,7 @@ interface MissionSummary {
<section class="pipeline-board" aria-label="Regional pipeline board">
<div class="section-header">
<h2 class="section-title">Regional Pipeline</h2>
<a routerLink="/setup/topology/environments" class="section-link">All environments</a>
<a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="section-link">All environments</a>
</div>
<div class="env-grid">
@@ -213,7 +213,7 @@ interface MissionSummary {
<section class="risk-table" aria-label="Environments at risk">
<div class="section-header">
<h2 class="section-title">Environments at Risk</h2>
<a routerLink="/setup/topology/environments" class="section-link">Open environments</a>
<a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="section-link">Open environments</a>
</div>
@if (riskEnvironments().length === 0) {
@@ -266,7 +266,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="SBOM snapshot">
<div class="card-header">
<h2 class="card-title">SBOM Findings Snapshot</h2>
<a routerLink="/security/sbom-lake" class="card-link">View SBOM</a>
<a routerLink="/security/sbom-lake" queryParamsHandling="merge" class="card-link">View SBOM</a>
</div>
<div class="card-body">
<div class="snapshot-stat">
@@ -288,8 +288,8 @@ interface MissionSummary {
}
</div>
<div class="card-footer">
<a routerLink="/security/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
<a routerLink="/releases/runs" class="card-action">Release Runs</a>
<a routerLink="/security/findings" [queryParams]="{ reachability: 'critical' }" queryParamsHandling="merge" class="card-action">Open Findings</a>
<a routerLink="/releases/runs" queryParamsHandling="merge" class="card-action">Release Runs</a>
</div>
</section>
@@ -297,7 +297,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="Reachability summary">
<div class="card-header">
<h2 class="card-title">Reachability</h2>
<a routerLink="/security/reachability" class="card-link">View reachability</a>
<a routerLink="/security/reachability" queryParamsHandling="merge" class="card-link">View reachability</a>
</div>
<div class="card-body">
<div class="bir-matrix">
@@ -328,7 +328,7 @@ interface MissionSummary {
</p>
</div>
<div class="card-footer">
<a routerLink="/security/reachability" class="card-action">Deep analysis</a>
<a routerLink="/security/reachability" queryParamsHandling="merge" class="card-action">Deep analysis</a>
</div>
</section>
@@ -336,7 +336,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="Nightly ops signals">
<div class="card-header">
<h2 class="card-title">Nightly Ops Signals</h2>
<a routerLink="/ops/operations/data-integrity" class="card-link">Open Data Integrity</a>
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="card-link">Open Data Integrity</a>
</div>
<div class="card-body">
@for (signal of nightlyOpsSignals(); track signal.id) {
@@ -350,7 +350,7 @@ interface MissionSummary {
}
</div>
<div class="card-footer">
<a routerLink="/ops/operations/data-integrity" class="card-action">Open Data Integrity</a>
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="card-action">Open Data Integrity</a>
</div>
</section>
</div>
@@ -362,9 +362,9 @@ interface MissionSummary {
</div>
<div class="alerts-card">
<ul class="alerts-list">
<li><a routerLink="/releases/approvals">3 approvals blocked by policy gate evidence freshness</a></li>
<li><a routerLink="/security/disposition">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity">Feed freshness degraded for advisory ingest</a></li>
<li><a routerLink="/releases/approvals" queryParamsHandling="merge">3 approvals blocked by policy gate evidence freshness</a></li>
<li><a routerLink="/security/disposition" queryParamsHandling="merge">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Feed freshness degraded for advisory ingest</a></li>
</ul>
</div>
</section>
@@ -378,40 +378,40 @@ interface MissionSummary {
<article class="activity-card">
<h3 class="activity-card-title">Release Runs</h3>
<p class="activity-card-desc">Latest standard and hotfix promotions with gate checkpoints.</p>
<a routerLink="/releases/runs" class="activity-card-link">Open Runs</a>
<a routerLink="/releases/runs" queryParamsHandling="merge" class="activity-card-link">Open Runs</a>
</article>
<article class="activity-card">
<h3 class="activity-card-title">Evidence</h3>
<p class="activity-card-desc">Newest decision capsules and replay verification outcomes.</p>
<a routerLink="/evidence/capsules" class="activity-card-link">Open Capsules</a>
<a routerLink="/evidence/capsules" queryParamsHandling="merge" class="activity-card-link">Open Capsules</a>
</article>
<article class="activity-card">
<h3 class="activity-card-title">Audit</h3>
<p class="activity-card-desc">Unified activity trail by actor, resource, and correlation key.</p>
<a routerLink="/evidence/audit-log" class="activity-card-link">Open Audit Log</a>
<a routerLink="/evidence/audit-log" queryParamsHandling="merge" class="activity-card-link">Open Audit Log</a>
</article>
</div>
</section>
<!-- Cross-domain navigation links -->
<nav class="domain-nav" aria-label="Domain navigation">
<a routerLink="/releases/runs" class="domain-nav-item">
<a routerLink="/releases/runs" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9654;</span>
Release Runs
</a>
<a routerLink="/security" class="domain-nav-item">
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9632;</span>
Security &amp; Risk
</a>
<a routerLink="/ops/operations" class="domain-nav-item">
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9670;</span>
Platform
</a>
<a routerLink="/evidence" class="domain-nav-item">
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9679;</span>
Evidence (Decision Capsules)
</a>
<a routerLink="/ops/platform-setup" class="domain-nav-item">
<a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9881;</span>
Platform Setup
</a>

View File

@@ -17,17 +17,17 @@ import { RouterLink } from '@angular/router';
<article>
<h2>Release Runs</h2>
<p>Latest standard and hotfix promotions with gate checkpoints.</p>
<a routerLink="/releases/runs">Open Runs</a>
<a routerLink="/releases/runs" queryParamsHandling="merge">Open Runs</a>
</article>
<article>
<h2>Evidence</h2>
<p>Newest decision capsules and replay verification outcomes.</p>
<a routerLink="/evidence/capsules">Open Capsules</a>
<a routerLink="/evidence/capsules" queryParamsHandling="merge">Open Capsules</a>
</article>
<article>
<h2>Audit</h2>
<p>Unified activity trail by actor, resource, and correlation key.</p>
<a routerLink="/evidence/audit-log">Open Audit Log</a>
<a routerLink="/evidence/audit-log" queryParamsHandling="merge">Open Audit Log</a>
</article>
</div>
</section>

View File

@@ -14,9 +14,9 @@ import { RouterLink } from '@angular/router';
</header>
<ul>
<li><a routerLink="/releases/approvals">3 approvals blocked by policy gate evidence freshness</a></li>
<li><a routerLink="/security/disposition">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity">Feed freshness degraded for advisory ingest</a></li>
<li><a routerLink="/releases/approvals" queryParamsHandling="merge">3 approvals blocked by policy gate evidence freshness</a></li>
<li><a routerLink="/security/disposition" queryParamsHandling="merge">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Feed freshness degraded for advisory ingest</a></li>
</ul>
</section>
`,