From cf20a8bc068edd096d62f0c1ba5d476ff3bd6506 Mon Sep 17 00:00:00 2001 From: master <> Date: Fri, 27 Mar 2026 11:46:17 +0200 Subject: [PATCH] Fix 11 UI consistency issues across web console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Web/StellaOps.Web/src/app/app.config.ts | 27 ++ .../src/app/core/api/approval.client.ts | 2 +- .../audit-log/audit-event-detail.component.ts | 6 +- .../audit-log/audit-export.component.ts | 2 +- .../audit-log/audit-integrations.component.ts | 4 +- .../audit-log-dashboard.component.ts | 370 ++++++++---------- .../audit-log/audit-log-table.component.ts | 16 +- .../dashboard-v3/dashboard-v3.component.ts | 8 +- .../doctor/doctor-dashboard.component.html | 255 ++++++------ .../features/doctor/services/doctor.store.ts | 9 + .../release-detail.component.ts | 3 +- .../release-list/release-list.component.ts | 26 +- .../releases/releases-activity.component.ts | 368 +++++++++++++---- .../triage/triage-artifacts.component.html | 4 +- .../triage/triage-artifacts.component.ts | 8 +- .../src/app/routes/triage.routes.ts | 4 +- .../stella-page-tabs.component.ts | 62 ++- 17 files changed, 671 insertions(+), 503 deletions(-) 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

Details

-
{{ event()?.details | json }}
+
{{ (event()?.details ?? {}) | json }}
@if (event()?.diff) { @@ -108,11 +108,11 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo

Before

-
{{ event()?.diff?.before | json }}
+
{{ (event()?.diff?.before ?? {}) | json }}

After

-
{{ event()?.diff?.after | json }}
+
{{ (event()?.diff?.after ?? {}) | json }}
@if (event()?.diff?.fields?.length) { diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts index 961e524fe..6cd5937aa 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts @@ -122,7 +122,7 @@ import { AuditExportRequest, AuditExportResponse, AuditLogFilters, AuditModule, {{ 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) { -
-
- {{ formatAnomalyType(alert.type) }} - {{ formatTime(alert.detectedAt) }} -
-

{{ 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.

} -
-
- } -
-

Quick Access

- -
- -
-
-

Recent Events

- View all -
- - - - - - - - - - - - @for (event of recentEvents(); track event.id) { - - - - - - - + @if (anomalies().length > 0) { +
+

Anomaly Alerts

+
+ @for (alert of anomalies(); track alert.id) { +
+
+ {{ formatAnomalyType(alert.type) }} + {{ formatTime(alert.detectedAt) }} +
+

{{ alert.description }}

+ +
+ } +
+
} - -
TimestampModuleActionActorResource
{{ formatTime(event.timestamp) }}{{ event.module }}{{ event.action }}{{ event.actor.name }}{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}
-
+ +
+
+

Recent Events

+ +
+ + + + + + + + + + + + @for (event of recentEvents(); track event.id) { + + + + + + + + } + +
TimestampModuleActionActorResource
{{ 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) { @@ -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 {
-

Mission Board

+

Dashboard

{{ tenantLabel() }}

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

Doctor Packs

-

Discovered integrations and available checks

-
- - - -
- @for (pack of store.packGroups(); track pack.category) { - @if (activePackTab() === pack.category) { -
- @for (plugin of pack.plugins; track plugin.pluginId) { -
-
-
- {{ plugin.displayName }} - {{ plugin.pluginId }} -
-
- {{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks - @if (plugin.version && plugin.version !== 'unknown') { - v{{ plugin.version }} - } -
-
- @if (plugin.checks.length > 0) { -
- @for (check of plugin.checks; track check.checkId) { - {{ check.checkId }} - } -
- } @else { -
Checks not discovered yet.
- } -
- } -
- } - } -
-
- } - - +
@for (sev of severities; track sev.value) { -
+ @if (store.hasReport()) { +
+ @if (overallHealthy()) { + + All OK + } @else { + + {{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }} + } +
+ } @if (store.searchQuery() || store.severityFilter().length || store.categoryFilter()) {
- -
- @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) { +
+
+

Doctor Packs

+

Discovered integrations and available checks

- } - @if (store.hasReport()) { -
- - {{ getResultsForTab().length }} of {{ store.report()?.results?.length || 0 }} checks - -
- -
- @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) { +
+
+
+ {{ plugin.displayName }} + {{ plugin.pluginId }} +
+
+ {{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks + @if (plugin.version && plugin.version !== 'unknown') { + v{{ plugin.version }} + } +
+
+ @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.

- -
- } -
+
+ } @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()) {

{{ modeLabel() }} · {{ release()!.name }} {{ release()!.version }}

-

{{ release()!.digest || 'digest-unavailable' }}

+

{{ release()!.digest || 'Pending digest' }}

{{ release()!.releaseType }} {{ release()!.targetRegion || '-' }} @@ -200,6 +200,7 @@ interface ReloadOptions { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts index e2f132ccd..746c595b0 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts @@ -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: `
@@ -28,6 +29,7 @@ import { PageActionService } from '../../../../core/services/page-action.service

Release Versions

Digest-first release version catalog across standard and hotfix lanes

+
@@ -187,7 +189,7 @@ import { PageActionService } from '../../../../core/services/page-action.service - {{ 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') { -
- - @if (!pendingBannerCollapsed()) { - - - - - - - - - - - - - @for (apr of pendingApprovals(); track apr.id) { - - - - - - - - - } - -
ReleasePromotionGateRiskExpires
{{ apr.releaseName }} {{ apr.releaseVersion }}{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}{{ deriveGateType(apr) }}{{ apr.urgency }}{{ timeRemaining(apr.expiresAt) }}View
- } +
+
+

+ + {{ pendingApprovals().length }} Pending Approval{{ pendingApprovals().length === 1 ? '' : 's' }} +

+ +
+
+ @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) }} +
+
+ + + +
+
+ } +
} + + + + + @if (showRejectDlg()) { + + } + + + + @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...
} +
+
+ + > + + @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 { - - - - - - - - - - - - - - +
+ @if (showApcLeft()) { + + } +
@for (apr of filteredApprovals(); track apr.id) { -
- - - - - - - - - - } @empty { - +
+
+
{{ 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') { + + + } + +
+
} - -
ReleasePromotionGateRiskStatusRequesterExpires
{{ apr.releaseName }} {{ apr.releaseVersion }}{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}{{ deriveGateType(apr) }}{{ apr.urgency }}{{ apr.status }}{{ apr.requestedBy }}{{ timeRemaining(apr.expiresAt) }}View
No approvals match the active gate filters.
+ + @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 @@

- Artifact workspace + Vulnerabilities @if (isDemo()) { (Demo) }

- Triage live artifacts by lane, then open a single evidence-first decision workspace. + Triage artifacts by lane, then open an evidence-first decision workspace.

diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts index 7819a6c86..dbfce35d0 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts @@ -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(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 } { diff --git a/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts b/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts index 10d4b602f..acfd565d9 100644 --- a/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts @@ -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), }, diff --git a/src/Web/StellaOps.Web/src/app/shared/components/stella-page-tabs/stella-page-tabs.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/stella-page-tabs/stella-page-tabs.component.ts index bf926a8a8..50ea9bc5e 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/stella-page-tabs/stella-page-tabs.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/stella-page-tabs/stella-page-tabs.component.ts @@ -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=`. + * Pages using child routes (e.g. ConsoleAdminLayoutComponent) should NOT + * set urlParam — they manage URL sync via router.navigate() themselves. + * * Usage: * * @@ -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 { } } +
+ +
@@ -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(); + 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 |||) */