@@ -540,6 +546,18 @@ type DetailTab =
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
+
+ .blocked-finding-link {
+ display: inline-block;
+ font-size: 0.82rem;
+ color: #991b1b;
+ text-decoration: none;
+ font-weight: 500;
+ }
+
+ .blocked-finding-link:hover {
+ text-decoration: underline;
+ }
`,
],
})
diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts
index 7c3bec729..a971f791d 100644
--- a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts
@@ -197,6 +197,9 @@ interface PromotionRow {
{{ promotion.riskSignal.text }}
+ @if (promotion.riskSignal.level === 'blocked') {
+
Review blocking finding ->
+ }
@@ -411,6 +414,19 @@ interface PromotionRow {
color: #4b5563;
}
+ .blocked-finding-link {
+ display: block;
+ margin-top: 0.3rem;
+ font-size: 0.75rem;
+ color: #991b1b;
+ text-decoration: none;
+ font-weight: 500;
+ }
+
+ .blocked-finding-link:hover {
+ text-decoration: underline;
+ }
+
.requested-cell {
display: grid;
gap: 0.2rem;
diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts
index 504ab5e44..6d02316c0 100644
--- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts
@@ -5,6 +5,8 @@ import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-
import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators';
import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api';
+import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
+import type { VulnerabilityStats } from '../../core/api/vulnerability.models';
import { PlatformContextStore } from '../../core/context/platform-context.store';
@@ -95,11 +97,12 @@ interface PlatformListResponse {
Risk Posture
{{ riskPostureLabel() }}
{{ findingsCount() }} findings in scope
+ View in Vulnerabilities workspace
Blocking Items
{{ blockerCount() }}
- Policy action = block
+ {{ triageCriticalCount() }} critical, {{ triageHighCount() }} high severity
VEX Coverage
@@ -271,6 +274,8 @@ interface PlatformListResponse {
}
.kpis .value{margin:.2rem 0 0;font-size:1.15rem;font-weight:var(--font-weight-semibold)}
.kpis small{font-size:.68rem;color:var(--color-text-secondary)}
+ .kpi-link{display:block;margin-top:.25rem;font-size:.66rem;color:var(--color-brand-primary);text-decoration:none}
+ .kpi-link:hover{text-decoration:underline}
.grid{
display:grid;
@@ -297,6 +302,7 @@ interface PlatformListResponse {
export class SecurityRiskOverviewComponent {
private readonly http = inject(HttpClient);
private readonly advisorySourcesApi = inject(AdvisorySourcesApi);
+ private readonly vulnApi = inject(VULNERABILITY_API);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
@@ -306,10 +312,27 @@ export class SecurityRiskOverviewComponent {
readonly sbomRows = signal([]);
readonly feedHealth = signal([]);
readonly vexSourceHealth = signal([]);
+ readonly triageStats = signal(null);
- readonly findingsCount = computed(() => this.findings().length);
+ /** Use triage stats total when security findings API returns empty. */
+ readonly findingsCount = computed(() => {
+ const securityFindings = this.findings().length;
+ if (securityFindings > 0) return securityFindings;
+ return this.triageStats()?.total ?? 0;
+ });
readonly reachableCount = computed(() => this.findings().filter((item) => item.reachable).length);
- readonly blockerCount = computed(() => this.topBlockers().length);
+
+ /** Triage severity breakdown for blocker KPI. */
+ readonly triageCriticalCount = computed(() => this.triageStats()?.bySeverity?.critical ?? 0);
+ readonly triageHighCount = computed(() => this.triageStats()?.bySeverity?.high ?? 0);
+
+ /** Blockers: disposition-based blockers + critical open findings from triage. */
+ readonly blockerCount = computed(() => {
+ const dispositionBlockers = this.topBlockers().length;
+ if (dispositionBlockers > 0) return dispositionBlockers;
+ // Fall back to critical open from triage when no disposition data
+ return this.triageStats()?.criticalOpen ?? 0;
+ });
readonly topBlockers = computed(() =>
this.dispositions()
.filter((item) => item.policyAction === 'block' || item.effectiveDisposition === 'action_required')
@@ -342,10 +365,20 @@ export class SecurityRiskOverviewComponent {
});
readonly riskPostureLabel = computed(() => {
- const critical = this.findings().filter((item) => item.severity === 'critical').length;
- const high = this.findings().filter((item) => item.severity === 'high').length;
+ const securityFindings = this.findings();
+ const critical = securityFindings.filter((item) => item.severity === 'critical').length;
+ const high = securityFindings.filter((item) => item.severity === 'high').length;
if (critical > 0) return 'HIGH';
if (high > 0) return 'ELEVATED';
+ // Fall back to triage stats when security findings API returns empty
+ if (securityFindings.length === 0) {
+ const stats = this.triageStats();
+ if (stats) {
+ if ((stats.bySeverity?.critical ?? 0) > 0) return 'HIGH';
+ if ((stats.bySeverity?.high ?? 0) > 0) return 'ELEVATED';
+ if (stats.total > 0) return 'GUARDED';
+ }
+ }
return 'GUARDED';
});
@@ -452,16 +485,20 @@ export class SecurityRiskOverviewComponent {
catchError(() => of([] as IntegrationHealthRow[]))
);
const vexHealth$ = of([] as IntegrationHealthRow[]);
+ const triageStats$ = this.vulnApi.getStats().pipe(
+ catchError(() => of(null as VulnerabilityStats | null))
+ );
- forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ })
+ forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$, triageStats: triageStats$ })
.pipe(take(1))
.subscribe({
- next: ({ findings, disposition, sbom, feedHealth, vexHealth }) => {
+ next: ({ findings, disposition, sbom, feedHealth, vexHealth, triageStats }) => {
this.findings.set(findings);
this.dispositions.set(disposition);
this.sbomRows.set(sbom);
this.feedHealth.set(feedHealth);
this.vexSourceHealth.set(vexHealth);
+ this.triageStats.set(triageStats);
this.loading.set(false);
},
error: (err: unknown) => {
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts
index 4046f6eed..5daec6993 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts
@@ -17,6 +17,7 @@ import {
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
+import { RouterModule } from '@angular/router';
import { Subject, takeUntil, forkJoin } from 'rxjs';
// Components
@@ -60,6 +61,7 @@ export interface FindingDetail {
standalone: true,
imports: [
CommonModule,
+ RouterModule,
TriageLaneToggleComponent,
GatedBucketsComponent,
GatingReasonFilterComponent,
@@ -194,6 +196,17 @@ export interface FindingDetail {
(decisionSubmit)="onDecisionSubmit($event)"
(decisionRevoked)="onDecisionRevoked($event)"
/>
+
+
+ @if (decisionSuccess()) {
+
+ }
`,
styles: [`
@@ -457,6 +470,37 @@ export interface FindingDetail {
background: var(--color-brand-secondary);
}
+ /* Workflow chain: decision success banner */
+ .decision-success-banner {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.6rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid color-mix(in srgb, var(--color-status-success-text) 30%, transparent);
+ background: color-mix(in srgb, var(--color-status-success-text) 8%, transparent);
+ color: var(--color-status-success-text);
+ font-size: 0.82rem;
+ font-weight: 500;
+ }
+
+ .policy-gate-link {
+ margin-left: auto;
+ color: var(--color-brand-primary);
+ text-decoration: none;
+ font-weight: 600;
+ }
+
+ .dismiss-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ font-size: 1.1rem;
+ padding: 0 0.2rem;
+ }
+
/* High contrast mode */
@media (prefers-contrast: high) {
.finding-card {
@@ -493,6 +537,7 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy {
readonly selectedFinding = signal(null);
readonly selectedCallPath = signal(null);
readonly isDrawerOpen = signal(false);
+ readonly decisionSuccess = signal(false);
readonly evidenceHash = signal('');
// T004: Filter by lane
@@ -644,6 +689,10 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy {
// T029: Record decision
this.ttfsService.recordDecision(finding.id, decision.status);
+
+ // Workflow chain: show success banner with policy gate link
+ this.decisionSuccess.set(true);
+ this.isDrawerOpen.set(false);
}
}
|