diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts
index 3e1879639..393f07c44 100644
--- a/src/Web/StellaOps.Web/src/app/app.config.ts
+++ b/src/Web/StellaOps.Web/src/app/app.config.ts
@@ -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,
diff --git a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts
index 3421dd159..f3686f45f 100644
--- a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts
+++ b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts
@@ -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,
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
index 98b533df0..ea799870f 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
@@ -99,7 +99,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
{{ exp.exportId.slice(0, 8) }}...
{{ exp.status }}
- {{ exp.eventCount | number }}
+ {{ (exp.eventCount ?? 0) | number }}
{{ formatTime(exp.createdAt) }}
{{ exp.expiresAt ? formatTime(exp.expiresAt) : '-' }}
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts
index 493acb3b8..6bbae433b 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts
@@ -85,11 +85,11 @@ import { AuditEvent } from '../../core/api/audit-log.models';
Before
-
{{ selectedEvent()?.diff?.before | json }}
+
{{ (selectedEvent()?.diff?.before ?? {}) | json }}
After
-
{{ selectedEvent()?.diff?.after | json }}
+
{{ (selectedEvent()?.diff?.after ?? {}) | json }}
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
index fd122f8b5..96d1354b0 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
@@ -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: `
- @if (stats()) {
-
-
- @for (entry of moduleStats(); track entry.module) {
-
- }
-
- }
+
+ @switch (activeTab()) {
+ @case ('overview') {
+
+ @if (stats()) {
+
+
+ @for (entry of moduleStats(); track entry.module) {
+
+ }
+
+ }
- @if (allCountsZero()) {
-
-
Audit events will appear as the platform is used. Events are captured automatically for:
-
- Release seals, promotions, and approvals
- Policy changes and activations
- VEX decisions and consensus votes
- Integration configuration changes
- Identity and access management
-
-
The Evidence rail indicator shows ON — audit capture is active.
-
- }
-
- @if (anomalies().length > 0) {
-
- Anomaly Alerts
-
- @for (alert of anomalies(); track alert.id) {
-
-
-
{{ alert.description }}
-
+ @if (allCountsZero()) {
+
+
Audit events will appear as the platform is used. Events are captured automatically for:
+
+ Release seals, promotions, and approvals
+ Policy changes and activations
+ VEX decisions and consensus votes
+ Integration configuration changes
+ Identity and access management
+
+
The Evidence rail indicator shows ON — audit capture is active.
}
-
-
- }
-
-
-
-
-
-
-
- Timestamp
- Module
- Action
- Actor
- Resource
-
-
-
- @for (event of recentEvents(); track event.id) {
-
- {{ formatTime(event.timestamp) }}
- {{ event.module }}
- {{ event.action }}
- {{ event.actor.name }}
- {{ event.resource.type }}: {{ event.resource.name || event.resource.id }}
-
+ @if (anomalies().length > 0) {
+
+ Anomaly Alerts
+
+ @for (alert of anomalies(); track alert.id) {
+
+
+
{{ alert.description }}
+
+
+ }
+
+
}
-
-
-
+
+
+
+
+
+
+ Timestamp
+ Module
+ Action
+ Actor
+ Resource
+
+
+
+ @for (event of recentEvents(); track event.id) {
+
+ {{ formatTime(event.timestamp) }}
+ {{ event.module }}
+ {{ event.action }}
+ {{ event.actor.name }}
+ {{ event.resource.type }}: {{ event.resource.name || event.resource.id }}
+
+ }
+
+
+
+ }
+ @case ('all-events') {
}
+ @case ('policy') {
}
+ @case ('authority') {
}
+ @case ('vex') {
}
+ @case ('integrations') {
}
+ @case ('trust') {
}
+ @case ('timeline') {
}
+ @case ('correlations') {
}
+ }
+
`,
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('overview');
+
readonly stats = signal(null);
readonly recentEvents = signal([]);
readonly anomalies = signal([]);
@@ -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 = {
- authority: 'Authority',
+ const labels: Record = {
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 = {
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());
}
}
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
index 8d52257ed..a5027def7 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
@@ -192,7 +192,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
}
Details
-
{{ selectedEvent()?.details | json }}
+
{{ (selectedEvent()?.details ?? {}) | json }}
@if (selectedEvent()?.diff) {
View Diff
@@ -216,11 +216,11 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
Before
-
{{ diffEvent()?.diff?.before | json }}
+
{{ (diffEvent()?.diff?.before ?? {}) | json }}
After
-
{{ diffEvent()?.diff?.after | json }}
+
{{ (diffEvent()?.diff?.after ?? {}) | json }}
@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; }
diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts
index 34f94618e..7badb7a0f 100644
--- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts
@@ -89,7 +89,7 @@ interface PendingAction {
@@ -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;
}
diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html
index 0870c2b75..aff378e2b 100644
--- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html
@@ -70,92 +70,7 @@
}
-
- @if (store.hasReport()) {
-
-
- @if (overallHealthy()) {
-
-
-
-
-
All systems operational
- } @else {
-
-
-
-
-
-
{{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }} detected
- }
-
-
-
- }
-
-
- @if (store.packGroups().length) {
-
-
-
-
- @for (pack of store.packGroups(); track pack.category) {
-
- {{ pack.label }}
- {{ pack.plugins.length }}
-
- }
-
-
-
- @for (pack of store.packGroups(); track pack.category) {
- @if (activePackTab() === pack.category) {
-
- @for (plugin of pack.plugins; track plugin.pluginId) {
-
-
- @if (plugin.checks.length > 0) {
-
- @for (check of plugin.checks; track check.checkId) {
- {{ check.checkId }}
- }
-
- } @else {
- Checks not discovered yet.
- }
-
- }
-
- }
- }
-
-
- }
-
-
+
@@ -171,15 +86,42 @@
@for (sev of severities; track sev.value) {
-
+
+ @if (severityCount(sev.value) !== null) {
+ {{ severityCount(sev.value) }}
+ }
{{ sev.label }}
}
+ @if (store.hasReport()) {
+
+ @if (overallHealthy()) {
+
+
+
+
+
All OK
+ } @else {
+
+
+
+
+
+
{{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }}
+ }
+
+ }
@if (store.searchQuery() || store.severityFilter().length || store.categoryFilter()) {
Clear
@@ -187,71 +129,94 @@
}
-
-
- @if (store.state() === 'idle' && !store.hasReport()) {
-
-
-
No Diagnostics Run Yet
-
Click "Quick" to run a fast diagnostic, or "Full" for comprehensive analysis.
+
+ @if (store.packGroups().length) {
+
+
- }
- @if (store.hasReport()) {
-
-
-
- @for (result of getResultsForTab(); track trackResult($index, result)) {
-
-
{{ result.checkId }}
-
- @switch (result.severity) {
- @case ('pass') { OK }
- @case ('fail') { FAIL }
- @case ('warn') { WARN }
- @case ('info') { INFO }
- @case ('skip') { SKIP }
+ @if (visiblePackPlugins().length > 0) {
+
+ @for (plugin of visiblePackPlugins(); track plugin.pluginId) {
+
+
+ @if (filteredChecks(plugin).length > 0) {
+
+ @for (check of filteredChecks(plugin); track check.checkId) {
+
+
+ @if (checkResult(check.checkId)) {
+
+ }
+
{{ check.checkId }}
+
+ @switch (checkStatus(check.checkId)) {
+ @case ('pass') {
[ OK ] }
+ @case ('fail') {
[ FAIL ] }
+ @case ('warn') {
[ WARN ] }
+ @case ('info') {
[ INFO ] }
+ @case ('skip') {
[ SKIP ] }
+ @default {
[ NOT RUN ] }
+ }
+
+ @if (expandedCheckId() === check.checkId && checkResult(check.checkId)) {
+
+
+
+ }
+
+ }
+
+ } @else if (plugin.checks.length > 0) {
+ No checks match the active filters.
+ } @else {
+ Checks not discovered yet.
}
-
-
- @if (isResultSelected(result)) {
-
-
-
+
}
- }
-
- @if (getResultsForTab().length === 0) {
-
-
No checks match your current filters.
-
Clear filters
-
- }
-
+
+ } @else {
+ No packs match the active filters.
+ }
- }
-
+
+ }
+
+
+ @if (store.state() === 'idle' && !store.hasReport() && !store.packGroups().length) {
+
+
+
No Diagnostics Run Yet
+
Click "Quick" to run a fast diagnostic, or "Full" for comprehensive analysis.
+
+ }
@if (showExportDialog()) {
diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts
index 905118b1b..6f469851e 100644
--- a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts
+++ b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts
@@ -385,6 +385,11 @@ export class DoctorStore {
return a < b ? -1 : 1;
}
+ private static readonly CATEGORY_LABELS: Record
= {
+ 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);
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
index 82ed1da2b..7cd419913 100644
--- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
@@ -155,7 +155,7 @@ interface ReloadOptions {
@if (release()) {
- {{ release.digest || 'digest-unavailable' }}
+ {{ release.digest || 'Pending digest' }}
{{ release.name }} · {{ release.version }}
@@ -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 ─── */
diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
index 4f62d180a..dfdcc032e 100644
--- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts
@@ -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: `
@@ -75,50 +78,107 @@ function deriveOutcomeIcon(status: string): string {
{{ context.timeWindow() }}
-
+
@if (pendingApprovals().length > 0 && viewMode() !== 'approvals') {
-
-
-
-
-
- {{ pendingApprovals().length }} pending approval{{ pendingApprovals().length === 1 ? '' : 's' }}
-
- @if (!pendingBannerCollapsed()) {
-
-
-
- Release
- Promotion
- Gate
- Risk
- Expires
-
-
-
-
- @for (apr of pendingApprovals(); track apr.id) {
-
- {{ apr.releaseName }} {{ apr.releaseVersion }}
- {{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}
- {{ deriveGateType(apr) }}
- {{ apr.urgency }}
- {{ timeRemaining(apr.expiresAt) }}
- View
-
- }
-
-
- }
+
+
+
+ @for (apr of pendingApprovals().slice(0, 5); track apr.id) {
+
+
+
{{ apr.releaseName }} @if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {{{ apr.releaseVersion }} }
+
{{ apr.urgency }}
+
+
+
{{ apr.sourceEnvironment }}
+
+
{{ apr.targetEnvironment }}
+
+
+ by {{ apr.requestedBy }}
+ {{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}
+ {{ timeRemaining(apr.expiresAt) }}
+
+
+ Approve
+ Reject
+ View
+
+
+ }
+
}
+
+
+
+
+ @if (showRejectDlg()) {
+
+
+
Reject Release
+
Reject {{ activeApr()?.releaseName }} {{ activeApr()?.releaseVersion }} to {{ activeApr()?.targetEnvironment }} ?
+
Reason (optional)
+
+
+ Cancel
+ Reject
+
+
+
+ }
+
+
+
+ @if (detailApr(); as d) {
+
+
Promotion
+
From {{ d.sourceEnvironment }}
+
To {{ d.targetEnvironment }}
+
Urgency {{ d.urgency }}
+
Requested by {{ d.requestedBy }}
+
Approvals {{ d.currentApprovals }} / {{ d.requiredApprovals }}
+
+ @if (d.justification) {
Justification {{ d.justification }}
}
+ @if (detailFull()?.gateResults?.length) {
+
Gate Results
+ @for (g of detailFull()!.gateResults; track g.gateId) {
+
{{ g.status }} {{ g.gateName }} {{ g.message }}
+ }
+
+ }
+ @if (detailFull()?.releaseComponents?.length) {
+
Components
+ @for (c of detailFull()!.releaseComponents; track c.name) {
{{ c.name }} {{ c.version }}
}
+
+ }
+
+ } @else { Loading...
}
+ Close
+
+
+ >
+
+
@if (viewMode() === 'approvals') {
@@ -140,45 +200,55 @@ function deriveOutcomeIcon(status: string): string {
-
-
-
-
-
}
+ } @else if (filteredApprovals().length === 0) {
+ No approvals match the active gate filters.
} @else {
-
-
-
- Release
- Promotion
- Gate
- Risk
- Status
- Requester
- Expires
-
-
-
-
+
+ @if (showApcLeft()) {
+
+
+
+ }
+
@for (apr of filteredApprovals(); track apr.id) {
-
- {{ apr.releaseName }} {{ apr.releaseVersion }}
- {{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}
- {{ deriveGateType(apr) }}
- {{ apr.urgency }}
- {{ apr.status }}
- {{ apr.requestedBy }}
- {{ timeRemaining(apr.expiresAt) }}
- View
-
- } @empty {
-
No approvals match the active gate filters.
+
+
+
{{ apr.releaseName }} @if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {{{ apr.releaseVersion }} }
+
{{ apr.urgency }}
+
+
+
{{ apr.sourceEnvironment }}
+
+
{{ apr.targetEnvironment }}
+
+
+ by {{ apr.requestedBy }}
+ {{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}
+ {{ timeRemaining(apr.expiresAt) }}
+
+
+ {{ apr.status }}
+ {{ deriveGateType(apr) }}
+
+
+ @if (apr.status === 'pending') {
+ Approve
+ Reject
+ }
+ View
+
+
}
-
-
+
+ @if (showApcRight()) {
+
+
+
+ }
+
}
} @else {
@@ -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([]);
readonly viewMode = signal<'timeline' | 'table' | 'correlations' | 'approvals'>('timeline');
- // ── Pending approvals banner ──────────────────────────────────────
+ // ── Pending approvals card lane ──────────────────────────────────
+ @ViewChild('apcScroll') apcScrollRef?: ElementRef;
+ @ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
readonly pendingApprovals = signal([]);
readonly pendingBannerCollapsed = signal(false);
+ readonly showApcLeft = signal(false);
+ readonly showApcRight = signal(false);
+ readonly activeApr = signal(null);
+ readonly showRejectDlg = signal(false);
+ readonly showDetailDlg = signal(false);
+ readonly detailApr = signal(null);
+ readonly detailFull = signal(null);
+ rejectReason = '';
// ── Approvals tab state ───────────────────────────────────────────
readonly allApprovals = signal([]);
@@ -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([]),
});
}
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html
index a53de298b..6b977f950 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html
@@ -2,13 +2,13 @@