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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 ─── */
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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 |||) */
|
||||
|
||||
Reference in New Issue
Block a user