Fix 11 UI consistency issues across web console

Naming consistency:
- Dashboard heading "Mission Board" → "Dashboard" to match breadcrumb/title
- Vulnerabilities page: "Artifact workspace" → "Vulnerabilities" (heading, breadcrumb, route title)
- Doctor checks: "NOT RAN" → "NOT RUN" (grammar fix)
- Doctor pack categories: add label overrides for "servicegraph" → "Service Graph" and "binaryanalysis" → "Binary Analysis"
- Release digest placeholder: "digest-unavailable" → "Pending digest"

Bug fixes:
- Register locale data for all 8 supported locales (bg-BG, de-DE, ru-RU, es-ES, fr-FR, uk-UA, zh-TW, zh-CN) to fix NG02100 InvalidPipeArgument errors on Audit Log and other pages using Angular built-in pipes
- Null-safe json/number pipes in audit-log components (audit-log-table, audit-event-detail, audit-integrations, audit-export)
- Approval version fallback: use empty string instead of releaseName to prevent duplicate text in approval cards
- Approval card template: hide version span when it matches the release name

Layout/UX:
- stella-page-tabs: enable horizontal scroll on desktop (was mobile-only), prevents tab wrapping on Diagnostics (11 tabs), Audit Log (9 tabs)
- Triage date formatting: use DateFormatService for locale-aware dates instead of bare toLocaleString()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-27 11:46:17 +02:00
parent 2bc06169f8
commit cf20a8bc06
17 changed files with 671 additions and 503 deletions

View File

@@ -1,3 +1,12 @@
import { registerLocaleData } from '@angular/common';
import localeBg from '@angular/common/locales/bg';
import localeDe from '@angular/common/locales/de';
import localeRu from '@angular/common/locales/ru';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeUk from '@angular/common/locales/uk';
import localeZhHant from '@angular/common/locales/zh-Hant';
import localeZhHans from '@angular/common/locales/zh-Hans';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, inject, LOCALE_ID, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
@@ -5,6 +14,16 @@ import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angula
import { provideMarkdown } from 'ngx-markdown';
import { PageTitleStrategy } from './core/navigation/page-title.strategy';
// Register locale data for Angular built-in pipes (DecimalPipe, DatePipe, etc.)
registerLocaleData(localeBg, 'bg-BG');
registerLocaleData(localeDe, 'de-DE');
registerLocaleData(localeRu, 'ru-RU');
registerLocaleData(localeEs, 'es-ES');
registerLocaleData(localeFr, 'fr-FR');
registerLocaleData(localeUk, 'uk-UA');
registerLocaleData(localeZhHant, 'zh-TW');
registerLocaleData(localeZhHans, 'zh-CN');
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
import {
@@ -37,6 +56,7 @@ import {
} from './core/api/vulnerability-http.client';
import { RISK_API, MockRiskApi } from './core/api/risk.client';
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
import { SCRIPTS_API, MockScriptsClient } from './core/api/scripts.client';
import { AppConfigService } from './core/config/app-config.service';
import { I18nService } from './core/i18n';
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
@@ -1120,6 +1140,13 @@ export const appConfig: ApplicationConfig = {
useExisting: IdentityProviderApiHttpClient,
},
// Scripts API (mock client — no backend persistence yet)
MockScriptsClient,
{
provide: SCRIPTS_API,
useExisting: MockScriptsClient,
},
// Doctor background services — started from AppComponent to avoid
// NG0200 circular DI during APP_INITIALIZER (Router not yet ready).
DoctorTrendService,

View File

@@ -122,7 +122,7 @@ export class ApprovalHttpClient implements ApprovalApi {
id: row.approvalId ?? row.id,
releaseId: row.releaseId,
releaseName: row.releaseName,
releaseVersion: row.releaseVersion ?? row.releaseName,
releaseVersion: row.releaseVersion ?? '',
sourceEnvironment: row.sourceEnvironment,
targetEnvironment: row.targetEnvironment,
requestedBy: row.requestedBy,

View File

@@ -99,7 +99,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
<div class="details-section">
<h3>Details</h3>
<pre class="json-block">{{ event()?.details | json }}</pre>
<pre class="json-block">{{ (event()?.details ?? {}) | json }}</pre>
</div>
@if (event()?.diff) {
@@ -108,11 +108,11 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ event()?.diff?.before | json }}</pre>
<pre>{{ (event()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ event()?.diff?.after | json }}</pre>
<pre>{{ (event()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (event()?.diff?.fields?.length) {

View File

@@ -122,7 +122,7 @@ import { AuditExportRequest, AuditExportResponse, AuditLogFilters, AuditModule,
<tr>
<td class="mono">{{ exp.exportId.slice(0, 8) }}...</td>
<td><span class="badge" [class]="exp.status">{{ exp.status }}</span></td>
<td>{{ exp.eventCount | number }}</td>
<td>{{ (exp.eventCount ?? 0) | number }}</td>
<td class="mono">{{ formatTime(exp.createdAt) }}</td>
<td class="mono">{{ exp.expiresAt ? formatTime(exp.expiresAt) : '-' }}</td>
<td>

View File

@@ -85,11 +85,11 @@ import { AuditEvent } from '../../core/api/audit-log.models';
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ selectedEvent()?.diff?.before | json }}</pre>
<pre>{{ (selectedEvent()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ selectedEvent()?.diff?.after | json }}</pre>
<pre>{{ (selectedEvent()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
</div>

View File

@@ -6,165 +6,159 @@ import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '../../core/api/audit-log.models';
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 { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { AuditPolicyComponent } from './audit-policy.component';
import { AuditAuthorityComponent } from './audit-authority.component';
import { AuditVexComponent } from './audit-vex.component';
import { AuditIntegrationsComponent } from './audit-integrations.component';
import { AuditTrustComponent } from './audit-trust.component';
import { AuditTimelineSearchComponent } from './audit-timeline-search.component';
import { AuditCorrelationsComponent } from './audit-correlations.component';
import { AuditLogTableComponent } from './audit-log-table.component';
const AUDIT_TABS: StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10' },
{ id: 'all-events', label: 'All Events', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'policy', label: 'Policy', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'authority', label: 'Authority', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' },
{ id: 'vex', label: 'VEX', 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' },
{ id: 'integrations', label: 'Integrations', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' },
{ id: 'trust', label: 'Trust', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z|||M9 12l2 2 4-4' },
{ id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
];
@Component({
selector: 'app-audit-log-dashboard',
imports: [CommonModule, RouterModule, StellaMetricCardComponent, StellaMetricGridComponent],
imports: [
CommonModule, RouterModule,
StellaMetricCardComponent, StellaMetricGridComponent, StellaPageTabsComponent,
AuditPolicyComponent, AuditAuthorityComponent, AuditVexComponent,
AuditIntegrationsComponent, AuditTrustComponent,
AuditTimelineSearchComponent, AuditCorrelationsComponent, AuditLogTableComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-dashboard">
<header class="page-header">
<h1>Unified Audit Log</h1>
<p class="description">Cross-module audit trail visibility for compliance and governance</p>
<div class="header-actions">
<a routerLink="events" class="btn-primary">View All Events</a>
<a routerLink="/evidence/exports" class="btn-secondary">Export</a>
</div>
</header>
@if (stats()) {
<stella-metric-grid [columns]="moduleStats().length + 1">
<stella-metric-card
label="Total Events (7d)"
[value]="(stats()?.totalEvents | number) ?? '0'"
icon="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8"
/>
@for (entry of moduleStats(); track entry.module) {
<stella-metric-card
[label]="formatModule(entry.module)"
[value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)"
/>
}
</stella-metric-grid>
}
<stella-page-tabs
[tabs]="auditTabs"
[activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Audit log sections"
>
@switch (activeTab()) {
@case ('overview') {
<!-- Overview: stats + anomalies + recent events -->
@if (stats()) {
<stella-metric-grid [columns]="moduleStats().length + 1">
<stella-metric-card
label="Total Events (7d)"
[value]="(stats()?.totalEvents | number) ?? '0'"
icon="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8"
/>
@for (entry of moduleStats(); track entry.module) {
<stella-metric-card
[label]="formatModule(entry.module)"
[value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)"
/>
}
</stella-metric-grid>
}
@if (allCountsZero()) {
<div class="audit-log__empty-guidance">
<p>Audit events will appear as the platform is used. Events are captured automatically for:</p>
<ul>
<li>Release seals, promotions, and approvals</li>
<li>Policy changes and activations</li>
<li>VEX decisions and consensus votes</li>
<li>Integration configuration changes</li>
<li>Identity and access management</li>
</ul>
<p class="audit-log__status-note">The Evidence rail indicator shows <strong>ON</strong> — audit capture is active.</p>
</div>
}
@if (anomalies().length > 0) {
<section class="anomaly-alerts">
<h2>Anomaly Alerts</h2>
<div class="alert-list">
@for (alert of anomalies(); track alert.id) {
<div class="alert-card" [class]="alert.severity">
<div class="alert-header">
<span class="alert-type">{{ formatAnomalyType(alert.type) }}</span>
<span class="alert-time">{{ formatTime(alert.detectedAt) }}</span>
</div>
<p class="alert-desc">{{ alert.description }}</p>
<div class="alert-footer">
<span class="affected">{{ alert.affectedEvents.length }} events</span>
@if (!alert.acknowledged) {
<button class="btn-sm" (click)="acknowledgeAlert(alert.id)">Acknowledge</button>
} @else {
<span class="ack">Acked by {{ alert.acknowledgedBy }}</span>
}
</div>
@if (allCountsZero()) {
<div class="audit-log__empty-guidance">
<p>Audit events will appear as the platform is used. Events are captured automatically for:</p>
<ul>
<li>Release seals, promotions, and approvals</li>
<li>Policy changes and activations</li>
<li>VEX decisions and consensus votes</li>
<li>Integration configuration changes</li>
<li>Identity and access management</li>
</ul>
<p class="audit-log__status-note">The Evidence rail indicator shows <strong>ON</strong> — audit capture is active.</p>
</div>
}
</div>
</section>
}
<section class="quick-access">
<h2>Quick Access</h2>
<div class="access-grid">
<a routerLink="policy" class="access-card">
<span class="access-icon">policy</span>
<span class="access-label">Policy Audit</span>
<span class="access-desc">Promotions, simulations, approvals</span>
</a>
<a routerLink="authority" class="access-card">
<span class="access-icon">token</span>
<span class="access-label">Authority Audit</span>
<span class="access-desc">Token lifecycle, revocations</span>
</a>
<a routerLink="vex" class="access-card">
<span class="access-icon">vex</span>
<span class="access-label">VEX Audit</span>
<span class="access-desc">Decisions, consensus votes</span>
</a>
<a routerLink="integrations" class="access-card">
<span class="access-icon">integration</span>
<span class="access-label">Integration Audit</span>
<span class="access-desc">Config changes, connections</span>
</a>
<a routerLink="timeline" class="access-card">
<span class="access-icon">timeline</span>
<span class="access-label">Timeline Search</span>
<span class="access-desc">Search across all indexed events</span>
</a>
<a routerLink="correlations" class="access-card">
<span class="access-icon">correlate</span>
<span class="access-label">Correlations</span>
<span class="access-desc">Event clusters by causality</span>
</a>
</div>
</section>
<section class="recent-events">
<div class="section-header">
<h2>Recent Events</h2>
<a routerLink="events" class="link">View all</a>
</div>
<table class="events-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Module</th>
<th>Action</th>
<th>Actor</th>
<th>Resource</th>
</tr>
</thead>
<tbody>
@for (event of recentEvents(); track event.id) {
<tr [routerLink]="['events', event.id]" class="clickable">
<td class="mono">{{ formatTime(event.timestamp) }}</td>
<td><span class="badge module" [class]="event.module">{{ event.module }}</span></td>
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td>{{ event.actor.name }}</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
</tr>
@if (anomalies().length > 0) {
<section class="anomaly-alerts">
<h2>Anomaly Alerts</h2>
<div class="alert-list">
@for (alert of anomalies(); track alert.id) {
<div class="alert-card" [class]="alert.severity">
<div class="alert-header">
<span class="alert-type">{{ formatAnomalyType(alert.type) }}</span>
<span class="alert-time">{{ formatTime(alert.detectedAt) }}</span>
</div>
<p class="alert-desc">{{ alert.description }}</p>
<div class="alert-footer">
<span class="affected">{{ alert.affectedEvents.length }} events</span>
@if (!alert.acknowledged) {
<button class="btn-sm" (click)="acknowledgeAlert(alert.id)">Acknowledge</button>
} @else {
<span class="ack">Acked by {{ alert.acknowledgedBy }}</span>
}
</div>
</div>
}
</div>
</section>
}
</tbody>
</table>
</section>
<section class="recent-events">
<div class="section-header">
<h2>Recent Events</h2>
<button class="link" (click)="activeTab.set('all-events')">View all</button>
</div>
<table class="events-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Module</th>
<th>Action</th>
<th>Actor</th>
<th>Resource</th>
</tr>
</thead>
<tbody>
@for (event of recentEvents(); track event.id) {
<tr class="clickable">
<td class="mono">{{ formatTime(event.timestamp) }}</td>
<td><span class="badge module" [class]="event.module">{{ event.module }}</span></td>
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td>{{ event.actor.name }}</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
</tr>
}
</tbody>
</table>
</section>
}
@case ('all-events') { <app-audit-log-table /> }
@case ('policy') { <app-audit-policy /> }
@case ('authority') { <app-audit-authority /> }
@case ('vex') { <app-audit-vex /> }
@case ('integrations') { <app-audit-integrations /> }
@case ('trust') { <app-audit-trust /> }
@case ('timeline') { <app-audit-timeline-search /> }
@case ('correlations') { <app-audit-correlations /> }
}
</stella-page-tabs>
</div>
`,
styles: [`
.audit-dashboard { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 2rem; }
.page-header { margin-bottom: 1rem; }
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.description { color: var(--color-text-secondary); margin: 0 0 1rem; font-size: 0.9rem; }
.header-actions { display: flex; gap: 0.75rem; }
.btn-primary {
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text);
border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm);
text-decoration: none; font-weight: var(--font-weight-medium);
transition: opacity 150ms ease;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary);
padding: 0.5rem 1rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit;
transition: border-color 150ms ease;
}
.btn-secondary:hover { border-color: var(--color-brand-primary); }
stella-metric-grid { margin-bottom: 2rem; }
.anomaly-alerts { margin-bottom: 2rem; }
.description { color: var(--color-text-secondary); margin: 0; font-size: 0.9rem; }
stella-metric-grid { margin-bottom: 1.5rem; }
.anomaly-alerts { margin-bottom: 1.5rem; }
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
.alert-card {
@@ -184,38 +178,20 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
.btn-sm {
padding: 0.25rem 0.5rem; font-size: 0.8rem; cursor: pointer;
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text);
border: none; border-radius: var(--radius-sm);
transition: opacity 150ms ease;
border: none; border-radius: var(--radius-sm); transition: opacity 150ms ease;
}
.btn-sm:hover { opacity: 0.9; }
.ack { font-size: 0.75rem; color: var(--color-text-muted); }
.quick-access { margin-bottom: 2rem; }
.quick-access h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.access-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.access-card {
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); padding: 1rem; text-decoration: none; color: inherit;
transition: border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
.access-card:hover {
border-color: var(--color-brand-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; color: var(--color-text-secondary); }
.access-label { font-weight: var(--font-weight-semibold); display: block; margin-bottom: 0.25rem; color: var(--color-text-heading); }
.access-desc { font-size: 0.8rem; color: var(--color-text-secondary); }
.recent-events { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.section-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary); }
.section-header h2 { margin: 0; font-size: 1rem; }
.link { font-size: 0.85rem; color: var(--color-text-link); text-decoration: none; }
.link { font-size: 0.85rem; color: var(--color-text-link); text-decoration: none; background: none; border: none; cursor: pointer; }
.link:hover { text-decoration: underline; }
.events-table { width: 100%; border-collapse: collapse; }
.events-table th, .events-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.84rem; }
.events-table th {
background: var(--color-surface-elevated); font-weight: var(--font-weight-semibold);
font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em;
position: sticky; top: 0; z-index: 1;
}
.events-table tbody tr:nth-child(even) { background: var(--color-surface-elevated); }
.mono { font-family: monospace; font-size: 0.78rem; }
@@ -234,11 +210,8 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
.badge.action.promote { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.resource { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audit-log__empty-guidance {
margin: 0 0 2rem;
padding: 0.75rem 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 1.5rem; padding: 0.75rem 1rem;
color: var(--color-text-secondary); font-size: 0.9rem; line-height: 1.5;
border-left: 3px solid var(--color-border-primary);
}
.audit-log__empty-guidance p { margin: 0 0 0.5rem; }
@@ -250,6 +223,9 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
export class AuditLogDashboardComponent implements OnInit {
private readonly auditClient = inject(AuditLogClient);
readonly auditTabs = AUDIT_TABS;
readonly activeTab = signal<string>('overview');
readonly stats = signal<AuditStatsSummary | null>(null);
readonly recentEvents = signal<AuditEvent[]>([]);
readonly anomalies = signal<AuditAnomalyAlert[]>([]);
@@ -298,17 +274,18 @@ export class AuditLogDashboardComponent implements OnInit {
return new Date(ts).toLocaleString();
}
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
formatModule(module: AuditModule): string {
const labels: Record<AuditModule, string> = {
authority: 'Authority',
const labels: Record<string, string> = {
policy: 'Policy',
jobengine: 'JobEngine',
integrations: 'Integrations',
authority: 'Authority',
vex: 'VEX',
integrations: 'Integrations',
release: 'Release',
scanner: 'Scanner',
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
};
return labels[module] || module;
}
@@ -316,53 +293,20 @@ export class AuditLogDashboardComponent implements OnInit {
getModuleIcon(module: AuditModule): string {
const icons: Record<string, string> = {
policy: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
authority: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M23 21v-2a4 4 0 0 0-3-3.87|||M16 3.13a4 4 0 0 1 0 7.75',
vex: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M9 11l3 3L22 4',
integrations: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6|||M15 3h6v6|||M10 14L21 3',
jobengine: 'M12 2v4|||M12 18v4|||M4.93 4.93l2.83 2.83|||M16.24 16.24l2.83 2.83|||M2 12h4|||M18 12h4|||M4.93 19.07l2.83-2.83|||M16.24 7.76l2.83-2.83',
scanner: 'M11 1a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M21 21l-4.35-4.35',
attestor: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
sbom: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6',
scheduler: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M12 6v6l4 2',
authority: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4',
vex: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
integrations: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6',
release: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5',
scanner: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
};
return icons[module] || 'M12 20V10|||M18 20V4|||M6 20v-4';
return icons[module] || 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
}
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
private sortModuleStatsDeterministically(entries: Array<{ module: AuditModule; count: number }>): Array<{ module: AuditModule; count: number }> {
return entries.sort((a, b) => b.count - a.count || a.module.localeCompare(b.module));
}
private sortModuleStatsDeterministically(
entries: readonly { module: AuditModule; count: number }[]
): Array<{ module: AuditModule; count: number }> {
return [...entries].sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
return a.module.localeCompare(b.module);
});
}
private sortEventsDeterministically(events: readonly AuditEvent[]): AuditEvent[] {
return [...events].sort((a, b) => {
const timestampDelta =
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
if (timestampDelta !== 0) {
return timestampDelta;
}
const idDelta = a.id.localeCompare(b.id);
if (idDelta !== 0) {
return idDelta;
}
const moduleDelta = a.module.localeCompare(b.module);
if (moduleDelta !== 0) {
return moduleDelta;
}
return a.action.localeCompare(b.action);
});
private sortEventsDeterministically(events: AuditEvent[]): AuditEvent[] {
return events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
}

View File

@@ -192,7 +192,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
}
<div class="detail-section">
<h4>Details</h4>
<pre class="json-block">{{ selectedEvent()?.details | json }}</pre>
<pre class="json-block">{{ (selectedEvent()?.details ?? {}) | json }}</pre>
</div>
@if (selectedEvent()?.diff) {
<button class="btn-primary" (click)="openDiffViewer(selectedEvent()!)">View Diff</button>
@@ -216,11 +216,11 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ diffEvent()?.diff?.before | json }}</pre>
<pre>{{ (diffEvent()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ diffEvent()?.diff?.after | json }}</pre>
<pre>{{ (diffEvent()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (diffEvent()?.diff?.fields?.length) {
@@ -248,16 +248,6 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
.filter-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; align-items: flex-end; }
.filter-row:last-child { margin-bottom: 0; }
.filter-group { display: flex; flex-direction: column; gap: 0.2rem; }
.filter-group label { font-size: 0.7rem; color: var(--color-text-secondary); font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.03em; }
.filter-group select, .filter-group input {
padding: 0.4rem 0.5rem; border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm); font-size: 0.84rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filter-group select:focus, .filter-group input:focus {
outline: none; border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.filter-group select[multiple] { height: 72px; }
.search-group { flex: 1; min-width: 200px; flex-direction: row; align-items: flex-end; }
.search-group label { display: none; }

View File

@@ -89,7 +89,7 @@ interface PendingAction {
<!-- Header: full-width -->
<header class="board-header">
<div class="header-identity">
<h1 class="board-title">Mission Board</h1>
<h1 class="board-title">Dashboard</h1>
<p class="board-subtitle">{{ tenantLabel() }}</p>
</div>
</header>
@@ -516,7 +516,7 @@ interface PendingAction {
}
.refresh-btn:focus-visible {
outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4));
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
@@ -896,7 +896,7 @@ interface PendingAction {
}
.env-card:focus-visible {
outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4));
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
@@ -1189,7 +1189,7 @@ interface PendingAction {
}
.scroll-arrow:focus-visible {
outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4));
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}

View File

@@ -70,92 +70,7 @@
</div>
}
<!-- Summary Bar -->
@if (store.hasReport()) {
<div class="summary-bar" [class.summary-bar--healthy]="overallHealthy()" [class.summary-bar--issues]="!overallHealthy()">
<div class="summary-bar__status">
@if (overallHealthy()) {
<svg class="summary-bar__icon summary-bar__icon--ok" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 12 15 16 10"/>
</svg>
<span class="summary-bar__text">All systems operational</span>
} @else {
<svg class="summary-bar__icon summary-bar__icon--issue" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="summary-bar__text">{{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }} detected</span>
}
</div>
<st-summary-strip
[summary]="store.summary()!"
[duration]="store.report()?.durationMs"
[overallSeverity]="store.report()?.overallSeverity" />
</div>
}
<!-- Doctor Packs — tabbed -->
@if (store.packGroups().length) {
<section class="pack-section">
<div class="pack-section__header">
<h2>Doctor Packs</h2>
<p class="pack-section__desc">Discovered integrations and available checks</p>
</div>
<nav class="pack-tabs" role="tablist" aria-label="Doctor pack categories">
@for (pack of store.packGroups(); track pack.category) {
<button
type="button"
role="tab"
class="pack-tabs__tab"
[class.pack-tabs__tab--active]="activePackTab() === pack.category"
[attr.aria-selected]="activePackTab() === pack.category"
(click)="activePackTab.set(pack.category)">
{{ pack.label }}
<span class="pack-tabs__count">{{ pack.plugins.length }}</span>
</button>
}
</nav>
<div class="pack-content" role="tabpanel">
@for (pack of store.packGroups(); track pack.category) {
@if (activePackTab() === pack.category) {
<div class="plugin-list">
@for (plugin of pack.plugins; track plugin.pluginId) {
<article class="plugin-card">
<div class="plugin-card__header">
<div class="plugin-card__info">
<span class="plugin-card__name">{{ plugin.displayName }}</span>
<span class="plugin-card__id">{{ plugin.pluginId }}</span>
</div>
<div class="plugin-card__meta">
<span class="plugin-card__checks">{{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks</span>
@if (plugin.version && plugin.version !== 'unknown') {
<span class="plugin-card__version">v{{ plugin.version }}</span>
}
</div>
</div>
@if (plugin.checks.length > 0) {
<div class="plugin-card__check-list">
@for (check of plugin.checks; track check.checkId) {
<span class="plugin-card__check-id">{{ check.checkId }}</span>
}
</div>
} @else {
<div class="plugin-card__empty">Checks not discovered yet.</div>
}
</article>
}
</div>
}
}
</div>
</section>
}
<!-- Filter Bar -->
<!-- 1. Search + Unified severity filter/summary chips -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -171,15 +86,42 @@
</div>
<div class="filter-bar__severity">
@for (sev of severities; track sev.value) {
<label class="severity-chip" [class]="sev.class" [class.severity-chip--active]="isSeveritySelected(sev.value)">
<label
class="severity-chip"
[class]="sev.class"
[class.severity-chip--active]="isSeveritySelected(sev.value)"
[class.severity-chip--has-count]="severityCount(sev.value) !== null"
[class.severity-chip--zero]="severityCount(sev.value) === 0"
>
<input
type="checkbox"
[checked]="isSeveritySelected(sev.value)"
(change)="toggleSeverity(sev.value)">
@if (severityCount(sev.value) !== null) {
<span class="severity-chip__count">{{ severityCount(sev.value) }}</span>
}
<span>{{ sev.label }}</span>
</label>
}
</div>
@if (store.hasReport()) {
<div class="filter-bar__status">
@if (overallHealthy()) {
<svg class="filter-bar__status-icon filter-bar__status-icon--ok" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 12 15 16 10"/>
</svg>
<span class="filter-bar__status-text filter-bar__status-text--ok">All OK</span>
} @else {
<svg class="filter-bar__status-icon filter-bar__status-icon--issue" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="filter-bar__status-text filter-bar__status-text--issue">{{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }}</span>
}
</div>
}
@if (store.searchQuery() || store.severityFilter().length || store.categoryFilter()) {
<button class="btn btn-ghost filter-bar__clear" (click)="clearFilters()">
Clear
@@ -187,71 +129,94 @@
}
</div>
<!-- Tabbed Results -->
<div class="results-container">
@if (store.state() === 'idle' && !store.hasReport()) {
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<h3>No Diagnostics Run Yet</h3>
<p>Click "Quick" to run a fast diagnostic, or "Full" for comprehensive analysis.</p>
<!-- 3. Doctor Packs — tabbed with severity-aware badges -->
@if (store.packGroups().length) {
<section class="pack-section">
<div class="pack-section__header">
<h2>Doctor Packs</h2>
<p class="pack-section__desc">Discovered integrations and available checks</p>
</div>
}
@if (store.hasReport()) {
<stella-page-tabs
[tabs]="doctorTabsWithStatus()"
[activeTab]="activeTab()"
(tabChange)="selectTab($any($event))"
ariaLabel="Diagnostic categories"
[tabs]="packTabs()"
[activeTab]="activePackTab()"
(tabChange)="activePackTab.set($any($event))"
ariaLabel="Doctor pack categories"
>
<div class="results-header">
<span class="results-count">
{{ getResultsForTab().length }} of {{ store.report()?.results?.length || 0 }} checks
</span>
</div>
<div class="results-list">
@for (result of getResultsForTab(); track trackResult($index, result)) {
<div class="check-row" [class.check-row--expanded]="isResultSelected(result)" (click)="selectResult(result)">
<span class="check-row__name" [title]="result.checkId">{{ result.checkId }}</span>
<span
class="check-row__badge"
[class]="'badge--' + result.severity"
[title]="'Last checked: ' + result.executedAt">
@switch (result.severity) {
@case ('pass') { OK }
@case ('fail') { FAIL }
@case ('warn') { WARN }
@case ('info') { INFO }
@case ('skip') { SKIP }
@if (visiblePackPlugins().length > 0) {
<div class="plugin-list">
@for (plugin of visiblePackPlugins(); track plugin.pluginId) {
<article class="plugin-card">
<div class="plugin-card__header">
<div class="plugin-card__info">
<span class="plugin-card__name">{{ plugin.displayName }}</span>
<span class="plugin-card__id">{{ plugin.pluginId }}</span>
</div>
<div class="plugin-card__meta">
<span class="plugin-card__checks">{{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks</span>
@if (plugin.version && plugin.version !== 'unknown') {
<span class="plugin-card__version">v{{ plugin.version }}</span>
}
</div>
</div>
@if (filteredChecks(plugin).length > 0) {
<div class="plugin-card__check-list plugin-card__check-list--boot">
@for (check of filteredChecks(plugin); track check.checkId) {
<div class="boot-entry" [class.boot-entry--expanded]="expandedCheckId() === check.checkId" [class.boot-entry--has-result]="checkResult(check.checkId)">
<div class="boot-line" [class.boot-line--clickable]="checkResult(check.checkId)" (click)="checkResult(check.checkId) ? toggleCheck(check.checkId) : null">
@if (checkResult(check.checkId)) {
<svg class="boot-line__chevron" [class.boot-line__chevron--open]="expandedCheckId() === check.checkId" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
}
<span class="boot-line__id">{{ check.checkId }}</span>
<span class="boot-line__dots"></span>
@switch (checkStatus(check.checkId)) {
@case ('pass') { <span class="boot-line__tag boot-line__tag--ok">[ OK ]</span> }
@case ('fail') { <span class="boot-line__tag boot-line__tag--fail">[ FAIL ]</span> }
@case ('warn') { <span class="boot-line__tag boot-line__tag--warn">[ WARN ]</span> }
@case ('info') { <span class="boot-line__tag boot-line__tag--info">[ INFO ]</span> }
@case ('skip') { <span class="boot-line__tag boot-line__tag--skip">[ SKIP ]</span> }
@default { <span class="boot-line__tag boot-line__tag--idle">[ NOT RUN ]</span> }
}
</div>
@if (expandedCheckId() === check.checkId && checkResult(check.checkId)) {
<div class="boot-detail">
<st-check-result
[result]="checkResult(check.checkId)!"
[expanded]="true"
[fixEnabled]="fixEnabled"
(rerun)="rerunCheck(check.checkId)" />
</div>
}
</div>
}
</div>
} @else if (plugin.checks.length > 0) {
<div class="plugin-card__empty">No checks match the active filters.</div>
} @else {
<div class="plugin-card__empty">Checks not discovered yet.</div>
}
</span>
</div>
@if (isResultSelected(result)) {
<div class="check-detail">
<st-check-result
[result]="result"
[expanded]="true"
[fixEnabled]="fixEnabled"
(rerun)="rerunCheck(result.checkId)" />
</div>
</article>
}
}
@if (getResultsForTab().length === 0) {
<div class="no-results">
<p>No checks match your current filters.</p>
<button class="btn btn-link" (click)="clearFilters()">Clear filters</button>
</div>
}
</div>
</div>
} @else {
<div class="plugin-card__empty" style="padding: 1.5rem; text-align: center;">No packs match the active filters.</div>
}
</stella-page-tabs>
}
</div>
</section>
}
<!-- Empty state (no report yet and no packs loading) -->
@if (store.state() === 'idle' && !store.hasReport() && !store.packGroups().length) {
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<h3>No Diagnostics Run Yet</h3>
<p>Click "Quick" to run a fast diagnostic, or "Full" for comprehensive analysis.</p>
</div>
}
<!-- Export Dialog -->
@if (showExportDialog()) {

View File

@@ -385,6 +385,11 @@ export class DoctorStore {
return a < b ? -1 : 1;
}
private static readonly CATEGORY_LABELS: Record<string, string> = {
servicegraph: 'Service Graph',
binaryanalysis: 'Binary Analysis',
};
private static formatLabel(value: string): string {
if (!value) {
return 'Uncategorized';
@@ -395,6 +400,10 @@ export class DoctorStore {
return 'Uncategorized';
}
if (DoctorStore.CATEGORY_LABELS[trimmed]) {
return DoctorStore.CATEGORY_LABELS[trimmed];
}
return trimmed.length === 1
? trimmed.toUpperCase()
: trimmed.charAt(0).toUpperCase() + trimmed.slice(1);

View File

@@ -155,7 +155,7 @@ interface ReloadOptions {
@if (release()) {
<header class="header">
<h1>{{ modeLabel() }} · {{ release()!.name }} <small>{{ release()!.version }}</small></h1>
<p>{{ release()!.digest || 'digest-unavailable' }}</p>
<p>{{ release()!.digest || 'Pending digest' }}</p>
<div class="chips">
<span>{{ release()!.releaseType }}</span>
<span>{{ release()!.targetRegion || '-' }}</span>
@@ -200,6 +200,7 @@ interface ReloadOptions {
<stella-page-tabs
[tabs]="RELEASE_RO_TABS()"
[activeTab]="activeTab()"
urlParam="tab"
(tabChange)="onTabChange($event)"
ariaLabel="Release detail tabs"
/>

View File

@@ -18,9 +18,10 @@ import { PaginationComponent, PageChangeEvent } from '../../../../shared/compone
import { DateFormatService } from '../../../../core/i18n/date-format.service';
import { PageActionService } from '../../../../core/services/page-action.service';
import { PageActionOutletComponent } from '../../../../shared/components/page-action-outlet/page-action-outlet.component';
@Component({
selector: 'app-release-list',
imports: [RouterModule, StellaFilterChipComponent, PaginationComponent],
imports: [RouterModule, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent],
template: `
<div class="release-list">
<header class="list-header">
@@ -28,6 +29,7 @@ import { PageActionService } from '../../../../core/services/page-action.service
<h1>Release Versions</h1>
<p class="subtitle">Digest-first release version catalog across standard and hotfix lanes</p>
</div>
<app-page-action-outlet />
</header>
<div class="filters">
@@ -187,7 +189,7 @@ import { PageActionService } from '../../../../core/services/page-action.service
</td>
<td class="col-identity">
<a [routerLink]="['/releases/versions', release.id, 'overview']" class="identity-link">
<strong>{{ release.digest || 'digest-unavailable' }}</strong>
<strong>{{ release.digest || 'Pending digest' }}</strong>
</a>
<div class="meta">{{ release.name }} · {{ release.version }}</div>
</td>
@@ -285,10 +287,9 @@ import { PageActionService } from '../../../../core/services/page-action.service
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: nowrap;
overflow-x: auto;
flex-wrap: wrap;
overflow: visible;
}
@media (max-width: 768px) { .filters { flex-wrap: wrap; } }
.filter-search {
position: relative;
@@ -309,21 +310,6 @@ import { PageActionService } from '../../../../core/services/page-action.service
width: 100%;
height: 28px;
padding: 0 0.5rem 0 1.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-primary);
font-size: 0.75rem;
outline: none;
transition: border-color 150ms ease;
}
.filter-search__input:focus {
border-color: var(--color-brand-primary);
}
.filter-search__input::placeholder {
color: var(--color-text-muted);
}
/* ─── Buttons ─── */

View File

@@ -1,5 +1,5 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, OnInit, signal, ViewChild, ElementRef, AfterViewInit, NgZone } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
@@ -13,7 +13,9 @@ import { PaginationComponent, PageChangeEvent } from '../../shared/components/pa
import { DateFormatService } from '../../core/i18n/date-format.service';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client';
import type { ApprovalRequest } from '../../core/api/approval.models';
import type { ApprovalRequest, ApprovalDetail } from '../../core/api/approval.models';
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
import { ModalComponent } from '../../shared/components/modal/modal.component';
const VIEW_MODE_TABS: StellaPageTab[] = [
{ id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
@@ -22,6 +24,7 @@ const VIEW_MODE_TABS: StellaPageTab[] = [
{ id: 'approvals', label: 'Approvals', 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' },
];
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
interface ReleaseActivityProjection {
activityId: string;
releaseId: string;
@@ -61,7 +64,7 @@ function deriveOutcomeIcon(status: string): string {
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent],
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent],
template: `
<section class="activity">
<header>
@@ -75,50 +78,107 @@ function deriveOutcomeIcon(status: string): string {
<span>{{ context.timeWindow() }}</span>
</div>
<!-- Pending approvals banner (hidden on Approvals tab) -->
<!-- Pending approvals inline lane (dashboard-style action cards) -->
@if (pendingApprovals().length > 0 && viewMode() !== 'approvals') {
<div class="pending-banner" [class.pending-banner--collapsed]="pendingBannerCollapsed()">
<button type="button" class="pending-banner__toggle" (click)="pendingBannerCollapsed.set(!pendingBannerCollapsed())">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" class="pending-banner__chevron">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="pending-banner__title">{{ pendingApprovals().length }} pending approval{{ pendingApprovals().length === 1 ? '' : 's' }}</span>
</button>
@if (!pendingBannerCollapsed()) {
<table class="pending-banner__table">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Gate</th>
<th>Risk</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
@for (apr of pendingApprovals(); track apr.id) {
<tr [class.pending-banner__row--expiring]="isExpiringSoon(apr.expiresAt)">
<td>{{ apr.releaseName }} {{ apr.releaseVersion }}</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</td>
<td><a class="run-link" [routerLink]="['/releases/approvals', apr.id]">View</a></td>
</tr>
}
</tbody>
</table>
}
<div class="pending-lane">
<div class="pending-lane__header">
<h2 class="pending-lane__title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{{ pendingApprovals().length }} Pending Approval{{ pendingApprovals().length === 1 ? '' : 's' }}
</h2>
<button type="button" class="pending-lane__link" (click)="onTabChange('approvals')">
View all <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="apc-lane">
@for (apr of pendingApprovals().slice(0, 5); track apr.id) {
<div class="apc" [class.apc--prod]="isProductionEnv(apr.targetEnvironment)" [class.apc--expiring]="isExpiringSoon(apr.expiresAt)">
<div class="apc__head">
<div class="apc__id"><span class="apc__name">{{ apr.releaseName }}</span>@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {<span class="apc__ver">{{ apr.releaseVersion }}</span>}</div>
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
</div>
<div class="apc__envs">
<span class="apc__env">{{ apr.sourceEnvironment }}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="apc__arr"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="apc__env" [class.apc__env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
</div>
<div class="apc__meta">
<span class="apc__who">by {{ apr.requestedBy }}</span>
<span class="apc__gate" [class.apc__gate--pass]="apr.gatesPassed" [class.apc__gate--fail]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}</span>
<span class="apc__exp" [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</span>
</div>
<div class="apc__btns">
<button class="apc__btn apc__btn--approve" (click)="onApprove(apr)" type="button">Approve</button>
<button class="apc__btn apc__btn--reject" (click)="onReject(apr)" type="button">Reject</button>
<button class="apc__btn apc__btn--view" (click)="onView(apr)" type="button">View</button>
</div>
</div>
}
</div>
</div>
}
<!-- Approve production confirmation -->
<app-confirm-dialog #approveConfirm title="Approve Production Deployment"
[message]="'Approve ' + (activeApr()?.releaseName ?? '') + ' ' + (activeApr()?.releaseVersion ?? '') + ' to PRODUCTION?'"
confirmLabel="Approve" cancelLabel="Cancel" variant="warning" (confirmed)="confirmApprove()" />
<!-- Reject dialog -->
@if (showRejectDlg()) {
<div class="dlg-overlay" (click)="cancelReject()" role="presentation">
<div class="dlg-box" (click)="$event.stopPropagation()" role="alertdialog" aria-modal="true">
<h2 class="dlg-box__title">Reject Release</h2>
<p class="dlg-box__msg">Reject <strong>{{ activeApr()?.releaseName }}</strong> {{ activeApr()?.releaseVersion }} to <strong>{{ activeApr()?.targetEnvironment }}</strong>?</p>
<label class="dlg-box__label" for="rejectReason">Reason (optional)</label>
<textarea id="rejectReason" class="dlg-box__ta" [(ngModel)]="rejectReason" rows="3" placeholder="Why are you rejecting?"></textarea>
<div class="dlg-box__actions">
<button type="button" class="dlg-btn dlg-btn--cancel" (click)="cancelReject()">Cancel</button>
<button type="button" class="dlg-btn dlg-btn--danger" (click)="confirmReject()">Reject</button>
</div>
</div>
</div>
}
<!-- Detail popup -->
<app-modal [open]="showDetailDlg()" (closed)="closeDetail()" [title]="detailApr()?.releaseName ?? 'Release'" [description]="'Version ' + (detailApr()?.releaseVersion ?? '')" size="lg" iconVariant="info">
@if (detailApr(); as d) {
<div class="det">
<div class="det__sec"><h3 class="det__h">Promotion</h3>
<div class="det__r"><span class="det__l">From</span><span>{{ d.sourceEnvironment }}</span></div>
<div class="det__r"><span class="det__l">To</span><span [class.det__prod]="isProductionEnv(d.targetEnvironment)">{{ d.targetEnvironment }}</span></div>
<div class="det__r"><span class="det__l">Urgency</span><span>{{ d.urgency }}</span></div>
<div class="det__r"><span class="det__l">Requested by</span><span>{{ d.requestedBy }}</span></div>
<div class="det__r"><span class="det__l">Approvals</span><span>{{ d.currentApprovals }} / {{ d.requiredApprovals }}</span></div>
</div>
@if (d.justification) { <div class="det__sec"><h3 class="det__h">Justification</h3><p class="det__txt">{{ d.justification }}</p></div> }
@if (detailFull()?.gateResults?.length) {
<div class="det__sec"><h3 class="det__h">Gate Results</h3>
@for (g of detailFull()!.gateResults; track g.gateId) {
<div class="det__gate"><span class="gate-chip" [attr.data-gate]="g.status === 'passed' ? 'pass' : g.status">{{ g.status }}</span><strong>{{ g.gateName }}</strong><span class="det__gmsg">{{ g.message }}</span></div>
}
</div>
}
@if (detailFull()?.releaseComponents?.length) {
<div class="det__sec"><h3 class="det__h">Components</h3>
@for (c of detailFull()!.releaseComponents; track c.name) { <div class="det__r"><span class="det__l">{{ c.name }}</span><span class="det__mono">{{ c.version }}</span></div> }
</div>
}
</div>
} @else { <div style="text-align:center;padding:2rem;color:var(--color-text-muted)">Loading...</div> }
<div modal-footer><button class="dlg-btn dlg-btn--cancel" (click)="closeDetail()">Close</button></div>
</app-modal>
<stella-page-tabs
[tabs]="viewModeTabs"
[activeTab]="viewMode()"
urlParam="tab"
(tabChange)="onTabChange($any($event))"
ariaLabel="Run list views"
/>
>
<app-page-action-outlet tabBarAction />
</stella-page-tabs>
@if (viewMode() === 'approvals') {
<!-- Approvals tab content -->
@@ -140,45 +200,55 @@ function deriveOutcomeIcon(status: string): string {
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--xs"></div>
</div>
}
</div>
} @else if (filteredApprovals().length === 0) {
<div class="empty-state">No approvals match the active gate filters.</div>
} @else {
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered approvals-table-enter">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Gate</th>
<th>Risk</th>
<th>Status</th>
<th>Requester</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
<div class="apc-lane-wrapper" [class.can-scroll-left]="showApcLeft()" [class.can-scroll-right]="showApcRight()">
@if (showApcLeft()) {
<button class="apc-scroll apc-scroll--left" (click)="scrollApc('left')" type="button" aria-label="Scroll left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
}
<div class="apc-lane" #apcScroll (scroll)="onApcScroll()">
@for (apr of filteredApprovals(); track apr.id) {
<tr [class.pending-banner__row--expiring]="apr.status === 'pending' && isExpiringSoon(apr.expiresAt)">
<td>{{ apr.releaseName }} {{ apr.releaseVersion }}</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
<td>{{ apr.requestedBy }}</td>
<td [class.text-warning]="apr.status === 'pending' && isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</td>
<td><a class="run-link" [routerLink]="['/releases/approvals', apr.id]">View</a></td>
</tr>
} @empty {
<tr><td colspan="8">No approvals match the active gate filters.</td></tr>
<div class="apc" [class.apc--prod]="isProductionEnv(apr.targetEnvironment)" [class.apc--expiring]="isExpiringSoon(apr.expiresAt)">
<div class="apc__head">
<div class="apc__id"><span class="apc__name">{{ apr.releaseName }}</span>@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {<span class="apc__ver">{{ apr.releaseVersion }}</span>}</div>
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
</div>
<div class="apc__envs">
<span class="apc__env">{{ apr.sourceEnvironment }}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="apc__arr"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="apc__env" [class.apc__env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
</div>
<div class="apc__meta">
<span class="apc__who">by {{ apr.requestedBy }}</span>
<span class="apc__gate" [class.apc__gate--pass]="apr.gatesPassed" [class.apc__gate--fail]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}</span>
<span class="apc__exp" [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</span>
</div>
<div class="apc__status-row">
<span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span>
<span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span>
</div>
<div class="apc__btns">
@if (apr.status === 'pending') {
<button class="apc__btn apc__btn--approve" (click)="onApprove(apr)" type="button">Approve</button>
<button class="apc__btn apc__btn--reject" (click)="onReject(apr)" type="button">Reject</button>
}
<button class="apc__btn apc__btn--view" (click)="onView(apr)" type="button">View</button>
</div>
</div>
}
</tbody>
</table>
</div>
@if (showApcRight()) {
<button class="apc-scroll apc-scroll--right" (click)="scrollApc('right')" type="button" aria-label="Scroll right">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
}
</div>
}
} @else {
<!-- Existing deployment views: filters + timeline/table/correlations -->
@@ -317,18 +387,12 @@ function deriveOutcomeIcon(status: string): string {
.pending-banner{animation:approvals-fadein 250ms ease-out both}
/* Inline filter chips row */
.activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: nowrap; overflow-x: auto; }
@media (max-width: 768px) { .activity-filters { flex-wrap: wrap; } }
.activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; overflow: visible; }
.filter-search { position: relative; flex: 0 1 240px; min-width: 160px; }
.filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
.filter-search__input {
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
background: transparent; color: var(--color-text-primary);
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
}
.filter-search__input:focus { border-color: var(--color-brand-primary); }
.filter-search__input::placeholder { color: var(--color-text-muted); }
.banner,.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)}
@@ -353,10 +417,82 @@ function deriveOutcomeIcon(status: string): string {
.pending-banner__chevron{transition:transform .2s ease;flex-shrink:0}
.pending-banner--collapsed .pending-banner__chevron{transform:rotate(-90deg)}
.pending-banner__title{flex:1}
.pending-banner__table{width:100%;border-collapse:collapse;font-size:.74rem}
.pending-banner__table th{text-align:left;padding:.3rem .5rem;font-weight:600;color:var(--color-text-secondary);border-bottom:1px solid var(--color-border-primary)}
.pending-banner__table td{padding:.3rem .5rem;border-bottom:1px solid var(--color-border-primary)}
.pending-banner__row--expiring{background:color-mix(in srgb, var(--color-status-warning) 8%, transparent)}
/* ─── Approval card lane ─── */
.apc-lane-wrapper{position:relative;padding:.5rem .25rem}
.apc-lane-wrapper.can-scroll-left::before{content:'';position:absolute;top:0;left:0;bottom:0;width:48px;background:linear-gradient(to right,var(--color-status-warning-bg,var(--color-surface-primary)) 0%,transparent 100%);pointer-events:none;z-index:1}
.apc-lane-wrapper.can-scroll-right::after{content:'';position:absolute;top:0;right:0;bottom:0;width:48px;background:linear-gradient(to left,var(--color-status-warning-bg,var(--color-surface-primary)) 0%,transparent 100%);pointer-events:none;z-index:1}
.apc-lane{display:flex;overflow-x:auto;flex-wrap:nowrap;gap:.65rem;scrollbar-width:none;scroll-behavior:smooth;padding-bottom:.25rem}
.apc-lane::-webkit-scrollbar{display:none}
.apc-scroll{position:absolute;top:50%;transform:translateY(-50%);z-index:2;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;border:1px solid var(--color-border-primary);background:var(--color-surface-primary);box-shadow:0 2px 6px rgba(0,0,0,.12);color:var(--color-text-primary);cursor:pointer;padding:0;transition:all 150ms ease}
.apc-scroll:hover{background:var(--color-surface-elevated);box-shadow:0 4px 12px rgba(0,0,0,.18);transform:translateY(-50%) scale(1.1)}
.apc-scroll--left{left:.25rem}
.apc-scroll--right{right:.25rem}
.apc{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);overflow:hidden;background:var(--color-surface-elevated);transition:transform 150ms ease,box-shadow 150ms ease;flex-shrink:0;width:260px;border-top:3px solid var(--color-status-success)}
.apc--prod{border-top-color:var(--color-status-warning)}
.apc--expiring{border-top-color:var(--color-severity-high,#c2410c)}
.apc:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.08)}
.apc__head{display:flex;justify-content:space-between;align-items:center;padding:.5rem .65rem;border-bottom:1px solid var(--color-border-primary)}
.apc__id{display:flex;flex-direction:column;gap:.05rem;min-width:0;flex:1}
.apc__name{font-weight:600;font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.apc__ver{font-size:.68rem;color:var(--color-text-secondary);font-family:var(--font-family-mono,monospace)}
.apc__envs{display:flex;align-items:center;gap:.35rem;padding:.4rem .65rem}
.apc__env{font-size:.68rem;font-weight:500;padding:.15rem .4rem;border-radius:var(--radius-sm);background:var(--color-surface-tertiary);color:var(--color-text-primary);text-transform:capitalize}
.apc__env--prod{background:color-mix(in srgb,var(--color-status-warning) 15%,transparent);color:var(--color-status-warning-text);font-weight:600}
.apc__arr{color:var(--color-text-muted);flex-shrink:0}
.apc__meta{display:flex;justify-content:space-between;align-items:center;padding:0 .65rem .35rem;font-size:.64rem}
.apc__who{color:var(--color-text-muted)}
.apc__gate{font-weight:500}
.apc__gate--pass{color:var(--color-status-success)}
.apc__gate--fail{color:var(--color-status-error)}
.apc__exp{font-family:var(--font-family-mono,monospace);color:var(--color-text-muted)}
.apc__status-row{display:flex;gap:.35rem;padding:0 .6rem .45rem;flex-wrap:wrap}
.pending-lane{margin-bottom:1rem;padding:1rem;border:1px solid var(--color-status-warning-border);border-radius:var(--radius-lg);background:var(--color-status-warning-bg)}
.pending-lane__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem}
.pending-lane__title{display:flex;align-items:center;gap:.5rem;margin:0;font-size:.9rem;font-weight:600;color:var(--color-status-warning-text)}
.pending-lane__link{display:inline-flex;align-items:center;gap:.25rem;background:none;border:none;font-size:.8rem;font-weight:500;color:var(--color-status-warning-text);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.pending-lane__link:hover{color:var(--color-text-primary)}
.empty-state{text-align:center;padding:2.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg);margin-top:.5rem}
.apc__btns{display:flex;border-top:1px solid var(--color-border-primary)}
.apc__btn{flex:1;padding:.4rem 0;font-size:.72rem;font-weight:500;border:none;cursor:pointer;transition:background 150ms ease;background:transparent;color:var(--color-text-secondary)}
.apc__btn:not(:last-child){border-right:1px solid var(--color-border-primary)}
.apc__btn--approve:hover{background:color-mix(in srgb,var(--color-status-success) 12%,transparent);color:var(--color-status-success)}
.apc__btn--reject:hover{background:color-mix(in srgb,var(--color-status-error) 12%,transparent);color:var(--color-status-error)}
.apc__btn--view:hover{background:var(--color-surface-tertiary);color:var(--color-text-primary)}
/* ─── Dialogs ─── */
.dlg-overlay{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.5);backdrop-filter:blur(2px)}
.dlg-box{width:100%;max-width:440px;padding:1.5rem;background:var(--color-surface-primary);border-radius:var(--radius-xl);box-shadow:0 20px 40px rgba(0,0,0,.2)}
.dlg-box__title{margin:0 0 .75rem;font-size:1.125rem;font-weight:600}
.dlg-box__msg{margin:0 0 1rem;font-size:.875rem;color:var(--color-text-secondary);line-height:1.5}
.dlg-box__label{display:block;font-size:.8rem;font-weight:500;color:var(--color-text-secondary);margin-bottom:.35rem}
.dlg-box__ta{width:100%;padding:.6rem;font-size:.85rem;font-family:inherit;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-secondary);color:var(--color-text-primary);resize:vertical;margin-bottom:1.25rem}
.dlg-box__ta:focus{outline:none;border-color:var(--color-focus-ring);box-shadow:0 0 0 2px var(--color-brand-muted)}
.dlg-box__actions{display:flex;justify-content:flex-end;gap:.75rem}
.dlg-btn{padding:.5rem 1rem;font-size:.85rem;font-weight:500;border:none;border-radius:var(--radius-md);cursor:pointer;transition:background 150ms ease}
.dlg-btn--cancel{color:var(--color-text-secondary);background:var(--color-surface-tertiary)}.dlg-btn--cancel:hover{background:var(--color-surface-secondary)}
.dlg-btn--danger{color:#fff;background:var(--color-severity-critical)}.dlg-btn--danger:hover{background:var(--color-status-error-text)}
/* ─── Detail modal ─── */
.det{display:flex;flex-direction:column;gap:1.25rem}
.det__sec{display:flex;flex-direction:column;gap:.5rem}
.det__h{margin:0;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:var(--color-text-secondary);padding-bottom:.35rem;border-bottom:1px solid var(--color-border-primary)}
.det__r{display:flex;justify-content:space-between;align-items:center;padding:.3rem 0;font-size:.85rem}
.det__l{color:var(--color-text-secondary);font-weight:500}
.det__prod{color:var(--color-status-warning-text);font-weight:600}
.det__txt{margin:0;font-size:.85rem;color:var(--color-text-primary);line-height:1.5}
.det__gate{display:flex;align-items:center;gap:.6rem;padding:.35rem 0;font-size:.82rem}
.det__gmsg{color:var(--color-text-secondary)}
.det__mono{font-family:var(--font-family-mono,monospace);font-size:.75rem;color:var(--color-text-muted)}
@media(prefers-reduced-motion:reduce){.apc{transition:none}.apc:hover{transform:none}}
/* Gate & urgency & status chips */
.gate-chip{display:inline-block;padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);text-transform:capitalize}
@@ -413,9 +549,19 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
readonly rows = signal<ReleaseActivityProjection[]>([]);
readonly viewMode = signal<'timeline' | 'table' | 'correlations' | 'approvals'>('timeline');
// ── Pending approvals banner ──────────────────────────────────────
// ── Pending approvals card lane ──────────────────────────────────
@ViewChild('apcScroll') apcScrollRef?: ElementRef<HTMLDivElement>;
@ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
readonly pendingApprovals = signal<ApprovalRequest[]>([]);
readonly pendingBannerCollapsed = signal(false);
readonly showApcLeft = signal(false);
readonly showApcRight = signal(false);
readonly activeApr = signal<ApprovalRequest | null>(null);
readonly showRejectDlg = signal(false);
readonly showDetailDlg = signal(false);
readonly detailApr = signal<ApprovalRequest | null>(null);
readonly detailFull = signal<ApprovalDetail | null>(null);
rejectReason = '';
// ── Approvals tab state ───────────────────────────────────────────
readonly allApprovals = signal<ApprovalRequest[]>([]);
@@ -678,9 +824,63 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
return ms > 0 && ms <= 4 * 60 * 60 * 1000;
}
// ── Card lane scroll ────────────────────────────────────────────────
onApcScroll(): void { this.updateApcArrows(); }
scrollApc(dir: 'left' | 'right'): void {
this.apcScrollRef?.nativeElement?.scrollBy({ left: dir === 'left' ? -300 : 300, behavior: 'smooth' });
}
private updateApcArrows(): void {
const el = this.apcScrollRef?.nativeElement;
if (!el) { this.showApcLeft.set(false); this.showApcRight.set(false); return; }
this.showApcLeft.set(el.scrollLeft > 1);
this.showApcRight.set(el.scrollWidth - el.scrollLeft - el.clientWidth > 1);
}
// ── Card lane actions ───────────────────────────────────────────────
isProductionEnv(env: string): boolean { return /prod/i.test(env); }
onApprove(apr: ApprovalRequest): void {
this.activeApr.set(apr);
if (this.isProductionEnv(apr.targetEnvironment)) { this.approveConfirmRef.open(); }
else { this.confirmApprove(); }
}
confirmApprove(): void {
const apr = this.activeApr();
if (!apr) return;
this.approvalApi.approve(apr.id, 'Approved from deployments page').pipe(take(1)).subscribe({
next: () => { this.activeApr.set(null); this.loadPendingApprovals(); },
error: () => this.activeApr.set(null),
});
}
onReject(apr: ApprovalRequest): void {
this.activeApr.set(apr);
this.rejectReason = '';
this.showRejectDlg.set(true);
}
confirmReject(): void {
const apr = this.activeApr();
if (!apr) return;
this.approvalApi.reject(apr.id, this.rejectReason || 'Rejected from deployments page').pipe(take(1)).subscribe({
next: () => { this.showRejectDlg.set(false); this.activeApr.set(null); this.loadPendingApprovals(); },
error: () => { this.showRejectDlg.set(false); this.activeApr.set(null); },
});
}
cancelReject(): void { this.showRejectDlg.set(false); this.activeApr.set(null); }
onView(apr: ApprovalRequest): void {
this.detailApr.set(apr);
this.detailFull.set(null);
this.showDetailDlg.set(true);
this.approvalApi.getApproval(apr.id).pipe(take(1)).subscribe({
next: (d) => this.detailFull.set(d),
error: () => {},
});
}
closeDetail(): void { this.showDetailDlg.set(false); this.detailApr.set(null); this.detailFull.set(null); }
private loadPendingApprovals(): void {
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(take(1)).subscribe({
next: (approvals) => this.pendingApprovals.set(approvals),
next: (approvals) => { this.pendingApprovals.set(approvals); setTimeout(() => this.updateApcArrows(), 150); },
error: () => this.pendingApprovals.set([]),
});
}

View File

@@ -2,13 +2,13 @@
<header class="triage-artifacts__header">
<div>
<h1>
Artifact workspace
Vulnerabilities
@if (isDemo()) {
<span class="demo-badge">(Demo)</span>
}
</h1>
<p class="triage-artifacts__subtitle">
Triage live artifacts by lane, then open a single evidence-first decision workspace.
Triage artifacts by lane, then open an evidence-first decision workspace.
</p>
</div>

View File

@@ -11,6 +11,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import { DateFormatService } from '../../core/i18n/date-format.service';
import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
import {
@@ -69,6 +70,7 @@ export class TriageArtifactsComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly dateFmt = inject(DateFormatService);
private readonly laneState = inject(TriageLaneStateService);
readonly loading = signal(false);
@@ -324,11 +326,7 @@ export class TriageArtifactsComponent implements OnInit {
formatWhen(value: string | null): string {
if (!value) return '\u2014';
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
return this.dateFmt.toLocaleString(value);
}
nextLaneAction(row: TriageArtifactRow): { readonly label: string; readonly lane: TriageArtifactLane } {

View File

@@ -8,8 +8,8 @@ export const TRIAGE_ROUTES: Routes = [
},
{
path: 'artifacts',
title: 'Artifact Workspace',
data: { breadcrumb: 'Artifacts' },
title: 'Vulnerabilities',
data: { breadcrumb: 'Vulnerabilities' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
},

View File

@@ -5,10 +5,17 @@
* Matches the System Settings reference design: selected-tab background,
* mandatory icon per tab, optional status indicator with hover hint.
*
* URL Sync (recommended):
* Set `urlParam="tab"` to persist the active tab in the URL query string.
* On page reload, the tab is restored from `?tab=<id>`.
* Pages using child routes (e.g. ConsoleAdminLayoutComponent) should NOT
* set urlParam — they manage URL sync via router.navigate() themselves.
*
* Usage:
* <stella-page-tabs
* [tabs]="myTabs"
* [activeTab]="activeTabSignal()"
* urlParam="tab"
* (tabChange)="activeTabSignal.set($event)"
* />
*
@@ -21,7 +28,10 @@ import {
Input,
Output,
EventEmitter,
OnInit,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
export interface StellaPageTab {
/** Unique tab identifier */
@@ -93,6 +103,9 @@ export interface StellaPageTab {
}
</button>
}
<div class="spt__bar-action">
<ng-content select="[tabBarAction]" />
</div>
</nav>
<!-- Panel area (slot for content) -->
@@ -114,10 +127,21 @@ export interface StellaPageTab {
/* ---- Tab bar ---- */
.spt__bar {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 0;
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 0;
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
/* ---- Bar action (projected page action) ---- */
.spt__bar-action {
margin-left: auto;
display: flex;
align-items: center;
padding: 0 0.75rem;
}
/* ---- Individual tab ---- */
@@ -240,27 +264,51 @@ export interface StellaPageTab {
/* ---- Responsive ---- */
@media (max-width: 640px) {
.spt__bar {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.spt__panel {
padding: 0.875rem;
}
}
`],
})
export class StellaPageTabsComponent {
export class StellaPageTabsComponent implements OnInit {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
@Input() tabs: readonly StellaPageTab[] = [];
@Input() activeTab = '';
@Input() ariaLabel = 'Page tabs';
/**
* Query parameter name used to persist the active tab in the URL.
* Set to `"tab"` (or any string) to enable URL sync.
* Leave empty/unset to disable (for pages that manage URL sync themselves,
* e.g. layout components with child routes).
*/
@Input() urlParam = '';
@Output() tabChange = new EventEmitter<string>();
ngOnInit(): void {
if (!this.urlParam) return;
const params = this.route.snapshot.queryParamMap;
const tabFromUrl = params.get(this.urlParam);
if (tabFromUrl && tabFromUrl !== this.activeTab && this.tabs.some(t => t.id === tabFromUrl)) {
// Emit so the parent signal updates to match the URL
this.tabChange.emit(tabFromUrl);
}
}
onSelect(tab: StellaPageTab): void {
if (tab.disabled) return;
this.tabChange.emit(tab.id);
if (this.urlParam) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { [this.urlParam]: tab.id },
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
}
/** Split multi-path icon strings (delimited by |||) */