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

@@ -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>
`,