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 0928b4f2c..a6ca18bef 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 @@ -1,9 +1,9 @@ /** - * Dashboard V3 - Mission Board - * Sprint: SPRINT_20260218_012_FE_ui_v2_rewire_dashboard_v3_mission_board (D7-01 through D7-05) + * Dashboard V3 - Mission Board (3-column redesign) + * Sprint: SPRINT_20260315_005_FE_dashboard_3col_real_api * - * Release mission board: aggregates environment risk, SBOM state, reachability, - * and data-integrity signals. Summarises; does not duplicate domain ownership. + * Layout: CSS Grid with security posture (1/3) | environments + actions (2/3) + * Data: Real API calls to vulnerability stats, advisory source status, and context store. */ import { @@ -12,13 +12,29 @@ import { computed, inject, signal, + OnInit, } from '@angular/core'; -import { TitleCasePipe, UpperCasePipe } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { catchError, take } from 'rxjs/operators'; +import { of } from 'rxjs'; + import { PlatformContextEnvironment, PlatformContextStore, } from '../../core/context/platform-context.store'; +import { + VULNERABILITY_API, + type VulnerabilityApi, +} from '../../core/api/vulnerability.client'; +import type { VulnerabilityStats } from '../../core/api/vulnerability.models'; +import { + SourceManagementApi, + type SourceStatusResponse, +} from '../integrations/advisory-vex-sources/source-management.api'; +import { + AUTH_SERVICE, + type AuthService, +} from '../../core/auth/auth.service'; interface EnvironmentCard { id: string; @@ -35,33 +51,38 @@ interface EnvironmentCard { lastDeployedAt: string; } -interface NightlyOpsSignal { - id: string; - label: string; - status: 'ok' | 'warn' | 'fail'; - detail: string; +interface AdvisoryFeedSummary { + totalSources: number; + enabledSources: number; + healthySources: number; + failedSources: number; + loaded: boolean; } -// MissionSummary removed — dashboard now computes from real environment data - @Component({ selector: 'app-dashboard-v3', standalone: true, - imports: [RouterLink, TitleCasePipe, UpperCasePipe], + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- +
-

Dashboard

-

Mission board for release health across regions and environments

+

Mission Board

+

{{ tenantLabel() }}

- +
@if (hasNoEnvironments()) { - +

Welcome to Stella Ops

@@ -99,291 +120,340 @@ interface NightlyOpsSignal {
} @else { - -
-
-
{{ filteredEnvironments().length }}
-
Environments
- View all -
+ +
+ + -
-
- - {{ healthyCount() }} -
-
Healthy Environments
- Ops detail -
-
+ +
+ +
+
+
{{ filteredEnvironments().length }}
+
Environments
+
+
+
{{ blockedCount() }}
+
Blocked
+
+
+
{{ degradedCount() }}
+
Degraded
+
+
+
+ + {{ healthyCount() }} +
+
Healthy
+
+
+ + +
+
+

Promotion Pipeline

+ All environments +
+ +
+ @for (env of filteredEnvironments(); track env.id) { +
+
+
+ {{ env.name }} + {{ env.region }} +
+ + {{ env.deployStatus }} + +
+ +
+
+ SBOM + + {{ env.sbomFreshness }} + +
+
+ CritR + + {{ env.critRCount }} + +
+
+ HighR + + {{ env.highRCount }} + +
+
+ B/I/R + {{ env.birCoverage }} +
+
+ Pending + + {{ env.pendingApprovals }} + +
+
+ + +
+ } + + @if (filteredEnvironments().length === 0) { +
+

No environments match the current filter.

+
+ } +
+
+ + + @if (riskEnvironments().length > 0) { +
+
+

Environments at Risk

+ Open environments +
+
+ + + + + + + + + + + + + @for (env of riskEnvironments(); track env.id) { + + + + + + + + + } + +
Region/EnvHealthSBOMCritRB/I/RAction
{{ env.region }} / {{ env.name }}{{ env.deployStatus }}{{ env.sbomFreshness }}{{ env.critRCount }}{{ env.birCoverage }} + + Open + +
+
+
+ } + + + +
+
} - -
-
-

Regional Pipeline

- All environments + +
- -
-
-

Environments at Risk

- Open environments +
+ + Security
- - @if (riskEnvironments().length === 0) { -
-

All environments are healthy.

-
- } @else { -
- - - - - - - - - - - - - - @for (env of riskEnvironments(); track env.id) { - - - - - - - - - - } - -
Region/EnvDeploy HealthSBOM StatusCrit ReachHybrid B/I/RLast SBOMAction
{{ env.region }} / {{ env.name }}{{ env.deployStatus }}{{ env.sbomFreshness }}{{ env.critRCount }}{{ env.birCoverage }}{{ env.lastDeployedAt }} - - Open - -
-
- } -
- - -
- -
-
-

SBOM Findings Snapshot

- View SBOM -
-
-
- {{ sbomStats().criticalEnvCount }} - Critical Reachable Environments -
-
- {{ sbomStats().totalCritR }} - Total Critical Reachable Findings -
-
- - {{ sbomStats().noIssueCount }} - - Environments with No Critical Findings -
- @if (sbomStats().totalCritR === 0) { -

No critical reachable issues detected in current scope.

- } -
- -
- - -
-
-

Reachability

- View reachability -
-
-
-
- B (Binary) -
-
-
- {{ reachabilityStats().bCoverage }}% -
-
- I (Interpreted) -
-
-
- {{ reachabilityStats().iCoverage }}% -
-
- R (Runtime) -
-
-
- {{ reachabilityStats().rCoverage }}% -
-
-

- Hybrid B/I/R reachability coverage across production environments. -

-
- -
- - -
-
-

Nightly Ops Signals

- Open Data Integrity -
-
- @for (signal of nightlyOpsSignals(); track signal.id) { -
- {{ signal.label }} - - {{ signal.status | uppercase }} - - {{ signal.detail }} -
- } -
- -
-
- - - +
+ + Evidence +
+
+ + DLQ +
+ + + Diagnostics + + `, styles: [` + /* ========================================================================= + Mission Board - 3-column CSS Grid Layout + ========================================================================= */ + .mission-board { padding: 1.5rem; max-width: 1600px; margin: 0 auto; - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; gap: 1.5rem; + min-height: calc(100vh - 120px); } - /* Header */ + /* -- Header (full width) ------------------------------------------------- */ .board-header { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; flex-wrap: wrap; gap: 1rem; } @@ -400,7 +470,29 @@ interface NightlyOpsSignal { margin: 0.25rem 0 0; } - /* Welcome Guide */ + .refresh-btn { + padding: 0.4rem 1rem; + font-size: 0.8rem; + font-weight: var(--font-weight-semibold); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-elevated); + color: var(--color-text-primary); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + } + + .refresh-btn:hover:not(:disabled) { + background: var(--color-surface-primary); + border-color: var(--color-brand-primary); + } + + .refresh-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + /* -- Welcome Guide (full width, shown when no environments) --------------- */ .welcome-guide { background: var(--color-surface-primary); border: 1px solid var(--color-brand-primary); @@ -477,30 +569,220 @@ interface NightlyOpsSignal { color: var(--color-text-secondary); } - /* Quick Links */ - .quick-links { + /* -- Board Body: 2-column layout ----------------------------------------- */ + .board-body { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1.5rem; + align-items: start; + } + + /* -- Left Column: Security Posture --------------------------------------- */ + .security-posture { display: flex; + flex-direction: column; + gap: 1rem; + } + + .posture-card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + } + + .posture-card-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border-primary); + background: var(--color-surface-elevated); + } + + .posture-card-title { + margin: 0; + font-size: 0.85rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-secondary); + } + + .posture-card-body { + padding: 1rem; + display: flex; + flex-direction: column; gap: 0.75rem; - flex-wrap: wrap; - border-top: 1px solid var(--color-border-primary); - padding-top: 1.25rem; + } + + .card-loading { + margin: 0; + font-size: 0.8rem; + color: var(--color-text-muted); + } + + .card-empty { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-secondary); + } + + .card-note { + margin: 0; + font-size: 0.78rem; + color: var(--color-text-muted); + } + + .posture-link { + display: inline-block; + font-size: 0.8rem; + color: var(--color-brand-primary); + text-decoration: none; + } + + .posture-link:hover { + text-decoration: underline; + } + + /* Severity grid (vulnerability summary) */ + .severity-grid { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .severity-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm, 4px); + font-size: 0.85rem; + } + + .severity-row.critical { + background: color-mix(in srgb, var(--color-status-error) 10%, transparent); + } + + .severity-row.high { + background: color-mix(in srgb, var(--color-status-warning) 10%, transparent); + } + + .severity-row.medium { + background: var(--color-surface-elevated); + } + + .severity-row.low { + background: var(--color-surface-elevated); + } + + .severity-label { + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + } + + .severity-count { + font-weight: var(--font-weight-bold); + font-size: 1rem; + } + + .severity-row.critical .severity-count { + color: var(--color-status-error); + } + + .severity-row.high .severity-count { + color: var(--color-status-warning); + } + + .posture-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.75rem; + color: var(--color-text-muted); + } + + /* SBOM stats */ + .sbom-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + text-align: center; + } + + .sbom-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + } + + .sbom-stat-value { + font-size: 1.25rem; + font-weight: var(--font-weight-bold); + } + + .sbom-stat-value.danger { + color: var(--color-status-error); + } + + .sbom-stat-label { + font-size: 0.68rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + /* Feed stats */ + .feed-stats { + display: flex; + gap: 1.25rem; + } + + .feed-stat { + display: flex; + align-items: baseline; + gap: 0.3rem; + } + + .feed-stat-value { + font-size: 1.2rem; + font-weight: var(--font-weight-bold); + } + + .feed-stat-value.healthy { + color: var(--color-status-success); + } + + .feed-stat-value.danger { + color: var(--color-status-error); + } + + .feed-stat-label { + font-size: 0.75rem; + color: var(--color-text-secondary); + } + + /* -- Right Column: Environments & Actions -------------------------------- */ + .environments-actions { + display: flex; + flex-direction: column; + gap: 1.25rem; } /* Mission Summary Strip */ .mission-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; } .summary-card { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - padding: 1rem 1.25rem; + padding: 0.75rem 1rem; display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.15rem; } .summary-card.warning { @@ -512,29 +794,24 @@ interface NightlyOpsSignal { } .summary-value { - font-size: 1.75rem; + font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-primary); } - .summary-value.env-name { - font-size: 1rem; - font-weight: var(--font-weight-semibold); - } - .summary-label { - font-size: 0.8rem; + font-size: 0.75rem; color: var(--color-text-secondary); } - .summary-link { - font-size: 0.8rem; - color: var(--color-brand-primary); - text-decoration: none; - margin-top: 0.25rem; + /* Pipeline Board */ + .pipeline-board { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + padding: 1.25rem; } - /* Section headers */ .section-header { display: flex; justify-content: space-between; @@ -554,17 +831,9 @@ interface NightlyOpsSignal { text-decoration: none; } - /* Pipeline Board */ - .pipeline-board { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 1.25rem; - } - .env-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; } @@ -591,7 +860,7 @@ interface NightlyOpsSignal { display: flex; justify-content: space-between; align-items: center; - padding: 0.75rem 1rem; + padding: 0.6rem 0.85rem; border-bottom: 1px solid var(--color-border-primary); } @@ -603,18 +872,18 @@ interface NightlyOpsSignal { .env-name { font-weight: var(--font-weight-semibold); - font-size: 0.95rem; + font-size: 0.9rem; } .env-region { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--color-text-secondary); } .status-badge { - font-size: 0.7rem; + font-size: 0.65rem; font-weight: var(--font-weight-medium); - padding: 0.2rem 0.5rem; + padding: 0.15rem 0.45rem; border-radius: var(--radius-full); text-transform: uppercase; letter-spacing: 0.03em; @@ -628,26 +897,26 @@ interface NightlyOpsSignal { .env-metrics { display: grid; grid-template-columns: repeat(5, 1fr); - padding: 0.75rem 1rem; - gap: 0.5rem; + padding: 0.6rem 0.85rem; + gap: 0.4rem; } .metric { display: flex; flex-direction: column; align-items: center; - gap: 0.15rem; + gap: 0.1rem; } .metric-label { - font-size: 0.65rem; + font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } .metric-value { - font-size: 1rem; + font-size: 0.9rem; font-weight: var(--font-weight-semibold); } @@ -662,13 +931,13 @@ interface NightlyOpsSignal { display: flex; justify-content: space-between; align-items: center; - padding: 0.5rem 1rem; + padding: 0.45rem 0.85rem; border-top: 1px solid var(--color-border-primary); background: var(--color-surface-primary); } .last-deployed { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--color-text-muted); } @@ -678,11 +947,12 @@ interface NightlyOpsSignal { } .env-link { - font-size: 0.8rem; + font-size: 0.75rem; color: var(--color-brand-primary); text-decoration: none; } + /* Risk Table */ .risk-table { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); @@ -697,18 +967,18 @@ interface NightlyOpsSignal { .risk-table__table { width: 100%; border-collapse: collapse; - font-size: 0.84rem; + font-size: 0.82rem; } .risk-table__table th, .risk-table__table td { text-align: left; border-bottom: 1px solid var(--color-border-primary); - padding: 0.55rem 0.45rem; + padding: 0.5rem 0.4rem; } .risk-table__table th { - font-size: 0.72rem; + font-size: 0.7rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; @@ -724,265 +994,27 @@ interface NightlyOpsSignal { font-weight: var(--font-weight-semibold); } - /* Cards Row */ - .cards-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem; - } - - .domain-card { + /* Quick Links */ + .quick-links { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - overflow: hidden; - display: flex; - flex-direction: column; - } - - .card-header { - display: flex; - justify-content: space-between; - align-items: center; padding: 1rem 1.25rem; - border-bottom: 1px solid var(--color-border-primary); - background: var(--color-surface-elevated); } - .card-title { - font-size: 1rem; - font-weight: var(--font-weight-semibold); - margin: 0; - } - - .card-link { - font-size: 0.8rem; - color: var(--color-brand-primary); - text-decoration: none; - } - - .card-body { - padding: 1.25rem; - flex: 1; - display: flex; - flex-direction: column; - gap: 0.75rem; - } - - .card-footer { - padding: 0.75rem 1.25rem; - border-top: 1px solid var(--color-border-primary); - display: flex; - gap: 1rem; - background: var(--color-surface-elevated); - } - - .card-action { + .quick-links-title { + margin: 0 0 0.75rem; font-size: 0.85rem; - color: var(--color-brand-primary); - text-decoration: none; - } - - .card-note { - font-size: 0.8rem; - color: var(--color-text-muted); - margin: 0; - } - - /* SBOM Snapshot */ - .snapshot-stat { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.25rem 0; - } - - .stat-value { - font-size: 1.25rem; - font-weight: var(--font-weight-bold); - } - - .stat-value.danger { color: var(--color-status-error); } - .stat-value.warning { color: var(--color-status-warning); } - - .stat-label { - font-size: 0.8rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; color: var(--color-text-secondary); } - /* B/I/R Matrix */ - .bir-matrix { + .quick-links-grid { display: flex; - flex-direction: column; gap: 0.6rem; - } - - .bir-item { - display: grid; - grid-template-columns: 90px 1fr 40px; - align-items: center; - gap: 0.5rem; - } - - .bir-label { - font-size: 0.8rem; - color: var(--color-text-secondary); - } - - .bir-bar-track { - background: var(--color-surface-elevated); - border-radius: var(--radius-full); - height: 8px; - overflow: hidden; - } - - .bir-bar { - height: 100%; - background: var(--color-brand-primary); - border-radius: var(--radius-full); - transition: width 0.3s ease; - } - - .bir-value { - font-size: 0.8rem; - font-weight: var(--font-weight-medium); - text-align: right; - } - - /* Data Integrity */ - .integrity-stat { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 0.25rem 0.75rem; - padding: 0.45rem 0; - border-bottom: 1px dashed var(--color-border-primary); - } - - .integrity-stat:last-child { - border-bottom: 0; - } - - .integrity-status { - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); - border-radius: var(--radius-full); - padding: 0.15rem 0.5rem; - letter-spacing: 0.04em; - } - - .integrity-status--ok { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - } - - .integrity-status--warn { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - } - - .integrity-status--fail { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - } - - .integrity-detail { - grid-column: 1 / -1; - color: var(--color-text-muted); - font-size: 0.75rem; - } - - /* Status dot */ - .status-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: var(--radius-full); - margin-right: 0.4rem; - vertical-align: middle; - } - - .status-dot.healthy { background: var(--color-status-success); } - .status-dot.degraded { background: var(--color-status-warning); } - .status-dot.error { background: var(--color-status-error); } - - /* Alerts Section */ - .alerts-card { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 1rem 1.25rem; - } - - .alerts-list { - margin: 0; - padding-left: 1.2rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - .alerts-list li { - font-size: 0.9rem; - color: var(--color-text-primary); - } - - .alerts-list a { - color: var(--color-brand-primary); - text-decoration: none; - } - - .alerts-list a:hover { - text-decoration: underline; - } - - /* Activity Section */ - .activity-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; - } - - .activity-card { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 1rem 1.25rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - .activity-card-title { - margin: 0; - font-size: 1rem; - font-weight: var(--font-weight-semibold); - } - - .activity-card-desc { - margin: 0; - font-size: 0.85rem; - color: var(--color-text-secondary); - flex: 1; - } - - .activity-card-link { - font-size: 0.85rem; - color: var(--color-brand-primary); - text-decoration: none; - } - - .activity-card-link:hover { - text-decoration: underline; - } - - /* Domain Navigation */ - .domain-nav { - display: flex; - gap: 0.75rem; flex-wrap: wrap; - border-top: 1px solid var(--color-border-primary); - padding-top: 1.25rem; } .domain-nav-item { @@ -992,7 +1024,7 @@ interface NightlyOpsSignal { padding: 0.4rem 0.9rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-full); - font-size: 0.85rem; + font-size: 0.82rem; color: var(--color-text-primary); text-decoration: none; background: var(--color-surface-elevated); @@ -1001,37 +1033,121 @@ interface NightlyOpsSignal { .domain-nav-item:hover { background: var(--color-surface-primary); + border-color: var(--color-brand-primary); } - .domain-icon { - font-size: 0.7rem; + /* -- Footer: Platform Health Bar ----------------------------------------- */ + .platform-health-bar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.65rem 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + font-size: 0.78rem; + } + + .health-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--color-text-secondary); + } + + .health-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--color-text-muted); + } + + .health-dot.ok { + background: var(--color-status-success); + } + + .health-spacer { + flex: 1; + } + + .health-link { + font-size: 0.78rem; color: var(--color-brand-primary); + text-decoration: none; } + .health-link:hover { + text-decoration: underline; + } + + /* Status dot (reused in summary cards) */ + .status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: var(--radius-full); + margin-right: 0.3rem; + vertical-align: middle; + } + + .status-dot.healthy { background: var(--color-status-success); } + .status-dot.degraded { background: var(--color-status-warning); } + .status-dot.error { background: var(--color-status-error); } + + /* -- Responsive: collapse to single column on mobile --------------------- */ @media (max-width: 768px) { - .board-header { - flex-direction: column; + .board-body { + grid-template-columns: 1fr; } .mission-summary { grid-template-columns: 1fr 1fr; } - .cards-row { - grid-template-columns: 1fr; - } - - .activity-grid { - grid-template-columns: 1fr; + .board-header { + flex-direction: column; + align-items: flex-start; } } `], }) -export class DashboardV3Component { +export class DashboardV3Component implements OnInit { private readonly context = inject(PlatformContextStore); + private readonly vulnApi = inject(VULNERABILITY_API); + private readonly sourceApi = inject(SourceManagementApi); + private readonly authService = inject(AUTH_SERVICE) as AuthService; + // -- Loading states ------------------------------------------------------- + readonly vulnStatsLoading = signal(false); + readonly feedStatusLoading = signal(false); + readonly refreshing = signal(false); + + // -- API data signals ----------------------------------------------------- + readonly vulnStats = signal(null); + readonly feedSummary = signal({ + totalSources: 0, + enabledSources: 0, + healthySources: 0, + failedSources: 0, + loaded: false, + }); + + // -- Context-derived signals ---------------------------------------------- readonly hasNoEnvironments = computed(() => this.context.environments().length === 0); + readonly tenantLabel = computed(() => { + const user = this.authService.user(); + if (user?.tenantName) { + return user.tenantName; + } + const tenantId = this.context.tenantId(); + return tenantId ?? 'Release mission board'; + }); + + readonly platformOk = computed(() => this.context.initialized() && !this.context.error()); + readonly blockedCount = computed(() => this.filteredEnvironments().filter((env) => env.deployStatus === 'blocked').length ); @@ -1044,13 +1160,18 @@ export class DashboardV3Component { this.filteredEnvironments().filter((env) => env.deployStatus === 'healthy').length ); - // No fake fallback data — dashboard shows setup guide when no environments exist. + // No fake fallback data -- dashboard shows setup guide when no environments exist. private readonly fallbackEnvironments: EnvironmentCard[] = []; constructor() { this.context.initialize(); } + ngOnInit(): void { + this.loadVulnerabilityStats(); + this.loadFeedStatus(); + } + readonly allEnvironments = computed(() => { const environments = this.context.environments(); if (environments.length === 0) { @@ -1093,15 +1214,13 @@ export class DashboardV3Component { }; }); - // Reachability stats — zeroed until real scan data is available - readonly reachabilityStats = signal({ - bCoverage: 0, - iCoverage: 0, - rCoverage: 0, - }); - - // Ops signals — empty until real signal data is available - readonly nightlyOpsSignals = signal([]); + refresh(): void { + this.refreshing.set(true); + this.loadVulnerabilityStats(); + this.loadFeedStatus(); + // Reset refreshing after a short delay to provide feedback + setTimeout(() => this.refreshing.set(false), 1500); + } environmentPostureRoute(env: EnvironmentCard): string[] { return ['/setup/topology/environments', env.environmentId, 'posture']; @@ -1116,11 +1235,57 @@ export class DashboardV3Component { }; } + // -- Private: API loaders ------------------------------------------------- + + private loadVulnerabilityStats(): void { + this.vulnStatsLoading.set(true); + this.vulnApi + .getStats() + .pipe( + take(1), + catchError(() => of(null as VulnerabilityStats | null)), + ) + .subscribe((stats) => { + this.vulnStats.set(stats); + this.vulnStatsLoading.set(false); + }); + } + + private loadFeedStatus(): void { + this.feedStatusLoading.set(true); + this.sourceApi + .getStatus() + .pipe( + take(1), + catchError(() => of({ sources: [] } as SourceStatusResponse)), + ) + .subscribe((response) => { + const sources = response.sources ?? []; + const enabledSources = sources.filter((s) => s.enabled).length; + const healthySources = sources.filter( + (s) => s.enabled && s.lastCheck?.isHealthy === true, + ).length; + const failedSources = sources.filter( + (s) => s.enabled && s.lastCheck !== null && s.lastCheck !== undefined && s.lastCheck.isHealthy === false, + ).length; + + this.feedSummary.set({ + totalSources: sources.length, + enabledSources, + healthySources, + failedSources, + loaded: true, + }); + this.feedStatusLoading.set(false); + }); + } + + // -- Private: Environment card mapping ------------------------------------ + private toEnvironmentCard( environment: PlatformContextEnvironment, index: number, ): EnvironmentCard { - const environmentType = environment.environmentType.toLowerCase(); const regionLabel = this.formatRegionLabel(environment.regionId); const statusSeed = this.resolveStatusSeed(environment, index); @@ -1144,7 +1309,7 @@ export class DashboardV3Component { _environment: PlatformContextEnvironment, _index: number, ): Omit { - // Honest defaults — no scan data available until real backends report + // Honest defaults -- no scan data available until real backends report return { deployStatus: 'unknown', sbomFreshness: 'missing', diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts index 198f46a5e..b0c70e623 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts @@ -1,6 +1,10 @@ +import { HttpClient } from '@angular/common/http'; import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { PlatformContextStore } from '../../core/context/platform-context.store'; import { ExportCenterComponent } from '../evidence-export/export-center.component'; import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component'; import { SecurityDispositionPageComponent } from './security-disposition-page.component'; @@ -27,10 +31,21 @@ class StubSecurityDispositionPageComponent {} }) class StubExportCenterComponent {} +const mockContextStore = { + selectedRegions: () => [], + selectedEnvironments: () => [], + initialize: () => {}, + contextVersion: () => 0, +}; + describe('SecurityReportsPageComponent', () => { let fixture: ComponentFixture; + let httpSpy: jasmine.SpyObj; beforeEach(async () => { + httpSpy = jasmine.createSpyObj('HttpClient', ['get']); + httpSpy.get.and.returnValue(of({ items: [] })); + TestBed.overrideComponent(SecurityReportsPageComponent, { remove: { imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent], @@ -46,6 +61,11 @@ describe('SecurityReportsPageComponent', () => { await TestBed.configureTestingModule({ imports: [SecurityReportsPageComponent], + providers: [ + provideRouter([]), + { provide: HttpClient, useValue: httpSpy }, + { provide: PlatformContextStore, useValue: mockContextStore }, + ], }).compileComponents(); fixture = TestBed.createComponent(SecurityReportsPageComponent); @@ -68,4 +88,106 @@ describe('SecurityReportsPageComponent', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Evidence export report surface'); }); + + it('shows Export CSV and Generate PDF buttons on the risk tab', () => { + const toolbar = fixture.nativeElement.querySelector('.export-toolbar') as HTMLElement; + expect(toolbar).toBeTruthy(); + expect(toolbar.textContent).toContain('Export CSV'); + expect(toolbar.textContent).toContain('Generate PDF'); + }); + + it('shows Export VEX Decisions button on the vex tab', () => { + const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf; + buttons[1].click(); + fixture.detectChanges(); + + const toolbar = fixture.nativeElement.querySelector('.export-toolbar') as HTMLElement; + expect(toolbar).toBeTruthy(); + expect(toolbar.textContent).toContain('Export VEX Decisions'); + }); + + it('shows Export Center link on the evidence tab', () => { + const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf; + buttons[2].click(); + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('.export-center-link') as HTMLAnchorElement; + expect(link).toBeTruthy(); + expect(link.textContent).toContain('Open Export Center'); + }); + + it('shows evidence explainer text on the evidence tab', () => { + const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf; + buttons[2].click(); + fixture.detectChanges(); + + const explainer = fixture.nativeElement.querySelector('.evidence-explainer') as HTMLElement; + expect(explainer).toBeTruthy(); + expect(explainer.textContent).toContain('Evidence Bundles'); + expect(explainer.textContent).toContain('StellaBundle OCI'); + }); + + it('calls HTTP API when exporting risk CSV', () => { + httpSpy.get.and.returnValue(of({ + items: [ + { + findingId: 'f-1', + cveId: 'CVE-2025-0001', + severity: 'critical', + environment: 'production', + region: 'eu-west', + releaseName: 'app-v1.0', + effectiveDisposition: 'action_required', + reachable: true, + }, + ], + })); + + // Spy on URL.createObjectURL to prevent actual download + spyOn(URL, 'createObjectURL').and.returnValue('blob:mock'); + spyOn(URL, 'revokeObjectURL'); + + fixture.componentInstance.exportRiskCsv(); + + expect(httpSpy.get).toHaveBeenCalledWith( + '/api/v2/security/findings', + jasmine.objectContaining({ params: jasmine.anything() }), + ); + }); + + it('calls HTTP API when exporting VEX ledger', () => { + httpSpy.get.and.returnValue(of({ + items: [ + { + findingId: 'f-1', + cveId: 'CVE-2025-0001', + releaseName: 'app-v1.0', + packageName: 'lodash', + environment: 'production', + effectiveDisposition: 'not_affected', + updatedAt: '2026-03-15T10:00:00Z', + vex: { status: 'not_affected', justification: 'component_not_present' }, + exception: { status: 'none', reason: '' }, + }, + ], + })); + + spyOn(URL, 'createObjectURL').and.returnValue('blob:mock'); + spyOn(URL, 'revokeObjectURL'); + + fixture.componentInstance.exportVexLedger(); + + expect(httpSpy.get).toHaveBeenCalledWith( + '/api/v2/security/disposition', + jasmine.objectContaining({ params: jasmine.anything() }), + ); + }); + + it('calls window.print for PDF generation', () => { + spyOn(window, 'print'); + + fixture.componentInstance.generatePdf(); + + expect(window.print).toHaveBeenCalled(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts index a36edc43f..abb99f45f 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts @@ -1,15 +1,55 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { catchError, map, of, take } from 'rxjs'; +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { downloadCsv, downloadJson } from '../../shared/utils/download-helpers'; import { ExportCenterComponent } from '../evidence-export/export-center.component'; import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component'; import { SecurityDispositionPageComponent } from './security-disposition-page.component'; type ReportTab = 'risk' | 'vex' | 'evidence'; +/** Minimal shape for the risk CSV export. */ +interface RiskFindingRow { + findingId: string; + cveId: string; + severity: string; + environment: string; + region?: string; + releaseName: string; + effectiveDisposition: string; + reachable: boolean; +} + +/** Minimal shape for VEX decision export. */ +interface VexDecisionRow { + findingId: string; + cveId: string; + releaseName: string; + packageName: string; + environment: string; + vexStatus: string; + vexJustification: string; + exceptionStatus: string; + exceptionReason: string; + updatedAt: string; +} + +interface PlatformListResponse { + items: T[]; +} + @Component({ selector: 'app-security-reports-page', standalone: true, - imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent], + imports: [ + RouterLink, + SecurityRiskOverviewComponent, + SecurityDispositionPageComponent, + ExportCenterComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -46,16 +86,45 @@ type ReportTab = 'risk' | 'vex' | 'evidence'; @switch (activeTab()) { @case ('risk') {
+
+ + +
} @case ('vex') {
+
+ +
} @case ('evidence') { -
+
+
+

Evidence Bundles

+

+ Evidence bundles (StellaBundle OCI) are managed from the Export Center. + The Export Center lets you create export profiles, schedule automated runs, + and download signed audit packs with DSSE envelopes, Rekor tile receipts, + and replay logs suitable for auditor delivery via OCI referrer. +

+ + Open Export Center + + +
} @@ -102,9 +171,203 @@ type ReportTab = 'risk' | 'vex' | 'evidence'; background: var(--color-surface-primary); overflow: hidden; } + + .export-toolbar { + display: flex; + gap: 0.5rem; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + + .export-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-brand-primary); + font-size: 0.76rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + } + + .export-btn:hover:not(:disabled) { + background: var(--color-surface-secondary); + border-color: var(--color-brand-primary); + } + + .export-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .evidence-tab .evidence-explainer { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + + .evidence-explainer h2 { + margin: 0 0 0.35rem; + font-size: 0.95rem; + } + + .evidence-explainer p { + margin: 0 0 0.75rem; + color: var(--color-text-secondary); + font-size: 0.82rem; + line-height: 1.45; + } + + .export-center-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.85rem; + background: var(--color-brand-primary); + color: #fff; + border-radius: var(--radius-md); + font-size: 0.82rem; + font-weight: 500; + text-decoration: none; + transition: opacity 0.15s; + } + + .export-center-link:hover { + opacity: 0.9; + } + + @media print { + .tabs, .export-toolbar, .evidence-explainer, .export-center-link { + display: none !important; + } + } `, ], }) export class SecurityReportsPageComponent { + private readonly http = inject(HttpClient); + private readonly context = inject(PlatformContextStore); + readonly activeTab = signal('risk'); + readonly riskExporting = signal(false); + readonly vexExporting = signal(false); + + exportRiskCsv(): void { + this.riskExporting.set(true); + + const params = this.buildContextParams(); + this.http + .get>('/api/v2/security/findings', { + params: params.set('pivot', 'cve'), + }) + .pipe( + map((res) => res.items ?? []), + catchError(() => of([] as RiskFindingRow[])), + take(1), + ) + .subscribe({ + next: (findings) => { + const headers = [ + 'Finding ID', + 'CVE', + 'Severity', + 'Environment', + 'Region', + 'Release', + 'Disposition', + 'Reachable', + ]; + const rows = findings.map((f) => [ + f.findingId, + f.cveId, + f.severity, + f.environment, + f.region ?? '', + f.releaseName, + f.effectiveDisposition, + f.reachable ? 'yes' : 'no', + ]); + + const date = new Date().toISOString().slice(0, 10); + downloadCsv(`risk-report-${date}.csv`, headers, rows); + this.riskExporting.set(false); + }, + error: () => { + this.riskExporting.set(false); + }, + }); + } + + generatePdf(): void { + document.body.classList.add('print-report'); + window.print(); + document.body.classList.remove('print-report'); + } + + exportVexLedger(): void { + this.vexExporting.set(true); + + const params = this.buildContextParams(); + + interface DispositionRow { + findingId: string; + cveId: string; + releaseName: string; + packageName: string; + environment: string; + effectiveDisposition: string; + updatedAt: string; + vex: { status: string; justification: string }; + exception: { status: string; reason: string }; + } + + this.http + .get>('/api/v2/security/disposition', { params }) + .pipe( + map((res) => res.items ?? []), + catchError(() => of([] as DispositionRow[])), + take(1), + ) + .subscribe({ + next: (rows) => { + const decisions: VexDecisionRow[] = rows.map((r) => ({ + findingId: r.findingId, + cveId: r.cveId, + releaseName: r.releaseName, + packageName: r.packageName, + environment: r.environment, + vexStatus: r.vex.status, + vexJustification: r.vex.justification, + exceptionStatus: r.exception.status, + exceptionReason: r.exception.reason, + updatedAt: r.updatedAt, + })); + + const date = new Date().toISOString().slice(0, 10); + downloadJson(`vex-ledger-${date}.json`, { + exportedAt: new Date().toISOString(), + recordCount: decisions.length, + decisions, + }); + this.vexExporting.set(false); + }, + error: () => { + this.vexExporting.set(false); + }, + }); + } + + private buildContextParams(): HttpParams { + let params = new HttpParams().set('limit', '500').set('offset', '0'); + const region = this.context.selectedRegions()[0]; + const environment = this.context.selectedEnvironments()[0]; + if (region) params = params.set('region', region); + if (environment) params = params.set('environment', environment); + return params; + } } diff --git a/src/Web/StellaOps.Web/src/app/shared/utils/download-helpers.ts b/src/Web/StellaOps.Web/src/app/shared/utils/download-helpers.ts new file mode 100644 index 000000000..8e23c06a6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/utils/download-helpers.ts @@ -0,0 +1,44 @@ +/** + * Browser download helpers for CSV and JSON exports. + * + * Sprint: SPRINT_20260315_005 (T03 - Security Reports downloadable exports) + */ + +/** + * Generates a CSV string from headers and rows and triggers a browser download. + */ +export function downloadCsv(filename: string, headers: string[], rows: string[][]): void { + const escapeCsvField = (field: string): string => { + if (field.includes(',') || field.includes('"') || field.includes('\n')) { + return `"${field.replace(/"/g, '""')}"`; + } + return field; + }; + + const csv = [ + headers.map(escapeCsvField).join(','), + ...rows.map((r) => r.map(escapeCsvField).join(',')), + ].join('\n'); + + triggerBlobDownload(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename); +} + +/** + * Serializes data as formatted JSON and triggers a browser download. + */ +export function downloadJson(filename: string, data: unknown): void { + const json = JSON.stringify(data, null, 2); + triggerBlobDownload(new Blob([json], { type: 'application/json' }), filename); +} + +/** + * Low-level helper: create a temporary object URL, click a hidden anchor, then revoke. + */ +function triggerBlobDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +}