Preserve mission control scope through context hydration
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">▶</span>
|
||||
Release Runs
|
||||
</a>
|
||||
<a routerLink="/security" class="domain-nav-item">
|
||||
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">■</span>
|
||||
Security & Risk
|
||||
</a>
|
||||
<a routerLink="/ops/operations" class="domain-nav-item">
|
||||
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">◆</span>
|
||||
Platform
|
||||
</a>
|
||||
<a routerLink="/evidence" class="domain-nav-item">
|
||||
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">●</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">⚙</span>
|
||||
Platform Setup
|
||||
</a>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user