Absorb Release Gates into Deployments Approvals tab

Product rationale: Users asking "why is my release blocked?" had to check
3 places (Policy > Release Gates, Deployments > Approvals, Release Detail > Gates).
Now the Deployments Approvals tab is the single surface for all blocking
information with action capability.

Approvals tab enriched:
- Summary cards: Pending Approvals, Gate Failures, Gates Passed counts
- Table columns: Release, Promotion, Gate Type, Status, Reason, Requested, Actions
- Gate Type column shows approval/policy/security/freeze/dependency
- Reason column explains blocking status
- Contextual action: "Policy" link to policy packs when gates are blocking
- Approve/Reject buttons for pending human approvals

Policy sidebar:
- Release Gates removed from Policy group (5 items → 4: Packs, Governance,
  Simulation, VEX & Exceptions, Audit)
- /ops/policy/gates redirects to /releases/deployments?view=approvals
- Sub-routes (gates/catalog, gates/simulate, etc.) retained for deep links
- Policy shell subtitle updated to remove "release gates" mention

No functionality loss — policy authors use Simulation to test rules and
Audit to review change history. The gate evaluation impact on releases
is now visible in context where operators can act on it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 10:52:53 +02:00
parent aaae7d771d
commit 0210c66ef6
2 changed files with 44 additions and 15 deletions

View File

@@ -204,14 +204,30 @@ function deriveOutcomeIcon(status: string): string {
} @else if (filteredApprovals().length === 0) {
<div class="empty-state">No approvals match the active gate filters.</div>
} @else {
<!-- Summary cards -->
<div class="gate-summary">
<div class="gate-summary__card gate-summary__card--pending">
<strong>{{ countByStatus('pending') }}</strong>
<span>Pending Approvals</span>
</div>
<div class="gate-summary__card gate-summary__card--blocked">
<strong>{{ countByGateResult(false) }}</strong>
<span>Gate Failures</span>
</div>
<div class="gate-summary__card gate-summary__card--passed">
<strong>{{ countByGateResult(true) }}</strong>
<span>Gates Passed</span>
</div>
</div>
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Gate Type</th>
<th>Status</th>
<th>Urgency</th>
<th>Gates</th>
<th>Reason</th>
<th>Requested</th>
<th>Actions</th>
</tr>
@@ -226,9 +242,11 @@ function deriveOutcomeIcon(status: string): string {
}
</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-type-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td><span [class.text-success]="apr.gatesPassed" [class.text-danger]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Passed' : 'Failed' }}</span></td>
<td class="muted" [title]="apr.gatesPassed ? 'All gates passed' : 'One or more gates blocking'">
{{ apr.gatesPassed ? 'All gates passed' : 'Gates blocking — review required' }}
</td>
<td class="muted">{{ formatDate(apr.requestedAt) }}<br>by {{ apr.requestedBy }}</td>
<td>
<div class="row-actions">
@@ -236,12 +254,15 @@ function deriveOutcomeIcon(status: string): string {
<button class="apc__btn apc__btn--approve" (click)="onApprove(apr)" type="button">Approve</button>
<button class="apc__btn apc__btn--reject" (click)="onReject(apr)" type="button">Reject</button>
}
@if (!apr.gatesPassed) {
<a class="apc__btn apc__btn--view" routerLink="/ops/policy/packs">Policy</a>
}
<button class="apc__btn apc__btn--view" (click)="onView(apr)" type="button">View</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="7" style="text-align:center;padding:1.5rem;color:var(--color-text-muted)">No approvals match the active gate filters.</td></tr>
<tr><td colspan="7" style="text-align:center;padding:1.5rem;color:var(--color-text-muted)">No approvals or gate evaluations match the current filters.</td></tr>
}
</tbody>
</table>
@@ -395,6 +416,14 @@ function deriveOutcomeIcon(status: string): string {
.apc__status-row{display:flex;gap:.35rem;padding:0 .6rem .45rem;flex-wrap:wrap}
.gate-summary{display:flex;gap:1rem;margin-bottom:1rem}
.gate-summary__card{flex:1;padding:0.75rem 1rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);display:flex;align-items:center;gap:0.5rem}
.gate-summary__card strong{font-size:1.25rem}
.gate-summary__card span{font-size:0.75rem;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.04em}
.gate-summary__card--pending{border-left:3px solid var(--color-status-warning)}
.gate-summary__card--blocked{border-left:3px solid var(--color-severity-high)}
.gate-summary__card--passed{border-left:3px solid var(--color-status-success)}
.gate-type-chip{font-size:0.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;padding:0.125rem 0.5rem;border-radius:var(--radius-sm);background:var(--color-surface-tertiary)}
.pending-lane{margin-bottom:1rem;padding:1rem;border:1px solid var(--color-status-warning-border);border-radius:var(--radius-lg);background:var(--color-status-warning-bg);animation:lane-enter 300ms ease-out both;overflow:hidden}
.pending-lane--exiting{animation:lane-exit 250ms ease-in forwards}
@keyframes lane-enter{from{opacity:0;max-height:0;padding:0;margin:0;border-width:0}to{opacity:1;max-height:500px}}
@@ -523,6 +552,14 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
readonly approvalSearchQuery = signal('');
countByStatus(status: string): number {
return this.filteredApprovals().filter(a => a.status === status).length;
}
countByGateResult(passed: boolean): number {
return this.filteredApprovals().filter(a => a.gatesPassed === passed).length;
}
readonly gateToggleState = signal<Record<string, boolean>>({
gated: false,
policy: false,

View File

@@ -777,7 +777,7 @@ export class AppSidebarComponent implements AfterViewInit {
id: 'ops-environments',
label: 'Environments',
icon: 'globe',
route: '/environments/regions',
route: '/environments/overview',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [
@@ -821,15 +821,7 @@ export class AppSidebarComponent implements AfterViewInit {
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
},
{
id: 'ops-policy-gates',
label: 'Release Gates',
icon: 'check-square',
route: '/ops/policy/gates',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
// Release Gates absorbed into Deployments > Approvals tab
{
id: 'ops-policy-audit',
label: 'Policy Audit',