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:
master
2026-03-20 12:55:08 +02:00
parent 5d67287d0a
commit e56f9a114a
7 changed files with 1292 additions and 478 deletions

View File

@@ -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';
}

View File

@@ -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;
}
}
}

View File

@@ -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');

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 */