From e56f9a114a8bb614d5fa57e8c5652adf7f6d70f6 Mon Sep 17 00:00:00 2001 From: master <> Date: Fri, 20 Mar 2026 12:55:08 +0200 Subject: [PATCH] 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) --- .../dashboard-v3/dashboard-v3.component.ts | 896 ++++++++++-------- .../releases/releases-activity.component.ts | 223 +++-- .../releases-unified-page.component.ts | 562 +++++++++++ .../app-sidebar/app-sidebar.component.ts | 65 +- .../app-sidebar/sidebar-nav-item.component.ts | 2 +- .../src/app/routes/releases.routes.ts | 7 +- .../stella-metric-card.component.ts | 15 +- 7 files changed, 1292 insertions(+), 478 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index b56ade6b1..9f1383dc2 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -1,8 +1,8 @@ /** - * Dashboard V3 - Mission Board (3-column redesign) + * Dashboard V3 - Mission Board (single-column layout) * Sprint: SPRINT_20260315_005_FE_dashboard_3col_real_api * - * Layout: CSS Grid with security posture (1/3) | environments + actions (2/3) + * Layout: Single-column flow — Pipeline, Env badges, Posture row (3-up), Risk table, Health bar, Quick links * Data: Real API calls to vulnerability stats, advisory source status, and context store. */ @@ -67,6 +67,16 @@ interface AdvisoryFeedSummary { loaded: boolean; } +interface PendingAction { + id: string; + title: string; + description: string; + count: number; + type: 'approval' | 'deployment' | 'gate' | 'evidence'; + icon: string; + route: string; +} + @Component({ selector: 'app-dashboard-v3', standalone: true, @@ -89,8 +99,27 @@ interface AdvisoryFeedSummary { - @if (hasNoEnvironments()) { - + @if (!contextReady()) { + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } @else if (hasNoEnvironments()) { +

Welcome to Stella Ops

@@ -128,11 +157,140 @@ interface AdvisoryFeedSummary {
} @else { - -
- - - - -
- - - - - - - - - -
-
-

Promotion Pipeline

- All environments -
- -
- @if (showPipelineLeftArrow()) { - - } -
- @for (env of filteredEnvironments(); track env.id) { -
-
-
- {{ env.name }} - {{ env.region }} -
- - {{ env.deployStatus }} - -
- -
-
- SBOM - - {{ env.sbomFreshness }} - -
-
- CritR - - {{ env.critRCount }} - -
-
- HighR - - {{ env.highRCount }} - -
-
- B/I/R - {{ env.birCoverage }} -
-
- Pending - - {{ env.pendingApprovals }} - -
-
- - -
- } - - @if (filteredEnvironments().length === 0) { -
-

No environments match the current filter.

-
- } -
- @if (showPipelineRightArrow()) { - - } -
-
- - - @if (riskEnvironments().length > 0) { -
-
-

Environments at Risk

- Open environments -
-
- @if (riskTableHasOverflow()) { - - } -
- - - - - - - - - - - - - @for (env of riskEnvironments(); track env.id) { - - - - - - - - - } - -
Region/EnvHealthSBOMCritRB/I/RAction
{{ env.region }} / {{ env.name }}{{ env.deployStatus }}{{ env.sbomFreshness }}{{ env.critRCount }}{{ env.birCoverage }} - - Open - -
-
-
-
- } - - -
-
- - Services -
-
- - Feeds -
-
- - Security -
-
- - Evidence -
-
- - DLQ -
- - - Diagnostics -
+
- - - + +
+
+

SBOM Health

+
+
+ + + + + + View SBOM +
+
+ + +
+
+

Environment Health

+
+
+ + + + +
+
+ + +
+
+

Environments at Risk

+ Open all +
+ @if (riskEnvironments().length > 0) { +
+ @if (riskTableHasOverflow()) { + + } +
+ + + + + + + + + + + + @for (env of riskEnvironments(); track env.id) { + + + + + + + + } + +
Region/EnvHealthSBOMCritRAction
{{ env.region }} / {{ env.name }}{{ env.deployStatus }}{{ env.sbomFreshness }}{{ env.critRCount }}Open
+
+
+ } @else { +

No environments at risk.

+ } +
+ + +
+
+ + Services +
+
+ + Feeds +
+
+ + Security +
+
+ + Evidence +
+
+ + DLQ +
+ + + Diagnostics + +
+ + + } `, styles: [` /* ========================================================================= - Mission Board - 3-column CSS Grid Layout + Mission Board - Single-column Layout ========================================================================= */ .mission-board { padding: 1.5rem; max-width: 100%; margin: 0 auto; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto 1fr; - overflow: hidden; - gap: 1.5rem; - min-height: calc(100vh - 120px); + display: flex; + flex-direction: column; + gap: 1.25rem; } /* -- Header (full width) ------------------------------------------------- */ @@ -558,6 +520,11 @@ interface AdvisoryFeedSummary { cursor: not-allowed; } + .refresh-btn:focus-visible { + outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4)); + outline-offset: 2px; + } + /* -- Welcome Guide (full width, shown when no environments) --------------- */ .welcome-guide { background: var(--color-surface-primary); @@ -637,30 +604,56 @@ interface AdvisoryFeedSummary { color: var(--color-text-secondary); } - /* -- Board Body: 2-column layout ----------------------------------------- */ - .board-body { + /* -- Posture Row: 4-column grid ------------------------------------------- */ + .status-lane { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(0, 4fr); - gap: 1.5rem; - align-items: start; - max-width: 100%; - overflow: hidden; + grid-template-columns: 160px 220px 200px 1fr; + gap: 1rem; + align-items: stretch; + } + + .status-lane__col { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .status-lane__card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: visible; + } + + .status-lane__col .status-lane__card:first-child { + flex: 1; + } + + .status-lane__col .status-lane__card:last-child { + flex: 0 0 auto; + } + + .status-lane__badges-body { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.75rem; + } + + .status-lane__badges-body stella-metric-card { + width: auto; + } + + .posture-card--narrow { + /* inherits from .posture-card */ } @media (max-width: 900px) { - .board-body { + .status-lane { grid-template-columns: 1fr; } } - /* -- Left Column: Security Posture --------------------------------------- */ - .security-posture { - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 220px; - } - .posture-card { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); @@ -670,14 +663,16 @@ interface AdvisoryFeedSummary { } .posture-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .posture-card-header { padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary); background: var(--color-surface-elevated); + display: flex; + justify-content: space-between; + align-items: center; } .posture-card-title { @@ -832,30 +827,29 @@ interface AdvisoryFeedSummary { color: var(--color-text-secondary); } - /* -- Right Column: Environments & Actions -------------------------------- */ - .environments-actions { + /* Pipeline actions row (second line inside pending actions card) */ + .pipeline-actions { display: flex; - flex-direction: column; - gap: 1.25rem; - min-width: 0; - overflow: hidden; + gap: 0.75rem; + padding: 0.75rem 1rem 1rem; + overflow-x: auto; + scrollbar-width: none; } + .pipeline-actions::-webkit-scrollbar { display: none; } - /* Mission Summary Strip — now uses stella-metric-grid/card */ - - /* Pipeline Board */ - .pipeline-board { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 1.25rem; + .pipeline-action-card { + min-width: 180px; + flex-shrink: 0; + cursor: pointer; + text-decoration: none; + color: inherit; } .section-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + margin-bottom: 0.75rem; } .section-title { @@ -875,7 +869,6 @@ interface AdvisoryFeedSummary { overflow-x: auto; flex-wrap: nowrap; gap: 0.75rem; - max-height: 220px; padding-bottom: 0.25rem; scrollbar-width: none; scroll-behavior: smooth; @@ -907,6 +900,11 @@ interface AdvisoryFeedSummary { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } + .env-card:focus-visible { + outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4)); + outline-offset: 2px; + } + .env-card.healthy { border-top: 3px solid var(--color-status-success); } .env-card.degraded { border-top: 3px solid var(--color-status-warning); } .env-card.blocked { border-top: 3px solid var(--color-status-error); } @@ -1018,11 +1016,24 @@ interface AdvisoryFeedSummary { .risk-table__container { overflow-x: auto; - max-height: 300px; overflow-y: auto; + flex: 1; scrollbar-width: none; } + /* Make risk table fill its card column */ + .status-lane > .status-lane__card:last-child { + display: flex; + flex-direction: column; + } + + .status-lane > .status-lane__card:last-child .risk-table__scroll-wrapper { + flex: 1; + display: flex; + flex-direction: column; + max-height: 400px; + } + .risk-table__container::-webkit-scrollbar { display: none; } @@ -1124,6 +1135,7 @@ interface AdvisoryFeedSummary { .env-grid-wrapper { position: relative; max-width: 100%; + padding: 0.75rem 1rem 0; } /* Left gradient fade */ @@ -1134,7 +1146,7 @@ interface AdvisoryFeedSummary { left: 0; bottom: 0; width: 56px; - background: linear-gradient(to right, var(--color-surface-secondary) 0%, transparent 100%); + background: linear-gradient(to right, var(--color-surface-primary) 0%, transparent 100%); pointer-events: none; z-index: 1; } @@ -1147,7 +1159,7 @@ interface AdvisoryFeedSummary { right: 0; bottom: 0; width: 56px; - background: linear-gradient(to left, var(--color-surface-secondary) 0%, transparent 100%); + background: linear-gradient(to left, var(--color-surface-primary) 0%, transparent 100%); pointer-events: none; z-index: 1; } @@ -1181,6 +1193,11 @@ interface AdvisoryFeedSummary { transform: scale(0.95); } + .scroll-arrow:focus-visible { + outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4)); + outline-offset: 2px; + } + .scroll-arrow--left { left: 0.5rem; } @@ -1254,21 +1271,91 @@ interface AdvisoryFeedSummary { top: 0.5rem; } + /* -- Risk + Pending Actions Row ------------------------------------------ */ + /* (pending-action styles removed — now uses stella-metric-card) */ + + /* -- Loading skeleton ----------------------------------------------------- */ + .board-loading { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .board-loading__card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + padding: 1.25rem; + } + + .board-loading__card--wide { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .skel-row { + display: flex; + gap: 1rem; + } + + .skel { + border-radius: var(--radius-md); + background: linear-gradient( + 90deg, + var(--color-surface-secondary, rgba(128,128,128,0.1)) 25%, + var(--color-surface-tertiary, rgba(128,128,128,0.2)) 50%, + var(--color-surface-secondary, rgba(128,128,128,0.1)) 75% + ); + background-size: 200% 100%; + animation: skel-shimmer 1.5s ease-in-out infinite; + } + + @keyframes skel-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + .skel--bar { + height: 1rem; + width: 40%; + } + + .skel--card { + flex: 1; + height: 100px; + border-radius: var(--radius-md); + } + + .skel--block { + height: 180px; + width: 100%; + } + + .skel-row .board-loading__card { + flex: 1; + } + /* -- Responsive: collapse to single column on mobile --------------------- */ @media (max-width: 768px) { - .board-body { + .status-lane { grid-template-columns: 1fr; } - .mission-summary { - grid-template-columns: 1fr 1fr; - } - .board-header { flex-direction: column; align-items: flex-start; } } + + /* -- Reduced motion ------------------------------------------------------- */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } `], }) export class DashboardV3Component implements OnInit, AfterViewInit { @@ -1313,7 +1400,8 @@ export class DashboardV3Component implements OnInit, AfterViewInit { }); // -- Context-derived signals ---------------------------------------------- - readonly hasNoEnvironments = computed(() => this.context.environments().length === 0); + readonly contextReady = computed(() => this.context.initialized()); + readonly hasNoEnvironments = computed(() => this.contextReady() && this.context.environments().length === 0); readonly tenantLabel = computed(() => { const user = this.authService.user(); @@ -1468,6 +1556,72 @@ export class DashboardV3Component implements OnInit, AfterViewInit { }; }); + readonly pendingActions = computed(() => { + const actions: PendingAction[] = []; + + // Pending approvals + const pendingApprovals = this.filteredEnvironments().reduce((sum, e) => sum + e.pendingApprovals, 0); + if (pendingApprovals > 0) { + actions.push({ + id: 'approvals', + title: 'Pending Approvals', + description: 'Release approvals awaiting review', + count: pendingApprovals, + type: 'approval', + 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', + }); + } + + // Blocked environments + const blocked = this.blockedCount(); + if (blocked > 0) { + actions.push({ + id: 'blocked', + title: 'Blocked Environments', + description: 'Environments blocking releases', + count: blocked, + type: 'gate', + icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', + route: '/setup/topology/environments', + }); + } + + // Degraded environments + const degraded = this.degradedCount(); + if (degraded > 0) { + actions.push({ + id: 'degraded', + title: 'Degraded Environments', + description: 'Environments needing attention', + count: degraded, + type: 'deployment', + icon: 'M22 12h-4l-3 9L9 3l-3 9H2', + route: '/setup/topology/environments', + }); + } + + // Critical vulnerabilities + const critOpen = this.vulnStats()?.criticalOpen ?? 0; + if (critOpen > 0) { + actions.push({ + id: 'critical-vulns', + title: 'Critical Open', + description: 'Critical vulnerabilities needing triage', + count: critOpen, + type: 'evidence', + icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + route: '/triage/artifacts', + }); + } + + return actions; + }); + + splitIconPaths(icon: string): string[] { + return icon.split('|||').map(p => p.trim()).filter(Boolean); + } + refresh(): void { this.refreshing.set(true); this.loadVulnerabilityStats(); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index f32db3a13..f3c1985ab 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -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: `
@@ -75,48 +76,15 @@ function deriveOutcomeIcon(status: string): string { ariaLabel="Run list views" /> -
- - - - - - - - - - - -
+ @if (error()) { @@ -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([]); 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(() => { + 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): Record { 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'; } diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts new file mode 100644 index 000000000..a287f98c3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts @@ -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: ` +
+
+
+

Releases

+

Unified pipeline view — versions, deployments, hotfixes, and approvals.

+
+
+ + + + + + + + + + + + + + @if (filteredReleases().length === 0) { +
+ +

No releases found

+

Create a new release or adjust your filters.

+
+ } @else { +
+ + + + + + + + + + + + + + @for (r of filteredReleases(); track r.id) { + + + + + + + + + + + + + + + + + } + +
ReleaseStageGatesRiskEvidenceStatusDecisions
+
+ {{ r.name }} + {{ r.version }} + + {{ r.lane === 'hotfix' ? 'Hotfix' : 'Standard' }} + + {{ r.digest | slice:0:19 }} +
+
+ {{ r.environment }} + {{ r.region }} + + + {{ r.gateStatus | uppercase }} + @if (r.gateBlockingCount > 0) { + {{ r.gateBlockingCount }} + } + + + + {{ r.riskTier | uppercase }} + + + + {{ r.evidencePosture === 'verified' ? 'Verified' : r.evidencePosture === 'partial' ? 'Partial' : 'Missing' }} + + + + {{ formatStatus(r.status) }} + + +
+ @if (r.status === 'ready' && r.gateStatus === 'pass') { + + } + @if (r.gatePendingApprovals > 0) { + + + Approve ({{ r.gatePendingApprovals }}) + + } + @if (r.gateStatus === 'block') { + + + Review Gates + + } + @if (r.evidencePosture === 'partial' || r.evidencePosture === 'missing') { + + + View Evidence + + } + @if (r.status === 'deploying' && r.deploymentProgress !== null) { + + + + + {{ r.deploymentProgress }}% + + } + @if (r.status === 'deployed' && r.gateStatus === 'pass') { + + + Promote + + } +
+
+
+ } +
+ `, + 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(MOCK_RELEASES); + readonly searchQuery = signal(''); + readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all'); + readonly statusFilter = signal('all'); + readonly gateFilter = signal('all'); + + readonly pipelineActiveFilters = computed(() => { + 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; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index b8d6815ef..b0e290616 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -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 | 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(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'); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts index bc365b434..6fc465e48 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts @@ -322,7 +322,7 @@ export interface NavItem { @if (!collapsed && badge !== null && badge > 0) { - {{ badge > 99 ? '99+' : badge }} + {{ badge > 9 ? '9+' : badge }} } diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 07afe6f9d..51468146a 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-card.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-card.component.ts index bf73174f5..2e916f499 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-card.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-card.component.ts @@ -49,7 +49,7 @@ import { RouterLink } from '@angular/router';
- {{ label }} + {{ label }} {{ value }} @if (subtitle) { {{ subtitle }} @@ -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 */