Unified releases page + dashboard layout redesign + sidebar restructure
- Create unified releases pipeline page with decision capsules (Deploy, Approve, Review Gates, View Evidence, Promote) - Replace raw select filters with app-filter-bar on releases and activity pages - Dashboard: single-column layout with Pending Actions card (pipeline + action badges), 4-column status lane (Vuln Summary + Feed Status | SBOM Health | Env Health | Environments at Risk), loading skeleton, reduced-motion support - Sidebar: Dashboard at Release Control root, flat menu items (Releases, Versions, Approvals, Activity), remove Promotions/Hotfixes - Metric card labels: proper font size with ellipsis + title tooltip - Badge cap changed from 99+ to 9+ - Action badges on sidebar: blocked gates, critical findings, failed runs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
|
||||
@@ -54,7 +55,7 @@ function deriveOutcomeIcon(status: string): string {
|
||||
@Component({
|
||||
selector: 'app-releases-activity',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent],
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, FilterBarComponent],
|
||||
template: `
|
||||
<section class="activity">
|
||||
<header>
|
||||
@@ -75,48 +76,15 @@ function deriveOutcomeIcon(status: string): string {
|
||||
ariaLabel="Run list views"
|
||||
/>
|
||||
|
||||
<div class="filters">
|
||||
<select [(ngModel)]="statusFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Status: All</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="laneFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Lane: All</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="hotfix">Hotfix</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Environment: All</option>
|
||||
<option value="dev">Dev</option>
|
||||
<option value="stage">Stage</option>
|
||||
<option value="prod">Prod</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="outcomeFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Outcome: All</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="needsApprovalFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Needs Approval: All</option>
|
||||
<option value="true">Needs Approval</option>
|
||||
<option value="false">No Approval Needed</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="integrityFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Data Integrity: All</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="clear">Clear</option>
|
||||
</select>
|
||||
</div>
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search runs..."
|
||||
[filters]="activityFilterOptions"
|
||||
[activeFilters]="activityActiveFilters()"
|
||||
(searchChange)="onActivitySearch($event)"
|
||||
(filterChange)="onActivityFilterAdded($event)"
|
||||
(filterRemove)="onActivityFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllActivityFilters()"
|
||||
/>
|
||||
|
||||
@if (error()) {
|
||||
<div class="banner error">{{ error() }}</div>
|
||||
@@ -210,8 +178,6 @@ function deriveOutcomeIcon(status: string): string {
|
||||
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
|
||||
.context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)}
|
||||
|
||||
.filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.35rem}
|
||||
.filters select{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .45rem;font-size:.72rem}
|
||||
.banner,table,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
|
||||
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
|
||||
@@ -245,34 +211,98 @@ export class ReleasesActivityComponent {
|
||||
readonly rows = signal<ReleaseActivityProjection[]>([]);
|
||||
readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline');
|
||||
|
||||
statusFilter = 'all';
|
||||
laneFilter = 'all';
|
||||
envFilter = 'all';
|
||||
outcomeFilter = 'all';
|
||||
needsApprovalFilter = 'all';
|
||||
integrityFilter = 'all';
|
||||
// ── Filter-bar configuration ──────────────────────────────────────────
|
||||
|
||||
readonly activityFilterOptions: FilterOption[] = [
|
||||
{ key: 'status', label: 'Status', options: [
|
||||
{ value: 'pending_approval', label: 'Pending Approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
]},
|
||||
{ key: 'lane', label: 'Lane', options: [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'hotfix', label: 'Hotfix' },
|
||||
]},
|
||||
{ key: 'env', label: 'Environment', options: [
|
||||
{ value: 'dev', label: 'Dev' },
|
||||
{ value: 'stage', label: 'Stage' },
|
||||
{ value: 'prod', label: 'Prod' },
|
||||
]},
|
||||
{ key: 'outcome', label: 'Outcome', options: [
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
]},
|
||||
{ key: 'needsApproval', label: 'Needs Approval', options: [
|
||||
{ value: 'true', label: 'Needs Approval' },
|
||||
{ value: 'false', label: 'No Approval Needed' },
|
||||
]},
|
||||
{ key: 'integrity', label: 'Data Integrity', options: [
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'clear', label: 'Clear' },
|
||||
]},
|
||||
];
|
||||
|
||||
readonly statusFilter = signal('all');
|
||||
readonly laneFilter = signal('all');
|
||||
readonly envFilter = signal('all');
|
||||
readonly outcomeFilter = signal('all');
|
||||
readonly needsApprovalFilter = signal('all');
|
||||
readonly integrityFilter = signal('all');
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
readonly activityActiveFilters = computed<ActiveFilter[]>(() => {
|
||||
const filters: ActiveFilter[] = [];
|
||||
const pairs: { key: string; value: string }[] = [
|
||||
{ key: 'status', value: this.statusFilter() },
|
||||
{ key: 'lane', value: this.laneFilter() },
|
||||
{ key: 'env', value: this.envFilter() },
|
||||
{ key: 'outcome', value: this.outcomeFilter() },
|
||||
{ key: 'needsApproval', value: this.needsApprovalFilter() },
|
||||
{ key: 'integrity', value: this.integrityFilter() },
|
||||
];
|
||||
|
||||
for (const pair of pairs) {
|
||||
if (pair.value !== 'all') {
|
||||
const opt = this.activityFilterOptions
|
||||
.find(f => f.key === pair.key)?.options
|
||||
.find(o => o.value === pair.value);
|
||||
filters.push({ key: pair.key, value: pair.value, label: opt?.label ?? pair.value });
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
});
|
||||
|
||||
readonly filteredRows = computed(() => {
|
||||
let rows = [...this.rows()];
|
||||
|
||||
if (this.statusFilter !== 'all') {
|
||||
rows = rows.filter((item) => item.status.toLowerCase() === this.statusFilter);
|
||||
const statusF = this.statusFilter();
|
||||
const laneF = this.laneFilter();
|
||||
const envF = this.envFilter();
|
||||
const outcomeF = this.outcomeFilter();
|
||||
const needsApprovalF = this.needsApprovalFilter();
|
||||
const integrityF = this.integrityFilter();
|
||||
|
||||
if (statusF !== 'all') {
|
||||
rows = rows.filter((item) => item.status.toLowerCase() === statusF);
|
||||
}
|
||||
if (this.laneFilter !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveLane(item) === this.laneFilter);
|
||||
if (laneF !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveLane(item) === laneF);
|
||||
}
|
||||
if (this.envFilter !== 'all') {
|
||||
rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(this.envFilter));
|
||||
if (envF !== 'all') {
|
||||
rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(envF));
|
||||
}
|
||||
if (this.outcomeFilter !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveOutcome(item) === this.outcomeFilter);
|
||||
if (outcomeF !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveOutcome(item) === outcomeF);
|
||||
}
|
||||
if (this.needsApprovalFilter !== 'all') {
|
||||
const expected = this.needsApprovalFilter === 'true';
|
||||
if (needsApprovalF !== 'all') {
|
||||
const expected = needsApprovalF === 'true';
|
||||
rows = rows.filter((item) => this.deriveNeedsApproval(item) === expected);
|
||||
}
|
||||
if (this.integrityFilter !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveDataIntegrity(item) === this.integrityFilter);
|
||||
if (integrityF !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveDataIntegrity(item) === integrityF);
|
||||
}
|
||||
|
||||
return rows;
|
||||
@@ -324,7 +354,7 @@ export class ReleasesActivityComponent {
|
||||
this.route.data.subscribe((data) => {
|
||||
const lane = (data['defaultLane'] as string | undefined) ?? null;
|
||||
if (lane === 'hotfix') {
|
||||
this.laneFilter = 'hotfix';
|
||||
this.laneFilter.set('hotfix');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -336,12 +366,12 @@ export class ReleasesActivityComponent {
|
||||
this.viewMode.set('timeline');
|
||||
}
|
||||
|
||||
this.statusFilter = params.get('status') ?? this.statusFilter;
|
||||
this.laneFilter = params.get('lane') ?? this.laneFilter;
|
||||
this.envFilter = params.get('env') ?? this.envFilter;
|
||||
this.outcomeFilter = params.get('outcome') ?? this.outcomeFilter;
|
||||
this.needsApprovalFilter = params.get('needsApproval') ?? this.needsApprovalFilter;
|
||||
this.integrityFilter = params.get('integrity') ?? this.integrityFilter;
|
||||
if (params.get('status')) this.statusFilter.set(params.get('status')!);
|
||||
if (params.get('lane')) this.laneFilter.set(params.get('lane')!);
|
||||
if (params.get('env')) this.envFilter.set(params.get('env')!);
|
||||
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!);
|
||||
if (params.get('needsApproval')) this.needsApprovalFilter.set(params.get('needsApproval')!);
|
||||
if (params.get('integrity')) this.integrityFilter.set(params.get('integrity')!);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -353,12 +383,12 @@ export class ReleasesActivityComponent {
|
||||
mergeQuery(next: Record<string, string>): Record<string, string | null> {
|
||||
return {
|
||||
view: next['view'] ?? this.viewMode(),
|
||||
status: this.statusFilter !== 'all' ? this.statusFilter : null,
|
||||
lane: this.laneFilter !== 'all' ? this.laneFilter : null,
|
||||
env: this.envFilter !== 'all' ? this.envFilter : null,
|
||||
outcome: this.outcomeFilter !== 'all' ? this.outcomeFilter : null,
|
||||
needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null,
|
||||
integrity: this.integrityFilter !== 'all' ? this.integrityFilter : null,
|
||||
status: this.statusFilter() !== 'all' ? this.statusFilter() : null,
|
||||
lane: this.laneFilter() !== 'all' ? this.laneFilter() : null,
|
||||
env: this.envFilter() !== 'all' ? this.envFilter() : null,
|
||||
outcome: this.outcomeFilter() !== 'all' ? this.outcomeFilter() : null,
|
||||
needsApproval: this.needsApprovalFilter() !== 'all' ? this.needsApprovalFilter() : null,
|
||||
integrity: this.integrityFilter() !== 'all' ? this.integrityFilter() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,6 +400,47 @@ export class ReleasesActivityComponent {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter-bar handlers ────────────────────────────────────────────────
|
||||
|
||||
onActivitySearch(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
}
|
||||
|
||||
onActivityFilterAdded(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'status': this.statusFilter.set(f.value); break;
|
||||
case 'lane': this.laneFilter.set(f.value); break;
|
||||
case 'env': this.envFilter.set(f.value); break;
|
||||
case 'outcome': this.outcomeFilter.set(f.value); break;
|
||||
case 'needsApproval': this.needsApprovalFilter.set(f.value); break;
|
||||
case 'integrity': this.integrityFilter.set(f.value); break;
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onActivityFilterRemoved(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'status': this.statusFilter.set('all'); break;
|
||||
case 'lane': this.laneFilter.set('all'); break;
|
||||
case 'env': this.envFilter.set('all'); break;
|
||||
case 'outcome': this.outcomeFilter.set('all'); break;
|
||||
case 'needsApproval': this.needsApprovalFilter.set('all'); break;
|
||||
case 'integrity': this.integrityFilter.set('all'); break;
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
clearAllActivityFilters(): void {
|
||||
this.statusFilter.set('all');
|
||||
this.laneFilter.set('all');
|
||||
this.envFilter.set('all');
|
||||
this.outcomeFilter.set('all');
|
||||
this.needsApprovalFilter.set('all');
|
||||
this.integrityFilter.set('all');
|
||||
this.searchQuery.set('');
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' {
|
||||
return item.releaseName.toLowerCase().includes('hotfix') ? 'hotfix' : 'standard';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Releases Unified Page Component
|
||||
*
|
||||
* Combines release versions, deployments, hotfixes, and approvals into a single
|
||||
* tabbed interface with decision capsules on each release row.
|
||||
*
|
||||
* Tab 1 "Pipeline": unified release table (standard + hotfix) with contextual actions.
|
||||
* Tab 2 "Approvals": embeds the existing ApprovalQueueComponent.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
|
||||
import { UpperCasePipe, SlicePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
|
||||
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
// ── Data model ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PipelineRelease {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
digest: string;
|
||||
lane: 'standard' | 'hotfix';
|
||||
environment: string;
|
||||
region: string;
|
||||
status: 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
|
||||
gateStatus: 'pass' | 'warn' | 'block';
|
||||
gateBlockingCount: number;
|
||||
gatePendingApprovals: number;
|
||||
riskTier: 'critical' | 'high' | 'medium' | 'low' | 'none';
|
||||
evidencePosture: 'verified' | 'partial' | 'missing';
|
||||
deploymentProgress: number | null;
|
||||
updatedAt: string;
|
||||
lastActor: string;
|
||||
}
|
||||
|
||||
// ── Mock data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_RELEASES: PipelineRelease[] = [
|
||||
{
|
||||
id: 'rel-001', name: 'api-gateway', version: 'v2.14.0', digest: 'sha256:a1b2c3d4e5f6',
|
||||
lane: 'standard', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deployed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'low', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T09:15:00Z', lastActor: 'ci-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'rel-002', name: 'payment-svc', version: 'v3.2.1', digest: 'sha256:f7e8d9c0b1a2',
|
||||
lane: 'standard', environment: 'Staging', region: 'eu-west-1',
|
||||
status: 'ready', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'medium', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T08:30:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-003', name: 'auth-service', version: 'v1.9.0', digest: 'sha256:3344556677aa',
|
||||
lane: 'standard', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deploying', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'low', evidencePosture: 'verified', deploymentProgress: 67,
|
||||
updatedAt: '2026-03-20T10:02:00Z', lastActor: 'deploy-bot',
|
||||
},
|
||||
{
|
||||
id: 'rel-004', name: 'scanner-engine', version: 'v4.0.0-rc1', digest: 'sha256:bb11cc22dd33',
|
||||
lane: 'standard', environment: 'QA', region: 'us-west-2',
|
||||
status: 'ready', gateStatus: 'block', gateBlockingCount: 3, gatePendingApprovals: 2,
|
||||
riskTier: 'high', evidencePosture: 'partial', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T22:45:00Z', lastActor: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'rel-005', name: 'notification-hub', version: 'v2.1.0', digest: 'sha256:ee44ff55aa66',
|
||||
lane: 'standard', environment: 'Dev', region: 'us-east-1',
|
||||
status: 'draft', gateStatus: 'warn', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'none', evidencePosture: 'missing', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T16:20:00Z', lastActor: 'bob',
|
||||
},
|
||||
{
|
||||
id: 'rel-006', name: 'api-gateway', version: 'v2.13.9-hotfix.1', digest: 'sha256:1a2b3c4d5e6f',
|
||||
lane: 'hotfix', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deploying', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'critical', evidencePosture: 'verified', deploymentProgress: 34,
|
||||
updatedAt: '2026-03-20T10:10:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-007', name: 'billing-service', version: 'v5.0.2', digest: 'sha256:77889900aabb',
|
||||
lane: 'standard', environment: 'Staging', region: 'ap-southeast-1',
|
||||
status: 'failed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'medium', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T07:55:00Z', lastActor: 'ci-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'rel-008', name: 'evidence-locker', version: 'v1.3.0', digest: 'sha256:ccddee112233',
|
||||
lane: 'standard', environment: 'QA', region: 'eu-west-1',
|
||||
status: 'ready', gateStatus: 'warn', gateBlockingCount: 1, gatePendingApprovals: 1,
|
||||
riskTier: 'low', evidencePosture: 'partial', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T20:30:00Z', lastActor: 'carol',
|
||||
},
|
||||
{
|
||||
id: 'rel-009', name: 'policy-engine', version: 'v2.0.0-hotfix.3', digest: 'sha256:aabb11223344',
|
||||
lane: 'hotfix', environment: 'Production', region: 'eu-west-1',
|
||||
status: 'deployed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'high', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T06:15:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-010', name: 'feed-mirror', version: 'v1.7.0', digest: 'sha256:5566778899dd',
|
||||
lane: 'standard', environment: 'Staging', region: 'us-east-1',
|
||||
status: 'rolled_back', gateStatus: 'block', gateBlockingCount: 2, gatePendingApprovals: 0,
|
||||
riskTier: 'critical', evidencePosture: 'missing', deploymentProgress: null,
|
||||
updatedAt: '2026-03-18T14:10:00Z', lastActor: 'deploy-bot',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Component({
|
||||
selector: 'app-releases-unified-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
UpperCasePipe,
|
||||
SlicePipe,
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
StellaMetricCardComponent,
|
||||
StellaMetricGridComponent,
|
||||
FilterBarComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="rup">
|
||||
<header class="rup__header">
|
||||
<div>
|
||||
<h1 class="rup__title">Releases</h1>
|
||||
<p class="rup__subtitle">Unified pipeline view — versions, deployments, hotfixes, and approvals.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Metric cards -->
|
||||
<stella-metric-grid [columns]="4">
|
||||
<stella-metric-card
|
||||
label="Total Releases"
|
||||
[value]="'' + totalReleases()"
|
||||
subtitle="All versions in pipeline"
|
||||
icon="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Active Deployments"
|
||||
[value]="'' + activeDeployments()"
|
||||
subtitle="Currently deploying"
|
||||
icon="M12 19V5|||M5 12l7-7 7 7"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Gates Blocked"
|
||||
[value]="'' + gatesBlocked()"
|
||||
subtitle="Require attention"
|
||||
icon="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Pending Approvals"
|
||||
[value]="'' + pendingApprovals()"
|
||||
subtitle="Awaiting review"
|
||||
icon="M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
|
||||
route="/releases/approvals"
|
||||
/>
|
||||
</stella-metric-grid>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<div class="rup__toolbar">
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search releases..."
|
||||
[filters]="pipelineFilterOptions"
|
||||
[activeFilters]="pipelineActiveFilters()"
|
||||
(searchChange)="searchQuery.set($event)"
|
||||
(filterChange)="onPipelineFilterAdded($event)"
|
||||
(filterRemove)="onPipelineFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllPipelineFilters()"
|
||||
/>
|
||||
<div class="rup__toolbar-actions">
|
||||
<a routerLink="/releases/versions/new" class="btn btn--primary btn--sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Release
|
||||
</a>
|
||||
<a routerLink="/releases/versions/new" [queryParams]="{ type: 'hotfix', hotfixLane: 'true' }" class="btn btn--warning btn--sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
Hotfix
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Releases table -->
|
||||
@if (filteredReleases().length === 0) {
|
||||
<div class="rup__empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<p class="rup__empty-title">No releases found</p>
|
||||
<p class="rup__empty-text">Create a new release or adjust your filters.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rup__table-wrap">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Release</th>
|
||||
<th>Stage</th>
|
||||
<th>Gates</th>
|
||||
<th>Risk</th>
|
||||
<th>Evidence</th>
|
||||
<th>Status</th>
|
||||
<th>Decisions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredReleases(); track r.id) {
|
||||
<tr>
|
||||
<!-- Release -->
|
||||
<td>
|
||||
<div class="rup__release-cell">
|
||||
<a class="rup__release-name" [routerLink]="['/releases/versions', r.id]">{{ r.name }}</a>
|
||||
<span class="rup__release-version">{{ r.version }}</span>
|
||||
<span class="rup__lane-badge" [class.rup__lane-badge--hotfix]="r.lane === 'hotfix'">
|
||||
{{ r.lane === 'hotfix' ? 'Hotfix' : 'Standard' }}
|
||||
</span>
|
||||
<span class="rup__digest" [title]="r.digest">{{ r.digest | slice:0:19 }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Stage -->
|
||||
<td>
|
||||
<span class="rup__stage">{{ r.environment }}</span>
|
||||
<span class="rup__region">{{ r.region }}</span>
|
||||
</td>
|
||||
<!-- Gates -->
|
||||
<td>
|
||||
<span class="rup__badge"
|
||||
[class.rup__badge--success]="r.gateStatus === 'pass'"
|
||||
[class.rup__badge--warning]="r.gateStatus === 'warn'"
|
||||
[class.rup__badge--error]="r.gateStatus === 'block'">
|
||||
{{ r.gateStatus | uppercase }}
|
||||
@if (r.gateBlockingCount > 0) {
|
||||
<span class="rup__badge-count">{{ r.gateBlockingCount }}</span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Risk -->
|
||||
<td>
|
||||
<span class="rup__risk-badge"
|
||||
[attr.data-tier]="r.riskTier">
|
||||
{{ r.riskTier | uppercase }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Evidence -->
|
||||
<td>
|
||||
<span class="rup__evidence-badge"
|
||||
[class.rup__evidence-badge--verified]="r.evidencePosture === 'verified'"
|
||||
[class.rup__evidence-badge--partial]="r.evidencePosture === 'partial'"
|
||||
[class.rup__evidence-badge--missing]="r.evidencePosture === 'missing'">
|
||||
{{ r.evidencePosture === 'verified' ? 'Verified' : r.evidencePosture === 'partial' ? 'Partial' : 'Missing' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Status -->
|
||||
<td>
|
||||
<span class="rup__status-badge" [attr.data-status]="r.status">
|
||||
{{ formatStatus(r.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Decisions -->
|
||||
<td>
|
||||
<div class="rup__decisions">
|
||||
@if (r.status === 'ready' && r.gateStatus === 'pass') {
|
||||
<button type="button" class="decision-capsule decision-capsule--deploy">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
|
||||
Deploy
|
||||
</button>
|
||||
}
|
||||
@if (r.gatePendingApprovals > 0) {
|
||||
<a class="decision-capsule decision-capsule--approve" routerLink="/releases/approvals">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l3 3L22 4"/></svg>
|
||||
Approve ({{ r.gatePendingApprovals }})
|
||||
</a>
|
||||
}
|
||||
@if (r.gateStatus === 'block') {
|
||||
<a class="decision-capsule decision-capsule--review" [routerLink]="['/releases/versions', r.id]" [queryParams]="{ tab: 'gates' }">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Review Gates
|
||||
</a>
|
||||
}
|
||||
@if (r.evidencePosture === 'partial' || r.evidencePosture === 'missing') {
|
||||
<a class="decision-capsule decision-capsule--evidence" [routerLink]="['/releases/versions', r.id]" [queryParams]="{ tab: 'evidence' }">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
@if (r.status === 'deploying' && r.deploymentProgress !== null) {
|
||||
<span class="decision-capsule decision-capsule--progress">
|
||||
<span class="decision-capsule__progress-track">
|
||||
<span class="decision-capsule__progress-fill" [style.width.%]="r.deploymentProgress"></span>
|
||||
</span>
|
||||
<span class="decision-capsule__progress-text">{{ r.deploymentProgress }}%</span>
|
||||
</span>
|
||||
}
|
||||
@if (r.status === 'deployed' && r.gateStatus === 'pass') {
|
||||
<a class="decision-capsule decision-capsule--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>
|
||||
Promote
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.rup { padding: 1.5rem; max-width: 1440px; }
|
||||
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
|
||||
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
:host ::ng-deep stella-metric-grid { margin-bottom: 1.25rem; }
|
||||
.rup__toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
:host ::ng-deep app-filter-bar { flex: 1 1 0; min-width: 0; }
|
||||
.rup__toolbar-actions { display: flex; gap: 0.375rem; margin-left: auto; padding-top: 0.5rem; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 0.375rem; padding: 0 0.75rem;
|
||||
border: none; border-radius: var(--radius-md, 6px); font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold, 600); cursor: pointer; text-decoration: none;
|
||||
white-space: nowrap; transition: background 150ms ease, box-shadow 150ms ease; line-height: 1;
|
||||
}
|
||||
.btn--sm { height: 32px; }
|
||||
.btn--primary { background: var(--color-btn-primary-bg); color: var(--color-surface-inverse, #fff); }
|
||||
.btn--primary:hover { box-shadow: var(--shadow-sm); }
|
||||
.btn--warning { background: var(--color-status-warning, #C89820); color: #fff; }
|
||||
.btn--warning:hover { box-shadow: var(--shadow-sm); }
|
||||
|
||||
.rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
.rup__release-cell { display: flex; flex-direction: column; gap: 0.125rem; min-width: 180px; }
|
||||
.rup__release-name { font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); text-decoration: none; font-size: 0.8125rem; }
|
||||
.rup__release-name:hover { text-decoration: underline; }
|
||||
.rup__release-version { font-size: 0.75rem; color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
|
||||
.rup__digest { font-size: 0.625rem; color: var(--color-text-muted); font-family: var(--font-mono, monospace); }
|
||||
|
||||
.rup__lane-badge {
|
||||
display: inline-block; width: fit-content; padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-full, 9999px); font-size: 0.5625rem;
|
||||
font-weight: var(--font-weight-semibold, 600); text-transform: uppercase;
|
||||
letter-spacing: 0.04em; background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary); border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
.rup__lane-badge--hotfix { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); border-color: var(--color-status-warning, #C89820); }
|
||||
|
||||
.rup__stage { display: block; font-size: 0.8125rem; color: var(--color-text-primary); font-weight: var(--font-weight-medium, 500); }
|
||||
.rup__region { display: block; font-size: 0.6875rem; color: var(--color-text-muted); }
|
||||
|
||||
/* Shared pill base for gate/risk/evidence/status badges */
|
||||
.rup__badge, .rup__risk-badge, .rup__evidence-badge, .rup__status-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-full, 9999px); font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
}
|
||||
.rup__badge { text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.rup__badge--success { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__badge--warning { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__badge--error { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__badge-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: currentColor; color: #fff; font-size: 0.5625rem; font-weight: 700; }
|
||||
|
||||
.rup__risk-badge[data-tier="critical"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__risk-badge[data-tier="high"] { background: #FFF3E0; color: #E65100; }
|
||||
.rup__risk-badge[data-tier="medium"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__risk-badge[data-tier="low"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__risk-badge[data-tier="none"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
|
||||
.rup__evidence-badge--verified { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__evidence-badge--partial { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__evidence-badge--missing { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
|
||||
.rup__status-badge { text-transform: capitalize; }
|
||||
.rup__status-badge[data-status="draft"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
.rup__status-badge[data-status="ready"] { background: #E3F2FD; color: #1565C0; }
|
||||
.rup__status-badge[data-status="deploying"] { background: #EDE7F6; color: #6A1B9A; }
|
||||
.rup__status-badge[data-status="deployed"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__status-badge[data-status="failed"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__status-badge[data-status="rolled_back"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
|
||||
.rup__decisions { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||
.rup__decisions-done { color: var(--color-status-success, #2E7D32); display: inline-flex; }
|
||||
|
||||
.decision-capsule {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-full, 9999px); font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold, 600); cursor: pointer;
|
||||
border: 1px solid transparent; transition: all 150ms ease;
|
||||
text-decoration: none; white-space: nowrap; line-height: 1.3; background: transparent;
|
||||
}
|
||||
.decision-capsule--deploy { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); border-color: var(--color-status-success, #2E7D32); }
|
||||
.decision-capsule--deploy:hover { background: var(--color-status-success, #2E7D32); color: #fff; }
|
||||
.decision-capsule--approve { background: #E3F2FD; color: #1565C0; border-color: #1565C0; }
|
||||
.decision-capsule--approve:hover { background: #1565C0; color: #fff; }
|
||||
.decision-capsule--review { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); border-color: var(--color-status-warning, #C89820); }
|
||||
.decision-capsule--review:hover { background: var(--color-status-warning, #C89820); color: #fff; }
|
||||
.decision-capsule--evidence { background: #F3E5F5; color: #7B1FA2; border-color: #7B1FA2; }
|
||||
.decision-capsule--evidence:hover { background: #7B1FA2; color: #fff; }
|
||||
.decision-capsule--promote { background: #E0F2F1; color: #00695C; border-color: #00695C; }
|
||||
.decision-capsule--promote:hover { background: #00695C; color: #fff; }
|
||||
.decision-capsule--progress { cursor: default; background: var(--color-surface-secondary); border-color: var(--color-border-primary); gap: 0.375rem; }
|
||||
.decision-capsule__progress-track { width: 48px; height: 6px; border-radius: 3px; background: var(--color-border-primary); overflow: hidden; }
|
||||
.decision-capsule__progress-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-brand-primary, #4F46E5); transition: width 300ms ease; }
|
||||
.decision-capsule__progress-text { font-size: 0.625rem; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.rup__empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem 1rem; color: var(--color-text-muted); text-align: center; }
|
||||
.rup__empty svg { margin-bottom: 1rem; opacity: 0.4; }
|
||||
.rup__empty-title { font-size: 1rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-secondary); margin: 0 0 0.25rem; }
|
||||
.rup__empty-text { font-size: 0.8125rem; margin: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rup { padding: 1rem; }
|
||||
.rup__toolbar { flex-direction: column; align-items: stretch; }
|
||||
.rup__toolbar-actions { margin-left: 0; justify-content: flex-end; }
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ReleasesUnifiedPageComponent {
|
||||
// ── Filter-bar configuration ──────────────────────────────────────────
|
||||
|
||||
readonly pipelineFilterOptions: FilterOption[] = [
|
||||
{ key: 'lane', label: 'Lane', options: [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'hotfix', label: 'Hotfix' },
|
||||
]},
|
||||
{ key: 'status', label: 'Status', options: [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'ready', label: 'Ready' },
|
||||
{ value: 'deploying', label: 'Deploying' },
|
||||
{ value: 'deployed', label: 'Deployed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'rolled_back', label: 'Rolled Back' },
|
||||
]},
|
||||
{ key: 'gate', label: 'Gates', options: [
|
||||
{ value: 'pass', label: 'Pass' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
]},
|
||||
];
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
|
||||
readonly searchQuery = signal('');
|
||||
readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all');
|
||||
readonly statusFilter = signal<string>('all');
|
||||
readonly gateFilter = signal<string>('all');
|
||||
|
||||
readonly pipelineActiveFilters = computed<ActiveFilter[]>(() => {
|
||||
const filters: ActiveFilter[] = [];
|
||||
const lane = this.laneFilter();
|
||||
const status = this.statusFilter();
|
||||
const gate = this.gateFilter();
|
||||
|
||||
if (lane !== 'all') {
|
||||
const opt = this.pipelineFilterOptions.find(f => f.key === 'lane')?.options.find(o => o.value === lane);
|
||||
filters.push({ key: 'lane', value: lane, label: opt?.label ?? lane });
|
||||
}
|
||||
if (status !== 'all') {
|
||||
const opt = this.pipelineFilterOptions.find(f => f.key === 'status')?.options.find(o => o.value === status);
|
||||
filters.push({ key: 'status', value: status, label: opt?.label ?? status });
|
||||
}
|
||||
if (gate !== 'all') {
|
||||
const opt = this.pipelineFilterOptions.find(f => f.key === 'gate')?.options.find(o => o.value === gate);
|
||||
filters.push({ key: 'gate', value: gate, label: opt?.label ?? gate });
|
||||
}
|
||||
return filters;
|
||||
});
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────
|
||||
|
||||
readonly totalReleases = computed(() => this.releases().length);
|
||||
|
||||
readonly activeDeployments = computed(
|
||||
() => this.releases().filter((r) => r.status === 'deploying').length,
|
||||
);
|
||||
|
||||
readonly gatesBlocked = computed(
|
||||
() => this.releases().filter((r) => r.gateStatus === 'block').length,
|
||||
);
|
||||
|
||||
readonly pendingApprovals = computed(() =>
|
||||
this.releases().reduce((sum, r) => sum + r.gatePendingApprovals, 0),
|
||||
);
|
||||
|
||||
readonly filteredReleases = computed(() => {
|
||||
let list = this.releases();
|
||||
const q = this.searchQuery().toLowerCase().trim();
|
||||
const lane = this.laneFilter();
|
||||
const status = this.statusFilter();
|
||||
const gate = this.gateFilter();
|
||||
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.version.toLowerCase().includes(q) ||
|
||||
r.digest.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (lane !== 'all') {
|
||||
list = list.filter((r) => r.lane === lane);
|
||||
}
|
||||
if (status !== 'all') {
|
||||
list = list.filter((r) => r.status === status);
|
||||
}
|
||||
if (gate !== 'all') {
|
||||
list = list.filter((r) => r.gateStatus === gate);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
// ── Filter-bar handlers ────────────────────────────────────────────────
|
||||
|
||||
onPipelineFilterAdded(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break;
|
||||
case 'status': this.statusFilter.set(f.value); break;
|
||||
case 'gate': this.gateFilter.set(f.value); break;
|
||||
}
|
||||
}
|
||||
|
||||
onPipelineFilterRemoved(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'lane': this.laneFilter.set('all'); break;
|
||||
case 'status': this.statusFilter.set('all'); break;
|
||||
case 'gate': this.gateFilter.set('all'); break;
|
||||
}
|
||||
}
|
||||
|
||||
clearAllPipelineFilters(): void {
|
||||
this.laneFilter.set('all');
|
||||
this.statusFilter.set('all');
|
||||
this.gateFilter.set('all');
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
formatStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'rolled_back': return 'Rolled Back';
|
||||
case 'deploying': return 'Deploying';
|
||||
case 'deployed': return 'Deployed';
|
||||
case 'draft': return 'Draft';
|
||||
case 'ready': return 'Ready';
|
||||
case 'failed': return 'Failed';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NgZone,
|
||||
} from '@angular/core';
|
||||
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
|
||||
@@ -635,6 +636,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly doctorTrendService = inject(DoctorTrendService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@@ -664,6 +666,9 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
private flyoutLeaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly pendingApprovalsCount = signal(0);
|
||||
private readonly blockedGatesCount = signal(0);
|
||||
private readonly criticalFindingsCount = signal(0);
|
||||
private readonly failedRunsCount = signal(0);
|
||||
private readonly pendingApprovalsBadgeLoadedAt = signal<number | null>(null);
|
||||
private readonly pendingApprovalsBadgeLoading = signal(false);
|
||||
|
||||
@@ -690,36 +695,26 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
id: 'releases',
|
||||
label: 'Releases',
|
||||
icon: 'package',
|
||||
route: '/releases/deployments',
|
||||
route: '/releases',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
badge$: () => this.blockedGatesCount(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
],
|
||||
children: [
|
||||
{
|
||||
id: 'rel-versions',
|
||||
label: 'Versions',
|
||||
route: '/releases/versions',
|
||||
icon: 'package',
|
||||
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
|
||||
},
|
||||
{ id: 'rel-deployments', label: 'Deployments', route: '/releases/deployments', icon: 'upload-cloud' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'promotions',
|
||||
label: 'Promotions',
|
||||
icon: 'git-merge',
|
||||
route: '/releases/promotions',
|
||||
id: 'versions',
|
||||
label: 'Versions',
|
||||
icon: 'layers',
|
||||
route: '/releases/versions',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -738,15 +733,15 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hotfixes',
|
||||
label: 'Hotfixes',
|
||||
icon: 'zap',
|
||||
route: '/releases/hotfixes',
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
icon: 'clock',
|
||||
route: '/releases/runs',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
badge$: () => this.failedRunsCount(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
],
|
||||
},
|
||||
// ── Group 2: Security ────────────────────────────────────────────
|
||||
@@ -757,6 +752,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
route: '/triage/artifacts',
|
||||
menuGroupId: 'security',
|
||||
menuGroupLabel: 'Security',
|
||||
badge$: () => this.criticalFindingsCount(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
StellaOpsScopes.FINDINGS_READ,
|
||||
@@ -1076,6 +1072,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
|
||||
constructor() {
|
||||
this.loadPendingApprovalsBadge(true);
|
||||
this.loadActionBadges();
|
||||
this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
|
||||
this.router.events
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -1235,6 +1232,32 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
});
|
||||
}
|
||||
|
||||
private loadActionBadges(): void {
|
||||
// Blocked gates count
|
||||
this.http.get<{ items?: unknown[] }>('/api/v2/releases/versions?gateStatus=block&limit=0').pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe({
|
||||
next: (res) => this.blockedGatesCount.set(res.items?.length ?? 0),
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
// Critical findings needing triage
|
||||
this.http.get<{ totalCount?: number; items?: unknown[] }>('/api/v2/security/findings?severity=critical&disposition=unreviewed&limit=0').pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe({
|
||||
next: (res) => this.criticalFindingsCount.set(res.totalCount ?? res.items?.length ?? 0),
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
// Failed runs in last 24h
|
||||
this.http.get<{ items?: unknown[] }>('/api/v2/releases/activity?outcome=failed&limit=0').pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe({
|
||||
next: (res) => this.failedRunsCount.set(res.items?.length ?? 0),
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private shouldForcePendingApprovalsRefresh(url: string): boolean {
|
||||
const path = (url || '').split('?')[0] ?? '';
|
||||
return path.startsWith('/releases/approvals') || path.startsWith('/releases/promotions');
|
||||
|
||||
@@ -322,7 +322,7 @@ export interface NavItem {
|
||||
|
||||
@if (!collapsed && badge !== null && badge > 0) {
|
||||
<span class="nav-item__badge" [attr.aria-label]="badge + ' pending'">
|
||||
{{ badge > 99 ? '99+' : badge }}
|
||||
{{ badge > 9 ? '9+' : badge }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
|
||||
@@ -43,8 +43,13 @@ function preserveReleasesRedirectWithQuery(template: string, fixedQueryParams: R
|
||||
export const RELEASES_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'deployments',
|
||||
title: 'Releases',
|
||||
data: { breadcrumb: 'Releases' },
|
||||
pathMatch: 'full' as const,
|
||||
loadComponent: () =>
|
||||
import('../features/releases/releases-unified-page.component').then(
|
||||
(m) => m.ReleasesUnifiedPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
|
||||
@@ -49,7 +49,7 @@ import { RouterLink } from '@angular/router';
|
||||
</svg>
|
||||
</div>
|
||||
<div class="smc__body">
|
||||
<span class="smc__label">{{ label }}</span>
|
||||
<span class="smc__label" [title]="label">{{ label }}</span>
|
||||
<span class="smc__value">{{ value }}</span>
|
||||
@if (subtitle) {
|
||||
<span class="smc__subtitle" [title]="subtitle">{{ subtitle }}</span>
|
||||
@@ -126,20 +126,19 @@ import { RouterLink } from '@angular/router';
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Label — row 1, max 2 lines, fixed height */
|
||||
/* Label — row 1, ellipsis + hover hint */
|
||||
.smc__label {
|
||||
grid-column: 2;
|
||||
font-size: 0.5625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.3;
|
||||
height: 1.5em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Value — row 2, centered, always one line */
|
||||
|
||||
Reference in New Issue
Block a user