Stabilize release confidence approval decision journey

This commit is contained in:
master
2026-03-15 04:04:36 +02:00
parent 4a5185121d
commit 7bdfcd5055
6 changed files with 314 additions and 23 deletions

View File

@@ -443,6 +443,76 @@ async function main() {
};
});
await recordStep(page, report, '02b-approval-detail-decision-cockpit', async (issues) => {
const detailUrl = buildScopedUrl('/releases/approvals/apr-001');
const verifyApprovalLink = async (tabName, linkName, expectedPath, options = {}) => {
await page.goto(detailUrl, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
if (tabName !== 'Overview') {
const tabButton = page.getByRole('button', { name: tabName, exact: true }).first();
if (!(await tabButton.isVisible().catch(() => false))) {
issues.push(`approval detail is missing the "${tabName}" tab`);
return;
}
await clickStable(page, tabButton);
await settle(page, 1_000);
}
if (typeof options.beforeLinkClick === 'function') {
await options.beforeLinkClick();
}
const target = page.getByRole('link', { name: linkName, exact: true }).first();
if (!(await target.isVisible().catch(() => false))) {
issues.push(`approval detail is missing the "${linkName}" handoff on ${tabName}`);
return;
}
await clickStable(page, target);
await page.waitForURL((url) => url.pathname === expectedPath, { timeout: 20_000 }).catch(() => {});
await settle(page, 1_500);
const finalPath = new URL(page.url()).pathname;
if (finalPath !== expectedPath) {
issues.push(`approval detail "${linkName}" landed on ${page.url()} instead of ${expectedPath}`);
}
issues.push(...scopeIssues(page.url(), `approval detail ${tabName} -> ${linkName}`));
};
await page.goto(detailUrl, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
await ensureHeading(page, /approval detail/i, issues, 'approval detail');
const cockpitText = await bodyText(page);
if (/coming soon/i.test(cockpitText) || !/decision case-file|reachability|ops\/data|replay\/verify/i.test(cockpitText)) {
issues.push('approval detail is still rendering placeholder content instead of the decision cockpit');
}
await verifyApprovalLink('Reachability', 'Open Reachability Ingest Health', '/ops/operations/data-integrity/reachability-ingest');
await verifyApprovalLink('Reachability', 'Open Env Detail', '/setup/topology/environments');
await verifyApprovalLink('Ops/Data', 'Open Data Integrity', '/ops/operations/data-integrity');
await verifyApprovalLink('Ops/Data', 'Open Integrations', '/setup/integrations');
await verifyApprovalLink('Gates', 'Trigger SBOM Scan', '/ops/operations/data-integrity/scan-pipeline', {
beforeLinkClick: async () => {
await clickStable(page, page.getByRole('button', { name: 'Gate detail trace', exact: true }).first());
await settle(page, 750);
},
});
return {
detailUrl,
};
});
await recordStep(page, report, '03-decision-capsules-search-and-detail', async (issues) => {
await page.goto(buildScopedUrl('/evidence/capsules'), {
waitUntil: 'domcontentloaded',

View File

@@ -0,0 +1,121 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { ApprovalDetailPageComponent } from './approval-detail-page.component';
import { APPROVALS_ROUTES } from './approvals.routes';
import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths';
describe('ApprovalDetailPageComponent', () => {
let component: ApprovalDetailPageComponent;
let fixture: ComponentFixture<ApprovalDetailPageComponent>;
const scopeQueryParams = {
tenant: 'demo-prod',
regions: 'eu-west',
environments: 'prod',
timeWindow: '24h',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalDetailPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
params: of({ id: 'apr-001' }),
queryParamMap: of(convertToParamMap(scopeQueryParams)),
snapshot: {
paramMap: convertToParamMap({ id: 'apr-001' }),
queryParamMap: convertToParamMap(scopeQueryParams),
queryParams: scopeQueryParams,
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalDetailPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads the decision cockpit for approval detail routes', async () => {
const detailRoute = APPROVALS_ROUTES.find((route) => route.path === ':id');
expect(await detailRoute?.loadComponent?.()).toBe(ApprovalDetailPageComponent);
});
it('uses canonical reachability handoff routes', () => {
component.setActiveTab('reachability');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href'));
expect(hrefs.some((href) => href?.startsWith(dataIntegrityPath('reachability-ingest')))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith('/setup/topology/environments'))).toBeTrue();
});
it('uses canonical ops/data handoff routes', () => {
component.setActiveTab('ops-data');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href'));
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.dataIntegrity))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith('/setup/integrations'))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.schedulerRuns))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.deadLetter))).toBeTrue();
});
it('uses canonical gate fix links for reachability blockers', () => {
const reachabilityGate = component.gateTraceRows.find((row) => row.id === 'reachability');
expect(reachabilityGate?.fixLinks.map((link) => link.route)).toEqual([
dataIntegrityPath('scan-pipeline'),
'/security/findings',
'/ops/policy/vex/exceptions',
OPERATIONS_PATHS.dataIntegrity,
]);
});
it('preserves operator scope in decision context query params', () => {
expect(component.decisioningContextParams({ create: '1' })).toEqual(
jasmine.objectContaining({
tenant: 'demo-prod',
regions: 'eu-west',
environments: 'prod',
timeWindow: '24h',
approvalId: 'apr-001',
create: '1',
}),
);
});
it('preserves operator scope in approval handoff links', () => {
component.setActiveTab('ops-data');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? '');
const dataIntegrityHref = hrefs.find((href) => href.startsWith(OPERATIONS_PATHS.dataIntegrity));
const integrationsHref = hrefs.find((href) => href.startsWith('/setup/integrations'));
expect(dataIntegrityHref).toContain('tenant=demo-prod');
expect(dataIntegrityHref).toContain('regions=eu-west');
expect(dataIntegrityHref).toContain('environments=prod');
expect(dataIntegrityHref).toContain('timeWindow=24h');
expect(integrationsHref).toContain('tenant=demo-prod');
expect(integrationsHref).toContain('regions=eu-west');
expect(integrationsHref).toContain('environments=prod');
expect(integrationsHref).toContain('timeWindow=24h');
});
});

View File

@@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ActivatedRoute, ParamMap, Router, RouterLink } from '@angular/router';
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths';
type GateResult = 'PASS' | 'WARN' | 'BLOCK';
type HealthStatus = 'OK' | 'WARN' | 'FAIL';
@@ -97,7 +97,7 @@ interface HistoryEvent {
template: `
<div class="approval-detail-v2">
<header class="decision-header">
<a routerLink="/releases/approvals" class="back-link">Back to Approvals</a>
<a routerLink="/releases/approvals" [queryParams]="scopeQueryParams()" class="back-link">Back to Approvals</a>
<div class="decision-header__title-row">
<h1>Approval Detail</h1>
@@ -232,7 +232,7 @@ interface HistoryEvent {
@if (row.result === 'BLOCK') {
<div class="fix-links">
@for (link of row.fixLinks; track link.label) {
<a [routerLink]="link.route" [queryParams]="link.queryParams || null">{{ link.label }}</a>
<a [routerLink]="link.route" [queryParams]="handoffQueryParams(link.queryParams)">{{ link.label }}</a>
}
</div>
}
@@ -298,7 +298,7 @@ interface HistoryEvent {
</table>
<div class="footer-links">
<a routerLink="/security/findings">Open Findings (filtered)</a>
<a routerLink="/security/findings" [queryParams]="scopeQueryParams()">Open Findings (filtered)</a>
<a routerLink="/ops/policy/vex" [queryParams]="decisioningContextParams()">Open VEX Hub</a>
<a routerLink="/ops/policy/vex/exceptions" [queryParams]="decisioningContextParams()">Open Exceptions</a>
</div>
@@ -339,8 +339,8 @@ interface HistoryEvent {
</table>
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Open Reachability Ingest Health</a>
<a routerLink="/topology/environments">Open Env Detail</a>
<a [routerLink]="dataIntegrityPath('reachability-ingest')" [queryParams]="scopeQueryParams()">Open Reachability Ingest Health</a>
<a [routerLink]="topologyEnvironmentsRoute" [queryParams]="scopeQueryParams()">Open Env Detail</a>
</div>
</section>
}
@@ -398,10 +398,10 @@ interface HistoryEvent {
</div>
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
<a routerLink="/integrations">Open Integrations</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns">Open Scheduler Runs</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter">Open DLQ</a>
<a [routerLink]="OPERATIONS_PATHS.dataIntegrity" [queryParams]="scopeQueryParams()">Open Data Integrity</a>
<a [routerLink]="integrationsRoute" [queryParams]="scopeQueryParams()">Open Integrations</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" [queryParams]="scopeQueryParams()">Open Scheduler Runs</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter" [queryParams]="scopeQueryParams()">Open DLQ</a>
</div>
</section>
}
@@ -420,8 +420,8 @@ interface HistoryEvent {
<p>Signature status: DSSE signed, transparency log anchored, replay metadata present.</p>
<div class="footer-links">
<button type="button" class="link-btn" (click)="exportPacket()">Export Packet</button>
<a routerLink="/evidence/exports">Open Export Center</a>
<a routerLink="/evidence/capsules">Open Proof Chain</a>
<a routerLink="/evidence/exports" [queryParams]="scopeQueryParams()">Open Export Center</a>
<a routerLink="/evidence/capsules" [queryParams]="scopeQueryParams()">Open Proof Chain</a>
</div>
</section>
}
@@ -447,7 +447,7 @@ interface HistoryEvent {
</ul>
<div class="footer-links">
<a routerLink="/evidence/verify-replay">Open canonical Replay/Verify</a>
<a routerLink="/evidence/verify-replay" [queryParams]="scopeQueryParams()">Open canonical Replay/Verify</a>
</div>
</section>
}
@@ -788,12 +788,16 @@ interface HistoryEvent {
})
export class ApprovalDetailPageComponent implements OnInit {
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
protected readonly dataIntegrityPath = dataIntegrityPath;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly integrationsRoute = '/setup/integrations';
protected readonly topologyEnvironmentsRoute = '/setup/topology/environments';
readonly minDecisionReasonLength = 10;
readonly activeTab = signal<ApprovalTabId>('overview');
readonly expandedGateId = signal<string | null>(null);
readonly scopeQueryParams = signal<Record<string, string>>({});
readonly approval = signal<ApprovalDetailState>({
id: 'apr-001',
@@ -846,14 +850,14 @@ export class ApprovalDetailPageComponent implements OnInit {
timestamp: '2026-02-19T18:11:00Z',
evidenceAge: '2h 11m',
fixLinks: [
{ label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' },
{ label: 'Trigger SBOM Scan', route: dataIntegrityPath('scan-pipeline') },
{ label: 'Open Finding', route: '/security/findings' },
{
label: 'Request Exception',
route: '/ops/policy/vex/exceptions',
queryParams: this.decisioningContextParams({ create: '1' }),
},
{ label: 'Open Data Integrity', route: '/platform-ops/data-integrity' },
{ label: 'Open Data Integrity', route: OPERATIONS_PATHS.dataIntegrity },
],
},
{
@@ -983,6 +987,10 @@ export class ApprovalDetailPageComponent implements OnInit {
this.approval.update((state) => ({ ...state, id }));
}
});
this.route.queryParamMap.subscribe((queryParamMap) => {
this.scopeQueryParams.set(this.mapQueryParams(queryParamMap));
});
}
setActiveTab(tab: ApprovalTabId): void {
@@ -1050,17 +1058,34 @@ export class ApprovalDetailPageComponent implements OnInit {
}));
}
handoffQueryParams(extra: Record<string, string> = {}): Record<string, string> {
return {
...this.scopeQueryParams(),
...extra,
};
}
decisioningContextParams(extra: Record<string, string> = {}): Record<string, string> {
const approval = this.approval();
const returnTo = buildContextReturnTo(this.router, ['/releases/approvals', approval.id]);
return {
return this.handoffQueryParams({
approvalId: approval.id,
releaseId: approval.bundleVersion,
environment: approval.targetEnvironment,
artifact: approval.bundleDigest,
returnTo,
...extra,
};
});
}
private mapQueryParams(queryParamMap: ParamMap): Record<string, string> {
return queryParamMap.keys.reduce<Record<string, string>>((params, key) => {
const value = queryParamMap.get(key);
if (value !== null && value.length > 0) {
params[key] = value;
}
return params;
}, {});
}
}

View File

@@ -27,16 +27,14 @@ export const APPROVALS_ROUTES: Routes = [
// A6-02 through A6-05 — Decision cockpit
{
path: ':id',
title: 'Approval Decision',
title: 'Approval Detail',
data: {
breadcrumb: 'Approval Decision',
breadcrumb: 'Approval Detail',
// Available tabs in the decision cockpit:
// overview | gates | security | reachability | ops-data | evidence | replay | history
decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'],
},
loadComponent: () =>
import('../release-orchestrator/approvals/approval-detail/approval-detail.component').then(
(m) => m.ApprovalDetailComponent
),
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
},
];

View File

@@ -13,6 +13,7 @@
"src/app/core/auth/tenant-activation.service.spec.ts",
"src/app/core/console/console-status.service.spec.ts",
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
"src/app/features/approvals/approval-detail-page.component.spec.ts",
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
"src/app/features/admin-notifications/components/channel-management.component.spec.ts",