finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

@@ -0,0 +1,319 @@
/**
* Function Map models for runtime linkage verification UI.
* Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification (RLV-010)
*/
/**
* Probe types supported for runtime observation.
*/
export type ProbeType = 'uprobe' | 'uretprobe' | 'kprobe' | 'kretprobe' | 'tracepoint' | 'usdt';
/**
* Verification status for a function map.
*/
export type VerificationStatus = 'verified' | 'not_verified' | 'degraded' | 'stale' | 'error';
/**
* Coverage status classification.
*/
export type CoverageStatus = 'complete' | 'adequate' | 'sparse' | 'insufficient';
/**
* Expected function call within a path.
*/
export interface ExpectedCall {
/** Symbol name (normalized) */
symbol: string;
/** Library/binary containing the symbol */
library: string;
/** Node hash (SHA-256 of PURL + symbol) */
nodeHash: string;
/** Probe type */
probeType: ProbeType;
}
/**
* Expected execution path in a function map.
*/
export interface ExpectedPath {
/** Entrypoint symbol */
entrypoint: ExpectedCall;
/** Expected calls in this path */
calls: ExpectedCall[];
/** Path hash */
pathHash: string;
/** Path label/description */
label?: string;
/** Tags for categorization */
tags?: string[];
/** Whether this path is optional */
optional?: boolean;
}
/**
* Coverage thresholds configuration.
*/
export interface CoverageThresholds {
/** Minimum overall observation rate (0.0-1.0) */
minObservationRate: number;
/** Observation time window in seconds */
windowSeconds: number;
}
/**
* Function map summary (list view).
*/
export interface FunctionMapSummary {
/** Unique identifier */
id: string;
/** Service name */
service: string;
/** Binary path */
binaryPath: string;
/** Number of expected paths */
pathCount: number;
/** Total expected calls across all paths */
callCount: number;
/** Creation timestamp */
createdAt: string;
/** Creator */
createdBy: string;
/** Last verification timestamp */
lastVerifiedAt?: string;
/** Current verification status */
verificationStatus: VerificationStatus;
/** Current coverage status */
coverageStatus: CoverageStatus;
/** Current observation rate */
observationRate?: number;
/** Build ID that generated this map */
buildId?: string;
/** Binary digest */
binaryDigest?: string;
}
/**
* Full function map detail.
*/
export interface FunctionMapDetail extends FunctionMapSummary {
/** Subject (SBOM/package info) */
subject: {
purl: string;
digest: string;
sbomRef?: string;
};
/** Expected paths */
expectedPaths: ExpectedPath[];
/** Coverage thresholds */
coverageThresholds: CoverageThresholds;
/** Hot function patterns used for generation */
hotFunctionPatterns: string[];
/** Weight manifest version (if linked) */
weightManifestVersion?: string;
/** DSSE attestation digest */
attestationDigest?: string;
}
/**
* Verification result from a verification run.
*/
export interface VerificationResult {
/** Verification ID */
id: string;
/** Function map ID */
functionMapId: string;
/** Verification timestamp */
verifiedAt: string;
/** Overall observation rate */
observationRate: number;
/** Per-path coverage */
pathCoverage: PathCoverageEntry[];
/** Unexpected symbols detected */
unexpectedSymbols: UnexpectedSymbol[];
/** Whether verification passed thresholds */
passed: boolean;
/** Time window evaluated */
windowStart: string;
windowEnd: string;
/** Probe attachment stats */
probeStats: {
attached: number;
failed: number;
total: number;
};
}
/**
* Per-path coverage entry.
*/
export interface PathCoverageEntry {
/** Path hash */
pathHash: string;
/** Path label */
label?: string;
/** Entrypoint symbol */
entrypoint: string;
/** Observed calls count */
observedCalls: number;
/** Expected calls count */
expectedCalls: number;
/** Coverage percentage (0-100) */
coveragePercent: number;
/** Whether this path is optional */
optional: boolean;
}
/**
* Unexpected symbol detected during verification.
*/
export interface UnexpectedSymbol {
/** Symbol name */
symbol: string;
/** Library where detected */
library: string;
/** Number of times observed */
observationCount: number;
/** First observed timestamp */
firstSeen: string;
/** Probe type */
probeType: ProbeType;
}
/**
* Runtime observation record.
*/
export interface ObservationRecord {
/** Observation timestamp */
timestamp: string;
/** Symbol hash (not the symbol itself - privacy) */
symbolHash: string;
/** Probe type */
probeType: ProbeType;
/** Process ID */
pid: number;
/** Whether this was matched to a function map path */
matched: boolean;
}
/**
* Observation summary for timeline display.
*/
export interface ObservationBucket {
/** Bucket start time */
start: string;
/** Bucket end time */
end: string;
/** Total observations in bucket */
count: number;
/** Matched observations */
matchedCount: number;
/** Unmatched observations */
unmatchedCount: number;
}
/**
* Request to generate a new function map.
*/
export interface GenerateFunctionMapRequest {
/** Service name */
service: string;
/** SBOM file content (CycloneDX JSON) */
sbomContent?: string;
/** OCI reference for SBOM */
sbomOciRef?: string;
/** Hot function patterns */
hotFunctionPatterns: string[];
/** Coverage thresholds */
coverageThresholds: CoverageThresholds;
/** Build ID */
buildId?: string;
}
/**
* Request to trigger verification.
*/
export interface VerifyFunctionMapRequest {
/** Time window start (ISO 8601) */
from: string;
/** Time window end (ISO 8601) */
to: string;
}
// =============================================================================
// Display helpers
// =============================================================================
/**
* Verification status display metadata.
*/
export interface VerificationStatusDisplay {
status: VerificationStatus;
label: string;
description: string;
color: string;
lightColor: string;
}
export const VERIFICATION_STATUS_DISPLAY: Record<VerificationStatus, VerificationStatusDisplay> = {
verified: {
status: 'verified',
label: 'Verified',
description: 'All paths meet coverage thresholds',
color: '#059669',
lightColor: '#D1FAE5',
},
not_verified: {
status: 'not_verified',
label: 'Not Verified',
description: 'Verification has not been run',
color: '#6B7280',
lightColor: '#F3F4F6',
},
degraded: {
status: 'degraded',
label: 'Degraded',
description: 'Some paths below coverage thresholds',
color: '#F59E0B',
lightColor: '#FEF3C7',
},
stale: {
status: 'stale',
label: 'Stale',
description: 'Verification data is outdated',
color: '#6B7280',
lightColor: '#F3F4F6',
},
error: {
status: 'error',
label: 'Error',
description: 'Verification failed with errors',
color: '#DC2626',
lightColor: '#FEE2E2',
},
};
/**
* Coverage status display metadata.
*/
export const COVERAGE_STATUS_DISPLAY: Record<CoverageStatus, { label: string; color: string }> = {
complete: { label: 'Complete', color: '#059669' },
adequate: { label: 'Adequate', color: '#CA8A04' },
sparse: { label: 'Sparse', color: '#EA580C' },
insufficient: { label: 'Insufficient', color: '#DC2626' },
};
/**
* Default hot function pattern suggestions.
*/
export const HOT_FUNCTION_SUGGESTIONS: string[] = [
'crypto/*',
'net/*',
'auth/*',
'tls/*',
'ssl/*',
'openssl/*',
'libsodium/*',
'pam/*',
'oauth/*',
'jwt/*',
];

View File

@@ -0,0 +1,210 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
// --- Request Models ---
export interface PolicyExportRequest {
policy_content: string;
format: 'json' | 'rego';
environment?: string;
include_remediation?: boolean;
include_comments?: boolean;
package_name?: string;
}
export interface PolicyImportRequest {
content: string;
format?: 'json' | 'rego';
validate_only?: boolean;
merge_strategy?: 'replace' | 'append';
dry_run?: boolean;
}
export interface PolicyValidateRequest {
content: string;
format?: 'json' | 'rego';
strict?: boolean;
}
export interface PolicyEvaluateRequest {
policy_content: string;
input?: PolicyEvaluationInput;
format?: 'json' | 'rego';
environment?: string;
include_remediation?: boolean;
}
export interface PolicyEvaluationInput {
environment?: string;
dsse_verified?: boolean;
rekor_verified?: boolean;
sbom_digest?: string;
freshness_verified?: boolean;
cvss_score?: number;
confidence?: number;
reachability_status?: string;
unknowns_ratio?: number;
}
// --- Response Models ---
export interface PolicyExportResponse {
success: boolean;
format: string;
content?: string;
digest?: string;
diagnostics?: PolicyDiagnostic[];
}
export interface PolicyImportResponse {
success: boolean;
source_format?: string;
gates_imported: number;
rules_imported: number;
native_mapped: number;
opa_evaluated: number;
diagnostics?: PolicyDiagnostic[];
mappings?: PolicyImportMapping[];
}
export interface PolicyValidateResponse {
valid: boolean;
detected_format?: string;
errors?: PolicyDiagnostic[];
warnings?: PolicyDiagnostic[];
}
export interface PolicyEvaluateResponse {
decision: 'allow' | 'warn' | 'block';
gates?: GateEvaluation[];
remediation?: RemediationHint[];
output_digest?: string;
}
export interface PolicyFormatsResponse {
formats: PolicyFormatInfo[];
}
// --- Shared Models ---
export interface PolicyDiagnostic {
severity: 'info' | 'warning' | 'error';
code: string;
message: string;
}
export interface PolicyImportMapping {
source_rule: string;
target_gate_type: string;
mapped_to_native: boolean;
}
export interface GateEvaluation {
gate_id: string;
gate_type: string;
passed: boolean;
reason?: string;
}
export interface RemediationHint {
code: string;
title: string;
severity: 'critical' | 'high' | 'medium' | 'low';
actions?: RemediationAction[];
}
export interface RemediationAction {
type: string;
description: string;
command?: string;
}
export interface PolicyFormatInfo {
id: string;
name: string;
schema: string;
import_supported: boolean;
export_supported: boolean;
}
// --- PolicyPack v2 Document Model ---
export interface PolicyPackDocument {
apiVersion: string;
kind: string;
metadata: PolicyPackMetadata;
spec: PolicyPackSpec;
}
export interface PolicyPackMetadata {
name: string;
version: string;
description?: string;
digest?: string;
created_at?: string;
}
export interface PolicyPackSpec {
settings: PolicyPackSettings;
gates: PolicyGateDefinition[];
rules?: PolicyRuleDefinition[];
}
export interface PolicyPackSettings {
default_action: 'allow' | 'warn' | 'block';
deterministic_mode?: boolean;
unknowns_threshold?: number;
}
export interface PolicyGateDefinition {
id: string;
type: string;
enabled: boolean;
config?: Record<string, unknown>;
environments?: Record<string, Record<string, unknown>>;
remediation?: RemediationHintDefinition;
}
export interface PolicyRuleDefinition {
name: string;
action: 'allow' | 'warn' | 'block';
priority?: number;
match?: Record<string, unknown>;
remediation?: RemediationHintDefinition;
}
export interface RemediationHintDefinition {
code: string;
title: string;
description?: string;
actions?: RemediationActionDefinition[];
references?: RemediationReference[];
severity: 'critical' | 'high' | 'medium' | 'low';
}
export interface RemediationActionDefinition {
type: string;
description: string;
command?: string;
}
export interface RemediationReference {
title: string;
url: string;
}
// --- Gate Type Constants ---
export const PolicyGateTypes = {
CvssThreshold: 'CvssThresholdGate',
SignatureRequired: 'SignatureRequiredGate',
EvidenceFreshness: 'EvidenceFreshnessGate',
SbomPresence: 'SbomPresenceGate',
MinimumConfidence: 'MinimumConfidenceGate',
UnknownsBudget: 'UnknownsBudgetGate',
ReachabilityRequirement: 'ReachabilityRequirementGate',
} as const;
export type PolicyGateType = (typeof PolicyGateTypes)[keyof typeof PolicyGateTypes];

View File

@@ -0,0 +1,81 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
PolicyExportRequest,
PolicyExportResponse,
PolicyImportRequest,
PolicyImportResponse,
PolicyValidateRequest,
PolicyValidateResponse,
PolicyEvaluateRequest,
PolicyEvaluateResponse,
PolicyFormatsResponse,
} from './policy-interop.models';
interface PlatformItemResponse<T> {
tenant_id: string;
actor_id: string;
data_as_of: string;
cached: boolean;
cache_ttl_seconds: number;
value: T;
}
@Injectable({ providedIn: 'root' })
export class PolicyInteropService {
private readonly baseUrl = '/api/v1/policy/interop';
constructor(private readonly http: HttpClient) {}
/**
* Export a policy to the specified format (JSON or Rego).
*/
export(request: PolicyExportRequest): Observable<PolicyExportResponse> {
return this.http
.post<PlatformItemResponse<PolicyExportResponse>>(`${this.baseUrl}/export`, request)
.pipe(map((r) => r.value));
}
/**
* Import a policy from JSON or Rego format.
*/
import(request: PolicyImportRequest): Observable<PolicyImportResponse> {
return this.http
.post<PlatformItemResponse<PolicyImportResponse>>(`${this.baseUrl}/import`, request)
.pipe(map((r) => r.value));
}
/**
* Validate a policy document against schema or syntax rules.
*/
validate(request: PolicyValidateRequest): Observable<PolicyValidateResponse> {
return this.http
.post<PlatformItemResponse<PolicyValidateResponse>>(`${this.baseUrl}/validate`, request)
.pipe(map((r) => r.value));
}
/**
* Evaluate a policy against evidence input and get allow/warn/block decision.
*/
evaluate(request: PolicyEvaluateRequest): Observable<PolicyEvaluateResponse> {
return this.http
.post<PlatformItemResponse<PolicyEvaluateResponse>>(`${this.baseUrl}/evaluate`, request)
.pipe(map((r) => r.value));
}
/**
* List supported policy formats.
*/
getFormats(): Observable<PolicyFormatsResponse> {
return this.http
.get<PlatformItemResponse<PolicyFormatsResponse>>(`${this.baseUrl}/formats`)
.pipe(map((r) => r.value));
}
}

View File

@@ -631,3 +631,147 @@ export function getReductionPercent(score: EvidenceWeightedScoreResult): number
return Math.round((score.reductionProfile.reductionAmount / score.reductionProfile.originalScore) * 100);
}
// =============================================================================
// Unified Trust Score (U metric) models
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra (TSF-008)
// =============================================================================
/**
* Unknowns band classification based on U fraction.
*/
export type UnknownsBand = 'Complete' | 'Adequate' | 'Sparse' | 'Insufficient';
/**
* Delta-if-present entry: how a missing signal would change the score.
*/
export interface DeltaIfPresent {
/** Evidence dimension key */
dimension: keyof EvidenceInputs;
/** Display label for the dimension */
label: string;
/** Whether this dimension is currently missing/unknown */
isMissing: boolean;
/** Estimated score delta if this signal were present (positive = score increases) */
delta: number;
/** The weight assigned to this dimension */
weight: number;
}
/**
* Unified score result combining EWS score + unknowns fraction.
*/
export interface UnifiedScoreResult {
/** Evidence-weighted score (0-100) */
ewsScore: number;
/** Unknowns fraction (0.0 - 1.0) */
unknownsFraction: number;
/** Unknowns band classification */
unknownsBand: UnknownsBand;
/** Delta-if-present for each missing signal */
deltaIfPresent: DeltaIfPresent[];
/** Weight manifest version used */
weightManifestVersion: string;
/** Weight manifest digest */
weightManifestDigest: string;
/** Number of known dimensions (non-missing) */
knownDimensions: number;
/** Total dimensions */
totalDimensions: number;
}
/**
* Band display metadata for unknowns bands.
*/
export interface UnknownsBandDisplayInfo {
/** Band classification */
band: UnknownsBand;
/** Display label */
label: string;
/** Short description */
description: string;
/** Min U value (inclusive) */
minU: number;
/** Max U value (exclusive, except Insufficient which is inclusive at 1.0) */
maxU: number;
/** Background color (CSS) */
backgroundColor: string;
/** Text color (CSS) */
textColor: string;
/** Light background for badges */
lightBackground: string;
}
/**
* Unknowns band display configuration.
*/
export const UNKNOWNS_BAND_DISPLAY: UnknownsBandDisplayInfo[] = [
{
band: 'Complete',
label: 'Complete',
description: 'All critical signals present, high confidence in score',
minU: 0.0,
maxU: 0.2,
backgroundColor: '#059669', // emerald-600
textColor: '#FFFFFF',
lightBackground: '#D1FAE5', // emerald-100
},
{
band: 'Adequate',
label: 'Adequate',
description: 'Most signals present, reasonable confidence',
minU: 0.2,
maxU: 0.4,
backgroundColor: '#CA8A04', // yellow-600
textColor: '#FFFFFF',
lightBackground: '#FEF9C3', // yellow-100
},
{
band: 'Sparse',
label: 'Sparse',
description: 'Significant signals missing, limited confidence',
minU: 0.4,
maxU: 0.6,
backgroundColor: '#EA580C', // orange-600
textColor: '#FFFFFF',
lightBackground: '#FFEDD5', // orange-100
},
{
band: 'Insufficient',
label: 'Insufficient',
description: 'Most signals missing, score is unreliable',
minU: 0.6,
maxU: 1.0,
backgroundColor: '#DC2626', // red-600
textColor: '#FFFFFF',
lightBackground: '#FEE2E2', // red-100
},
];
/**
* Helper to get band info for a given unknowns fraction.
*/
export function getBandForUnknowns(u: number): UnknownsBandDisplayInfo {
const clamped = Math.max(0, Math.min(1, u));
for (const info of UNKNOWNS_BAND_DISPLAY) {
if (clamped >= info.minU && clamped < info.maxU) {
return info;
}
}
// u === 1.0 falls into Insufficient
return UNKNOWNS_BAND_DISPLAY[UNKNOWNS_BAND_DISPLAY.length - 1];
}
/**
* Helper to check if unknowns fraction is high (Sparse or Insufficient).
*/
export function isHighUnknowns(u: number): boolean {
return u >= 0.4;
}
/**
* Helper to format unknowns fraction as percentage string.
*/
export function formatUnknownsPercent(u: number): string {
return `${Math.round(u * 100)}%`;
}

View File

@@ -0,0 +1,630 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
FunctionMapDetail,
ExpectedPath,
VerificationResult,
VERIFICATION_STATUS_DISPLAY,
COVERAGE_STATUS_DISPLAY,
} from '../../core/api/function-map.models';
/**
* Function Map Detail View Component.
*
* Displays full detail of a function map including:
* - Service info and generation metadata
* - Expected paths table with symbols
* - Coverage thresholds configuration
* - Recent verification history
*
* Sprint: SPRINT_20260122_039 (RLV-010)
*
* @example
* <stella-function-map-detail
* [functionMap]="mapDetail"
* [verificationHistory]="history"
* (back)="goBack()"
* (verify)="onVerify($event)"
* />
*/
@Component({
selector: 'stella-function-map-detail',
standalone: true,
imports: [CommonModule],
template: `
<div class="fm-detail">
<!-- Breadcrumb -->
<nav class="fm-detail__nav">
<button class="back-btn" (click)="back.emit()" type="button">
&larr; Function Maps
</button>
</nav>
<!-- Header -->
<header class="fm-detail__header">
<div class="header-info">
<h2 class="header-title">{{ functionMap().service }}</h2>
<span class="header-binary" [title]="functionMap().binaryPath">
{{ functionMap().binaryPath }}
</span>
</div>
<div class="header-actions">
<button
class="verify-btn"
(click)="verify.emit(functionMap().id)"
type="button"
>
Verify Now
</button>
</div>
</header>
<!-- Status banner -->
<div
class="fm-detail__status"
[style.backgroundColor]="statusDisplay().lightColor"
[style.borderColor]="statusDisplay().color"
>
<span class="status-label" [style.color]="statusDisplay().color">
{{ statusDisplay().label }}
</span>
@if (functionMap().observationRate !== undefined) {
<span class="status-rate">
Observation rate: {{ formatPercent(functionMap().observationRate!) }}
</span>
}
@if (functionMap().lastVerifiedAt) {
<span class="status-time">
Last verified: {{ formatDate(functionMap().lastVerifiedAt!) }}
</span>
}
</div>
<!-- Metadata grid -->
<section class="fm-detail__metadata">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">Created</span>
<span class="meta-value">{{ formatDate(functionMap().createdAt) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Created By</span>
<span class="meta-value">{{ functionMap().createdBy }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Build ID</span>
<span class="meta-value mono">{{ functionMap().buildId ?? 'N/A' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">PURL</span>
<span class="meta-value mono">{{ functionMap().subject.purl }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Digest</span>
<span class="meta-value mono" [title]="functionMap().subject.digest">
{{ truncateDigest(functionMap().subject.digest) }}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Thresholds</span>
<span class="meta-value">
Min rate: {{ formatPercent(functionMap().coverageThresholds.minObservationRate) }},
Window: {{ functionMap().coverageThresholds.windowSeconds }}s
</span>
</div>
</div>
</section>
<!-- Hot function patterns -->
@if (functionMap().hotFunctionPatterns.length > 0) {
<section class="fm-detail__patterns">
<h3 class="section-title">Hot Function Patterns</h3>
<div class="pattern-tags">
@for (pattern of functionMap().hotFunctionPatterns; track pattern) {
<span class="pattern-tag">{{ pattern }}</span>
}
</div>
</section>
}
<!-- Expected paths table -->
<section class="fm-detail__paths">
<h3 class="section-title">
Expected Paths
<span class="path-total">({{ functionMap().expectedPaths.length }})</span>
</h3>
<div class="paths-table-container">
<table class="paths-table" aria-label="Expected paths">
<thead>
<tr>
<th>Entrypoint</th>
<th>Calls</th>
<th>Tags</th>
<th>Optional</th>
</tr>
</thead>
<tbody>
@for (path of functionMap().expectedPaths; track path.pathHash) {
<tr
class="path-row"
[class.path-row--expanded]="expandedPath() === path.pathHash"
(click)="togglePath(path.pathHash)"
>
<td class="path-entrypoint">
<span class="symbol-name">{{ path.entrypoint.symbol }}</span>
<span class="library-name">{{ path.entrypoint.library }}</span>
</td>
<td class="path-calls">{{ path.calls.length }}</td>
<td class="path-tags">
@for (tag of path.tags ?? []; track tag) {
<span class="tag">{{ tag }}</span>
}
</td>
<td class="path-optional">
{{ path.optional ? 'Yes' : '-' }}
</td>
</tr>
@if (expandedPath() === path.pathHash) {
<tr class="path-detail-row">
<td colspan="4">
<div class="path-detail">
<div class="path-hash">
<span class="detail-label">Path Hash:</span>
<span class="detail-value mono">{{ path.pathHash }}</span>
</div>
<div class="call-list">
<span class="detail-label">Expected Calls:</span>
@for (call of path.calls; track call.nodeHash) {
<div class="call-item">
<span class="call-symbol">{{ call.symbol }}</span>
<span class="call-library">{{ call.library }}</span>
<span class="call-probe">{{ call.probeType }}</span>
</div>
}
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
<!-- Verification History -->
@if (verificationHistory().length > 0) {
<section class="fm-detail__history">
<h3 class="section-title">Verification History</h3>
<div class="history-list">
@for (result of verificationHistory(); track result.id) {
<div
class="history-item"
[class.history-item--pass]="result.passed"
[class.history-item--fail]="!result.passed"
>
<div class="history-status">
<span class="history-icon">{{ result.passed ? '[OK]' : '[FAIL]' }}</span>
</div>
<div class="history-info">
<span class="history-date">{{ formatDate(result.verifiedAt) }}</span>
<span class="history-rate">Rate: {{ formatPercent(result.observationRate) }}</span>
<span class="history-probes">
Probes: {{ result.probeStats.attached }}/{{ result.probeStats.total }}
</span>
</div>
@if (result.unexpectedSymbols.length > 0) {
<span class="history-warning">
{{ result.unexpectedSymbols.length }} unexpected
</span>
}
</div>
}
</div>
</section>
}
<!-- Attestation info -->
@if (functionMap().attestationDigest) {
<section class="fm-detail__attestation">
<h3 class="section-title">Attestation</h3>
<div class="attestation-info">
<span class="detail-label">DSSE Digest:</span>
<span class="detail-value mono">{{ functionMap().attestationDigest }}</span>
</div>
</section>
}
</div>
`,
styles: [`
.fm-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.fm-detail__nav {
margin-bottom: -8px;
}
.back-btn {
background: none;
border: none;
color: #2563EB;
font-size: 13px;
cursor: pointer;
padding: 4px 0;
&:hover { text-decoration: underline; }
}
.fm-detail__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #111827;
}
.header-binary {
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: #6B7280;
}
.verify-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: #FFFFFF;
background-color: #059669;
border: none;
border-radius: 6px;
cursor: pointer;
&:hover { background-color: #047857; }
}
.fm-detail__status {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
border-radius: 6px;
border-left: 3px solid;
}
.status-label {
font-weight: 700;
font-size: 13px;
}
.status-rate, .status-time {
font-size: 12px;
color: #6B7280;
}
.fm-detail__metadata {
background: #F9FAFB;
border-radius: 8px;
padding: 16px;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9CA3AF;
}
.meta-value {
font-size: 13px;
color: #374151;
word-break: break-all;
&.mono {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 700;
color: #111827;
}
.path-total {
font-weight: 400;
color: #6B7280;
font-size: 12px;
}
.fm-detail__patterns {
padding: 0;
}
.pattern-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pattern-tag {
padding: 3px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
background: #EEF2FF;
color: #4338CA;
border-radius: 4px;
}
.paths-table-container {
overflow-x: auto;
border: 1px solid #E5E7EB;
border-radius: 6px;
}
.paths-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.paths-table th {
padding: 8px 12px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #6B7280;
background: #F9FAFB;
border-bottom: 1px solid #E5E7EB;
}
.paths-table td {
padding: 10px 12px;
border-bottom: 1px solid #F3F4F6;
}
.path-row {
cursor: pointer;
&:hover { background: #F9FAFB; }
&--expanded { background: #EEF2FF; }
}
.path-entrypoint {
display: flex;
flex-direction: column;
gap: 1px;
}
.symbol-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: #111827;
}
.library-name {
font-size: 11px;
color: #9CA3AF;
}
.tag {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
background: #F3F4F6;
color: #6B7280;
border-radius: 3px;
margin-right: 4px;
}
.path-detail-row td {
background: #F9FAFB;
padding: 12px 16px;
}
.path-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.path-hash {
display: flex;
gap: 8px;
align-items: center;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: #6B7280;
}
.detail-value {
font-size: 12px;
color: #374151;
&.mono {
font-family: 'JetBrains Mono', monospace;
}
}
.call-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.call-item {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 8px;
background: #FFFFFF;
border-radius: 4px;
border: 1px solid #E5E7EB;
}
.call-symbol {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #111827;
}
.call-library {
font-size: 11px;
color: #9CA3AF;
}
.call-probe {
font-size: 10px;
padding: 1px 5px;
background: #EEF2FF;
color: #4338CA;
border-radius: 3px;
margin-left: auto;
}
.fm-detail__history {
padding: 0;
}
.history-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #E5E7EB;
&--pass { border-left: 3px solid #059669; }
&--fail { border-left: 3px solid #DC2626; }
}
.history-icon {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 700;
}
.history-item--pass .history-icon { color: #059669; }
.history-item--fail .history-icon { color: #DC2626; }
.history-info {
display: flex;
gap: 16px;
font-size: 12px;
color: #374151;
}
.history-warning {
margin-left: auto;
font-size: 11px;
color: #F59E0B;
font-weight: 600;
}
.fm-detail__attestation {
padding: 0;
}
.attestation-info {
display: flex;
gap: 8px;
align-items: center;
}
@media (max-width: 768px) {
.meta-grid {
grid-template-columns: 1fr;
}
.history-info {
flex-direction: column;
gap: 2px;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FunctionMapDetailComponent {
/** Full function map detail */
readonly functionMap = input.required<FunctionMapDetail>();
/** Verification history */
readonly verificationHistory = input<VerificationResult[]>([]);
/** Emits when back button clicked */
readonly back = output<void>();
/** Emits function map ID to verify */
readonly verify = output<string>();
/** Currently expanded path hash */
readonly expandedPath = signal<string | null>(null);
/** Status display info */
readonly statusDisplay = computed(() =>
VERIFICATION_STATUS_DISPLAY[this.functionMap().verificationStatus]
);
/** Toggle path expansion */
togglePath(pathHash: string): void {
this.expandedPath.update(current =>
current === pathHash ? null : pathHash
);
}
/** Format date */
formatDate(isoDate: string): string {
try {
return new Date(isoDate).toLocaleString();
} catch {
return isoDate;
}
}
/** Format percentage */
formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
/** Truncate digest */
truncateDigest(digest: string): string {
if (digest.length <= 20) return digest;
return digest.substring(0, 12) + '...' + digest.slice(-8);
}
}

View File

@@ -0,0 +1,791 @@
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
GenerateFunctionMapRequest,
CoverageThresholds,
HOT_FUNCTION_SUGGESTIONS,
} from '../../core/api/function-map.models';
/**
* Wizard step identifier.
*/
type WizardStep = 'sbom' | 'patterns' | 'thresholds' | 'review';
/**
* SBOM source type.
*/
type SbomSourceType = 'file' | 'oci';
/**
* Function Map Generator Wizard Component.
*
* Multi-step wizard for creating new function maps:
* - Step 1: Select SBOM source (file upload or OCI reference)
* - Step 2: Configure hot function patterns (with suggestions)
* - Step 3: Set coverage thresholds
* - Step 4: Review and create
*
* Sprint: SPRINT_20260122_039 (RLV-010)
*
* @example
* <stella-function-map-generator
* (generate)="onGenerate($event)"
* (cancel)="onCancel()"
* />
*/
@Component({
selector: 'stella-function-map-generator',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="wizard">
<!-- Progress bar -->
<div class="wizard__progress">
@for (step of steps; track step.id) {
<div
class="progress-step"
[class.progress-step--active]="currentStep() === step.id"
[class.progress-step--completed]="isStepCompleted(step.id)"
>
<span class="step-number">{{ $index + 1 }}</span>
<span class="step-label">{{ step.label }}</span>
</div>
}
</div>
<!-- Step 1: SBOM Source -->
@if (currentStep() === 'sbom') {
<div class="wizard__step">
<h3 class="step-title">Select SBOM Source</h3>
<p class="step-description">
Provide the SBOM for the service you want to create a function map for.
</p>
<div class="source-options">
<label class="source-option" [class.selected]="sbomSource() === 'file'">
<input
type="radio"
name="sbomSource"
value="file"
[checked]="sbomSource() === 'file'"
(change)="sbomSource.set('file')"
/>
<span class="option-title">File Upload</span>
<span class="option-desc">Upload a CycloneDX JSON SBOM file</span>
</label>
<label class="source-option" [class.selected]="sbomSource() === 'oci'">
<input
type="radio"
name="sbomSource"
value="oci"
[checked]="sbomSource() === 'oci'"
(change)="sbomSource.set('oci')"
/>
<span class="option-title">OCI Reference</span>
<span class="option-desc">Reference an SBOM stored in an OCI registry</span>
</label>
</div>
@if (sbomSource() === 'oci') {
<div class="input-group">
<label class="input-label">OCI Reference</label>
<input
type="text"
class="text-input"
placeholder="registry.example.com/app:latest"
[ngModel]="ociRef()"
(ngModelChange)="ociRef.set($event)"
/>
</div>
}
<div class="input-group">
<label class="input-label">Service Name</label>
<input
type="text"
class="text-input"
placeholder="my-service"
[ngModel]="serviceName()"
(ngModelChange)="serviceName.set($event)"
/>
</div>
<div class="input-group">
<label class="input-label">Build ID (optional)</label>
<input
type="text"
class="text-input"
placeholder="CI build identifier"
[ngModel]="buildId()"
(ngModelChange)="buildId.set($event)"
/>
</div>
</div>
}
<!-- Step 2: Hot Function Patterns -->
@if (currentStep() === 'patterns') {
<div class="wizard__step">
<h3 class="step-title">Configure Hot Function Patterns</h3>
<p class="step-description">
Define which functions to monitor at runtime. These patterns match
against library/symbol paths in the SBOM.
</p>
<div class="patterns-section">
<div class="suggestions">
<span class="suggestions-label">Suggestions:</span>
<div class="suggestion-tags">
@for (suggestion of suggestions; track suggestion) {
<button
class="suggestion-tag"
[class.suggestion-tag--selected]="isPatternSelected(suggestion)"
(click)="togglePattern(suggestion)"
type="button"
>
{{ suggestion }}
</button>
}
</div>
</div>
<div class="selected-patterns">
<span class="patterns-label">Selected patterns:</span>
@if (selectedPatterns().length === 0) {
<span class="no-patterns">No patterns selected</span>
} @else {
<div class="pattern-list">
@for (pattern of selectedPatterns(); track pattern) {
<div class="pattern-chip">
<span>{{ pattern }}</span>
<button
class="remove-pattern"
(click)="removePattern(pattern)"
type="button"
aria-label="Remove pattern"
>&times;</button>
</div>
}
</div>
}
</div>
<div class="custom-pattern">
<input
type="text"
class="text-input"
placeholder="Add custom pattern (e.g., mylib/*)"
[ngModel]="customPatternInput()"
(ngModelChange)="customPatternInput.set($event)"
(keydown.enter)="addCustomPattern()"
/>
<button
class="add-pattern-btn"
(click)="addCustomPattern()"
[disabled]="!customPatternInput()"
type="button"
>Add</button>
</div>
</div>
</div>
}
<!-- Step 3: Coverage Thresholds -->
@if (currentStep() === 'thresholds') {
<div class="wizard__step">
<h3 class="step-title">Set Coverage Thresholds</h3>
<p class="step-description">
Configure the minimum observation rate and time window for verification.
</p>
<div class="threshold-fields">
<div class="input-group">
<label class="input-label">Minimum Observation Rate</label>
<div class="range-group">
<input
type="range"
min="0.5"
max="1.0"
step="0.05"
[ngModel]="minRate()"
(ngModelChange)="minRate.set($event)"
/>
<span class="range-value">{{ formatPercent(minRate()) }}</span>
</div>
<span class="input-hint">
Minimum fraction of expected paths that must be observed
</span>
</div>
<div class="input-group">
<label class="input-label">Observation Window (seconds)</label>
<div class="window-options">
@for (opt of windowOptions; track opt.value) {
<button
class="window-btn"
[class.window-btn--active]="windowSeconds() === opt.value"
(click)="windowSeconds.set(opt.value)"
type="button"
>
{{ opt.label }}
</button>
}
</div>
<span class="input-hint">
Time window to evaluate for observations ({{ windowSeconds() }}s)
</span>
</div>
</div>
</div>
}
<!-- Step 4: Review -->
@if (currentStep() === 'review') {
<div class="wizard__step">
<h3 class="step-title">Review & Create</h3>
<p class="step-description">
Review your configuration before generating the function map.
</p>
<div class="review-section">
<div class="review-item">
<span class="review-label">Service:</span>
<span class="review-value">{{ serviceName() }}</span>
</div>
<div class="review-item">
<span class="review-label">SBOM Source:</span>
<span class="review-value">
{{ sbomSource() === 'file' ? 'File Upload' : ociRef() }}
</span>
</div>
@if (buildId()) {
<div class="review-item">
<span class="review-label">Build ID:</span>
<span class="review-value mono">{{ buildId() }}</span>
</div>
}
<div class="review-item">
<span class="review-label">Patterns:</span>
<span class="review-value">{{ selectedPatterns().join(', ') }}</span>
</div>
<div class="review-item">
<span class="review-label">Min Rate:</span>
<span class="review-value">{{ formatPercent(minRate()) }}</span>
</div>
<div class="review-item">
<span class="review-label">Window:</span>
<span class="review-value">{{ windowSeconds() }}s</span>
</div>
</div>
</div>
}
<!-- Navigation -->
<div class="wizard__nav">
<button
class="nav-btn nav-btn--cancel"
(click)="cancel.emit()"
type="button"
>Cancel</button>
<div class="nav-right">
@if (currentStepIndex() > 0) {
<button
class="nav-btn nav-btn--back"
(click)="goBack()"
type="button"
>Back</button>
}
@if (currentStepIndex() < steps.length - 1) {
<button
class="nav-btn nav-btn--next"
(click)="goNext()"
[disabled]="!canProceed()"
type="button"
>Next</button>
} @else {
<button
class="nav-btn nav-btn--create"
(click)="onSubmit()"
[disabled]="!canSubmit()"
type="button"
>Create Function Map</button>
}
</div>
</div>
</div>
`,
styles: [`
.wizard {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 640px;
}
.wizard__progress {
display: flex;
gap: 4px;
}
.progress-step {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 4px;
background: #F3F4F6;
transition: all 0.15s;
&--active {
background: #EEF2FF;
border: 1px solid #C7D2FE;
}
&--completed {
background: #D1FAE5;
}
}
.step-number {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
border-radius: 50%;
background: #D1D5DB;
color: #FFFFFF;
.progress-step--active & { background: #2563EB; }
.progress-step--completed & { background: #059669; }
}
.step-label {
font-size: 12px;
color: #6B7280;
.progress-step--active & { color: #2563EB; font-weight: 600; }
.progress-step--completed & { color: #059669; }
}
.wizard__step {
display: flex;
flex-direction: column;
gap: 16px;
}
.step-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #111827;
}
.step-description {
margin: 0;
font-size: 13px;
color: #6B7280;
}
.source-options {
display: flex;
gap: 12px;
}
.source-option {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border: 2px solid #E5E7EB;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s;
&.selected { border-color: #2563EB; background: #EEF2FF; }
&:hover { border-color: #93C5FD; }
input { position: absolute; opacity: 0; }
}
.option-title {
font-weight: 600;
font-size: 14px;
color: #111827;
}
.option-desc {
font-size: 12px;
color: #6B7280;
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.input-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.text-input {
padding: 8px 12px;
font-size: 13px;
border: 1px solid #D1D5DB;
border-radius: 6px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
&:focus { border-color: #2563EB; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); }
&::placeholder { color: #9CA3AF; }
}
.input-hint {
font-size: 11px;
color: #9CA3AF;
}
.suggestions-label, .patterns-label {
font-size: 12px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
display: block;
}
.suggestion-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.suggestion-tag {
padding: 4px 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
border: 1px solid #D1D5DB;
border-radius: 4px;
background: #FFFFFF;
cursor: pointer;
transition: all 0.15s;
&:hover { border-color: #2563EB; }
&--selected { background: #EEF2FF; border-color: #2563EB; color: #2563EB; }
}
.selected-patterns {
margin-top: 12px;
}
.no-patterns {
font-size: 12px;
color: #9CA3AF;
font-style: italic;
}
.pattern-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.pattern-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: #EEF2FF;
border: 1px solid #C7D2FE;
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: #4338CA;
}
.remove-pattern {
background: none;
border: none;
font-size: 14px;
color: #6366F1;
cursor: pointer;
padding: 0 2px;
line-height: 1;
&:hover { color: #DC2626; }
}
.custom-pattern {
display: flex;
gap: 8px;
margin-top: 12px;
}
.add-pattern-btn {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
background: #F3F4F6;
border: 1px solid #D1D5DB;
border-radius: 6px;
cursor: pointer;
&:hover { background: #E5E7EB; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.range-group {
display: flex;
align-items: center;
gap: 12px;
}
.range-group input[type="range"] {
flex: 1;
height: 6px;
accent-color: #2563EB;
}
.range-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 700;
color: #2563EB;
min-width: 40px;
}
.window-options {
display: flex;
gap: 6px;
}
.window-btn {
padding: 6px 12px;
font-size: 12px;
border: 1px solid #D1D5DB;
border-radius: 4px;
background: #FFFFFF;
cursor: pointer;
transition: all 0.15s;
&:hover { border-color: #2563EB; }
&--active { background: #2563EB; color: #FFFFFF; border-color: #2563EB; }
}
.review-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #F9FAFB;
border-radius: 8px;
}
.review-item {
display: flex;
gap: 12px;
}
.review-label {
min-width: 100px;
font-size: 12px;
font-weight: 600;
color: #6B7280;
}
.review-value {
font-size: 13px;
color: #111827;
&.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
}
.wizard__nav {
display: flex;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid #E5E7EB;
}
.nav-right {
display: flex;
gap: 8px;
}
.nav-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s;
&--cancel { background: none; color: #6B7280; &:hover { color: #374151; } }
&--back { background: #F3F4F6; color: #374151; &:hover { background: #E5E7EB; } }
&--next { background: #2563EB; color: #FFFFFF; &:hover { background: #1D4ED8; } }
&--create { background: #059669; color: #FFFFFF; &:hover { background: #047857; } }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
@media (max-width: 640px) {
.source-options { flex-direction: column; }
.window-options { flex-wrap: wrap; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FunctionMapGeneratorComponent {
/** Emits generated request */
readonly generate = output<GenerateFunctionMapRequest>();
/** Emits when cancelled */
readonly cancel = output<void>();
/** Wizard steps */
readonly steps: { id: WizardStep; label: string }[] = [
{ id: 'sbom', label: 'SBOM' },
{ id: 'patterns', label: 'Patterns' },
{ id: 'thresholds', label: 'Thresholds' },
{ id: 'review', label: 'Review' },
];
/** Hot function suggestions */
readonly suggestions = HOT_FUNCTION_SUGGESTIONS;
/** Window size options */
readonly windowOptions = [
{ value: 300, label: '5 min' },
{ value: 900, label: '15 min' },
{ value: 1800, label: '30 min' },
{ value: 3600, label: '1 hour' },
];
/** Current step */
readonly currentStep = signal<WizardStep>('sbom');
/** Current step index */
readonly currentStepIndex = computed(() =>
this.steps.findIndex(s => s.id === this.currentStep())
);
/** Form fields */
readonly sbomSource = signal<SbomSourceType>('file');
readonly ociRef = signal('');
readonly serviceName = signal('');
readonly buildId = signal('');
readonly selectedPatterns = signal<string[]>([]);
readonly customPatternInput = signal('');
readonly minRate = signal(0.95);
readonly windowSeconds = signal(1800);
/** Whether a step is completed */
isStepCompleted(stepId: WizardStep): boolean {
const idx = this.steps.findIndex(s => s.id === stepId);
return idx < this.currentStepIndex();
}
/** Can proceed to next step */
readonly canProceed = computed(() => {
const step = this.currentStep();
switch (step) {
case 'sbom':
if (!this.serviceName()) return false;
if (this.sbomSource() === 'oci' && !this.ociRef()) return false;
return true;
case 'patterns':
return this.selectedPatterns().length > 0;
case 'thresholds':
return true;
default:
return true;
}
});
/** Can submit */
readonly canSubmit = computed(() => {
return this.serviceName() && this.selectedPatterns().length > 0;
});
/** Check if pattern is selected */
isPatternSelected(pattern: string): boolean {
return this.selectedPatterns().includes(pattern);
}
/** Toggle a pattern */
togglePattern(pattern: string): void {
this.selectedPatterns.update(patterns =>
patterns.includes(pattern)
? patterns.filter(p => p !== pattern)
: [...patterns, pattern]
);
}
/** Remove a pattern */
removePattern(pattern: string): void {
this.selectedPatterns.update(patterns =>
patterns.filter(p => p !== pattern)
);
}
/** Add custom pattern */
addCustomPattern(): void {
const input = this.customPatternInput().trim();
if (input && !this.selectedPatterns().includes(input)) {
this.selectedPatterns.update(patterns => [...patterns, input]);
this.customPatternInput.set('');
}
}
/** Go to next step */
goNext(): void {
const idx = this.currentStepIndex();
if (idx < this.steps.length - 1) {
this.currentStep.set(this.steps[idx + 1].id);
}
}
/** Go to previous step */
goBack(): void {
const idx = this.currentStepIndex();
if (idx > 0) {
this.currentStep.set(this.steps[idx - 1].id);
}
}
/** Submit */
onSubmit(): void {
const request: GenerateFunctionMapRequest = {
service: this.serviceName(),
hotFunctionPatterns: this.selectedPatterns(),
coverageThresholds: {
minObservationRate: this.minRate(),
windowSeconds: this.windowSeconds(),
},
buildId: this.buildId() || undefined,
};
if (this.sbomSource() === 'oci') {
request.sbomOciRef = this.ociRef();
}
this.generate.emit(request);
}
/** Format percentage */
formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
}

View File

@@ -0,0 +1,629 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
FunctionMapSummary,
VerificationStatus,
VERIFICATION_STATUS_DISPLAY,
COVERAGE_STATUS_DISPLAY,
} from '../../core/api/function-map.models';
/**
* Function Map List View Component.
*
* Displays a table of all function maps for the tenant with:
* - Service name and binary path
* - Creation date
* - Last verification timestamp and status
* - Coverage status indicator
* - Actions: View, Verify Now, Delete
*
* Sprint: SPRINT_20260122_039 (RLV-010)
*
* @example
* <stella-function-map-list
* [functionMaps]="maps"
* [loading]="isLoading"
* (view)="onView($event)"
* (verify)="onVerify($event)"
* (delete)="onDelete($event)"
* (create)="onCreate()"
* />
*/
@Component({
selector: 'stella-function-map-list',
standalone: true,
imports: [CommonModule],
template: `
<div class="fm-list">
<!-- Header -->
<header class="fm-list__header">
<h2 class="fm-list__title">Function Maps</h2>
<button
class="fm-list__create-btn"
(click)="create.emit()"
type="button"
>
+ New Function Map
</button>
</header>
<!-- Loading state -->
@if (loading()) {
<div class="fm-list__loading" role="status" aria-label="Loading function maps">
<div class="loading-spinner"></div>
<span>Loading function maps...</span>
</div>
}
<!-- Empty state -->
@if (!loading() && functionMaps().length === 0) {
<div class="fm-list__empty">
<p class="empty-title">No function maps configured</p>
<p class="empty-description">
Function maps define expected runtime behavior for your services.
Create one to start monitoring function-level execution patterns.
</p>
<button class="fm-list__create-btn" (click)="create.emit()" type="button">
Create your first function map
</button>
</div>
}
<!-- Error state -->
@if (error()) {
<div class="fm-list__error" role="alert">
<span class="error-icon">[!]</span>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="retry.emit()" type="button">Retry</button>
</div>
}
<!-- Table -->
@if (!loading() && functionMaps().length > 0) {
<div class="fm-list__table-container">
<table class="fm-list__table" role="grid" aria-label="Function maps">
<thead>
<tr>
<th scope="col" class="col-service">Service</th>
<th scope="col" class="col-paths">Paths</th>
<th scope="col" class="col-created">Created</th>
<th scope="col" class="col-verified">Last Verified</th>
<th scope="col" class="col-status">Status</th>
<th scope="col" class="col-coverage">Coverage</th>
<th scope="col" class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (map of functionMaps(); track map.id) {
<tr
class="fm-row"
[class.fm-row--degraded]="map.verificationStatus === 'degraded'"
[class.fm-row--error]="map.verificationStatus === 'error'"
>
<td class="col-service">
<div class="service-cell">
<span class="service-name">{{ map.service }}</span>
<span class="binary-path" [title]="map.binaryPath">
{{ truncatePath(map.binaryPath) }}
</span>
</div>
</td>
<td class="col-paths">
<span class="path-count">{{ map.pathCount }}</span>
<span class="call-count">({{ map.callCount }} calls)</span>
</td>
<td class="col-created">
<span class="date-value">{{ formatDate(map.createdAt) }}</span>
</td>
<td class="col-verified">
@if (map.lastVerifiedAt) {
<span class="date-value">{{ formatRelative(map.lastVerifiedAt) }}</span>
} @else {
<span class="never-verified">Never</span>
}
</td>
<td class="col-status">
<span
class="status-badge"
[style.backgroundColor]="getStatusDisplay(map.verificationStatus).lightColor"
[style.color]="getStatusDisplay(map.verificationStatus).color"
[title]="getStatusDisplay(map.verificationStatus).description"
>
{{ getStatusDisplay(map.verificationStatus).label }}
</span>
</td>
<td class="col-coverage">
@if (map.observationRate !== undefined) {
<div class="coverage-cell">
<div class="coverage-bar-container">
<div
class="coverage-bar"
[style.width.%]="map.observationRate * 100"
[style.backgroundColor]="getCoverageColor(map.coverageStatus)"
></div>
</div>
<span class="coverage-value">{{ formatPercent(map.observationRate) }}</span>
</div>
} @else {
<span class="no-data">-</span>
}
</td>
<td class="col-actions">
<div class="action-buttons">
<button
class="action-btn action-btn--view"
(click)="view.emit(map.id)"
title="View details"
type="button"
>View</button>
<button
class="action-btn action-btn--verify"
(click)="verify.emit(map.id)"
title="Run verification now"
type="button"
>Verify</button>
<button
class="action-btn action-btn--delete"
(click)="confirmDelete(map)"
title="Delete function map"
type="button"
>Del</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Delete confirmation -->
@if (deleteTarget()) {
<div class="fm-list__confirm-overlay" (click)="cancelDelete()">
<div class="confirm-dialog" (click)="$event.stopPropagation()" role="alertdialog">
<p class="confirm-message">
Delete function map for <strong>{{ deleteTarget()!.service }}</strong>?
</p>
<p class="confirm-warning">This will remove all verification history.</p>
<div class="confirm-actions">
<button class="confirm-btn confirm-btn--cancel" (click)="cancelDelete()" type="button">Cancel</button>
<button class="confirm-btn confirm-btn--delete" (click)="executeDelete()" type="button">Delete</button>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.fm-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.fm-list__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.fm-list__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #111827;
}
.fm-list__create-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: #FFFFFF;
background-color: #2563EB;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
&:hover { background-color: #1D4ED8; }
&:focus-visible { outline: 2px solid #2563EB; outline-offset: 2px; }
}
.fm-list__loading {
display: flex;
align-items: center;
gap: 12px;
padding: 32px;
justify-content: center;
color: #6B7280;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #E5E7EB;
border-top-color: #2563EB;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fm-list__empty {
text-align: center;
padding: 48px 24px;
background: #F9FAFB;
border: 1px dashed #D1D5DB;
border-radius: 8px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 0 0 8px;
}
.empty-description {
font-size: 13px;
color: #6B7280;
margin: 0 0 16px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.fm-list__error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #FEF2F2;
border: 1px solid #FECACA;
border-radius: 6px;
color: #DC2626;
font-size: 13px;
}
.error-icon { font-weight: 700; }
.retry-btn {
margin-left: auto;
padding: 4px 12px;
font-size: 12px;
color: #DC2626;
background: #FFFFFF;
border: 1px solid #FECACA;
border-radius: 4px;
cursor: pointer;
}
.fm-list__table-container {
overflow-x: auto;
border: 1px solid #E5E7EB;
border-radius: 8px;
}
.fm-list__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead {
background: #F9FAFB;
}
th {
padding: 10px 12px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6B7280;
border-bottom: 1px solid #E5E7EB;
white-space: nowrap;
}
td {
padding: 12px;
border-bottom: 1px solid #F3F4F6;
vertical-align: middle;
}
.fm-row:last-child td {
border-bottom: none;
}
.fm-row--degraded {
background-color: rgba(245, 158, 11, 0.04);
}
.fm-row--error {
background-color: rgba(220, 38, 38, 0.04);
}
.service-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.service-name {
font-weight: 600;
color: #111827;
}
.binary-path {
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: #9CA3AF;
}
.path-count {
font-weight: 600;
color: #374151;
}
.call-count {
font-size: 11px;
color: #9CA3AF;
margin-left: 4px;
}
.date-value {
color: #374151;
}
.never-verified {
color: #9CA3AF;
font-style: italic;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
}
.coverage-cell {
display: flex;
align-items: center;
gap: 8px;
}
.coverage-bar-container {
width: 60px;
height: 6px;
background: #E5E7EB;
border-radius: 3px;
overflow: hidden;
}
.coverage-bar {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.coverage-value {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
color: #374151;
}
.no-data {
color: #D1D5DB;
}
.action-buttons {
display: flex;
gap: 4px;
}
.action-btn {
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
border: 1px solid #E5E7EB;
border-radius: 4px;
background: #FFFFFF;
cursor: pointer;
transition: all 0.15s;
&:hover { background: #F9FAFB; }
&:focus-visible { outline: 2px solid #2563EB; outline-offset: 1px; }
}
.action-btn--verify:hover {
border-color: #059669;
color: #059669;
}
.action-btn--delete:hover {
border-color: #DC2626;
color: #DC2626;
}
.fm-list__confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.confirm-dialog {
background: #FFFFFF;
border-radius: 8px;
padding: 24px;
max-width: 360px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.confirm-message {
margin: 0 0 8px;
font-size: 14px;
color: #111827;
}
.confirm-warning {
margin: 0 0 16px;
font-size: 12px;
color: #6B7280;
}
.confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.confirm-btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 4px;
border: none;
cursor: pointer;
}
.confirm-btn--cancel {
background: #F3F4F6;
color: #374151;
&:hover { background: #E5E7EB; }
}
.confirm-btn--delete {
background: #DC2626;
color: #FFFFFF;
&:hover { background: #B91C1C; }
}
@media (max-width: 768px) {
.fm-list__header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.col-paths, .col-created {
display: none;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FunctionMapListComponent {
/** Function map summaries */
readonly functionMaps = input.required<FunctionMapSummary[]>();
/** Loading state */
readonly loading = input(false);
/** Error message */
readonly error = input<string | null>(null);
/** Emits function map ID to view */
readonly view = output<string>();
/** Emits function map ID to verify */
readonly verify = output<string>();
/** Emits function map ID to delete */
readonly delete = output<string>();
/** Emits when create button clicked */
readonly create = output<void>();
/** Emits when retry clicked */
readonly retry = output<void>();
/** Delete confirmation target */
readonly deleteTarget = signal<FunctionMapSummary | null>(null);
/** Get status display info */
getStatusDisplay(status: VerificationStatus): VerificationStatusDisplay {
return VERIFICATION_STATUS_DISPLAY[status];
}
/** Get coverage color */
getCoverageColor(status: CoverageStatus): string {
return COVERAGE_STATUS_DISPLAY[status].color;
}
/** Format date for display */
formatDate(isoDate: string): string {
try {
return new Date(isoDate).toLocaleDateString();
} catch {
return isoDate;
}
}
/** Format relative time */
formatRelative(isoDate: string): string {
try {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
} catch {
return isoDate;
}
}
/** Format percentage */
formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
/** Truncate binary path for display */
truncatePath(path: string): string {
if (path.length <= 40) return path;
return '...' + path.slice(-37);
}
/** Show delete confirmation */
confirmDelete(map: FunctionMapSummary): void {
this.deleteTarget.set(map);
}
/** Cancel delete */
cancelDelete(): void {
this.deleteTarget.set(null);
}
/** Execute delete */
executeDelete(): void {
const target = this.deleteTarget();
if (target) {
this.delete.emit(target.id);
this.deleteTarget.set(null);
}
}
}

View File

@@ -0,0 +1,9 @@
/**
* Function Maps feature module exports.
* Sprint: SPRINT_20260122_039 (RLV-010)
*/
export { FunctionMapListComponent } from './function-map-list.component';
export { FunctionMapDetailComponent } from './function-map-detail.component';
export { FunctionMapGeneratorComponent } from './function-map-generator.component';
export { VerificationResultsPanelComponent } from './verification-results-panel.component';
export { ObservationTimelineComponent } from './observation-timeline.component';

View File

@@ -0,0 +1,385 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { ObservationBucket } from '../../core/api/function-map.models';
/**
* Observation Timeline Chart Component.
*
* Displays a time-series bar chart of observation counts with:
* - Matched vs unmatched observations per time bucket
* - Hover details
* - Time range selection
*
* Sprint: SPRINT_20260122_039 (RLV-010)
*
* @example
* <stella-observation-timeline
* [buckets]="observationBuckets"
* [height]="160"
* (bucketClick)="onBucketClick($event)"
* />
*/
@Component({
selector: 'stella-observation-timeline',
standalone: true,
imports: [CommonModule],
template: `
<div class="obs-timeline">
<header class="obs-timeline__header">
<h3 class="obs-title">Observations</h3>
<div class="obs-legend">
<span class="legend-item legend-item--matched">
<span class="legend-dot" style="background:#059669"></span>Matched
</span>
<span class="legend-item legend-item--unmatched">
<span class="legend-dot" style="background:#F59E0B"></span>Unmatched
</span>
</div>
</header>
@if (buckets().length === 0) {
<div class="obs-timeline__empty">
<p>No observations in this time range</p>
</div>
} @else {
<div class="obs-timeline__chart" [style.height.px]="height()">
<svg
[attr.width]="'100%'"
[attr.height]="height()"
class="chart-svg"
role="img"
aria-label="Observation timeline chart"
>
<!-- Bars -->
@for (bar of bars(); track $index) {
<g
class="bar-group"
(mouseenter)="onBarEnter($index)"
(mouseleave)="onBarLeave()"
(click)="bucketClick.emit(buckets()[$index])"
>
<!-- Matched portion -->
<rect
[attr.x]="bar.x"
[attr.y]="bar.matchedY"
[attr.width]="bar.width"
[attr.height]="bar.matchedHeight"
fill="#059669"
rx="2"
/>
<!-- Unmatched portion (stacked) -->
@if (bar.unmatchedHeight > 0) {
<rect
[attr.x]="bar.x"
[attr.y]="bar.unmatchedY"
[attr.width]="bar.width"
[attr.height]="bar.unmatchedHeight"
fill="#F59E0B"
rx="2"
/>
}
</g>
}
<!-- Baseline -->
<line
x1="0"
[attr.y1]="height() - 20"
[attr.x2]="'100%'"
[attr.y2]="height() - 20"
stroke="#E5E7EB"
stroke-width="1"
/>
</svg>
<!-- Tooltip -->
@if (hoveredIndex() !== null) {
<div
class="bar-tooltip"
[style.left.%]="getTooltipLeft()"
>
<div class="tooltip-time">{{ getTooltipTime() }}</div>
<div class="tooltip-row">
<span class="tooltip-label">Total:</span>
<span class="tooltip-value">{{ getTooltipTotal() }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Matched:</span>
<span class="tooltip-value tooltip-matched">{{ getTooltipMatched() }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Unmatched:</span>
<span class="tooltip-value tooltip-unmatched">{{ getTooltipUnmatched() }}</span>
</div>
</div>
}
</div>
<!-- X-axis labels -->
<div class="obs-timeline__axis">
<span>{{ formatAxisDate(buckets()[0].start) }}</span>
<span>{{ formatAxisDate(buckets()[buckets().length - 1].end) }}</span>
</div>
<!-- Summary -->
<div class="obs-timeline__summary">
<span class="summary-item">
Total: <strong>{{ totalObservations() }}</strong>
</span>
<span class="summary-item">
Match rate: <strong>{{ matchRate() }}</strong>
</span>
</div>
}
</div>
`,
styles: [`
.obs-timeline {
display: flex;
flex-direction: column;
gap: 8px;
}
.obs-timeline__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.obs-title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #111827;
}
.obs-legend {
display: flex;
gap: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #6B7280;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
.obs-timeline__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
background: #F9FAFB;
border-radius: 6px;
color: #9CA3AF;
font-size: 13px;
}
.obs-timeline__chart {
position: relative;
border-radius: 6px;
background: #F9FAFB;
overflow: visible;
}
.chart-svg {
display: block;
}
.bar-group {
cursor: pointer;
&:hover rect { opacity: 0.85; }
}
.bar-tooltip {
position: absolute;
top: -8px;
transform: translateX(-50%) translateY(-100%);
background: #1F2937;
color: #FFFFFF;
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
.tooltip-time {
font-weight: 600;
margin-bottom: 4px;
color: #D1D5DB;
}
.tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.tooltip-label {
color: #9CA3AF;
}
.tooltip-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.tooltip-matched { color: #34D399; }
.tooltip-unmatched { color: #FCD34D; }
.obs-timeline__axis {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #9CA3AF;
margin-top: -4px;
}
.obs-timeline__summary {
display: flex;
gap: 16px;
font-size: 12px;
color: #6B7280;
}
.summary-item strong {
color: #374151;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ObservationTimelineComponent {
/** Observation buckets */
readonly buckets = input.required<ObservationBucket[]>();
/** Chart height in pixels */
readonly height = input(160);
/** Emits when a bucket bar is clicked */
readonly bucketClick = output<ObservationBucket>();
/** Currently hovered bar index */
readonly hoveredIndex = signal<number | null>(null);
/** Max count across all buckets for scaling */
private readonly maxCount = computed(() => {
const counts = this.buckets().map(b => b.count);
return counts.length > 0 ? Math.max(...counts, 1) : 1;
});
/** Computed bar data */
readonly bars = computed(() => {
const data = this.buckets();
if (data.length === 0) return [];
const chartHeight = this.height() - 24; // padding for axis
const barGap = 2;
const barWidth = Math.max(2, (100 / data.length) - barGap);
const max = this.maxCount();
return data.map((bucket, i) => {
const totalHeight = (bucket.count / max) * chartHeight;
const matchedHeight = (bucket.matchedCount / max) * chartHeight;
const unmatchedHeight = totalHeight - matchedHeight;
const baseY = chartHeight;
const xPercent = (i / data.length) * 100;
return {
x: `${xPercent + barGap / 2}%`,
width: `${barWidth}%`,
matchedY: baseY - matchedHeight,
matchedHeight: Math.max(matchedHeight, 0),
unmatchedY: baseY - totalHeight,
unmatchedHeight: Math.max(unmatchedHeight, 0),
};
});
});
/** Total observations */
readonly totalObservations = computed(() =>
this.buckets().reduce((sum, b) => sum + b.count, 0)
);
/** Match rate percentage */
readonly matchRate = computed(() => {
const total = this.totalObservations();
if (total === 0) return '0%';
const matched = this.buckets().reduce((sum, b) => sum + b.matchedCount, 0);
return `${Math.round((matched / total) * 100)}%`;
});
/** Handle bar hover enter */
onBarEnter(index: number): void {
this.hoveredIndex.set(index);
}
/** Handle bar hover leave */
onBarLeave(): void {
this.hoveredIndex.set(null);
}
/** Get tooltip left position */
getTooltipLeft(): number {
const idx = this.hoveredIndex();
if (idx === null) return 0;
const count = this.buckets().length;
return ((idx + 0.5) / count) * 100;
}
/** Get tooltip time label */
getTooltipTime(): string {
const idx = this.hoveredIndex();
if (idx === null) return '';
const bucket = this.buckets()[idx];
return this.formatAxisDate(bucket.start);
}
/** Get tooltip total */
getTooltipTotal(): number {
const idx = this.hoveredIndex();
if (idx === null) return 0;
return this.buckets()[idx].count;
}
/** Get tooltip matched */
getTooltipMatched(): number {
const idx = this.hoveredIndex();
if (idx === null) return 0;
return this.buckets()[idx].matchedCount;
}
/** Get tooltip unmatched */
getTooltipUnmatched(): number {
const idx = this.hoveredIndex();
if (idx === null) return 0;
return this.buckets()[idx].unmatchedCount;
}
/** Format axis date */
formatAxisDate(isoDate: string): string {
try {
const d = new Date(isoDate);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,514 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import {
VerificationResult,
PathCoverageEntry,
UnexpectedSymbol,
VERIFICATION_STATUS_DISPLAY,
VerificationStatus,
} from '../../core/api/function-map.models';
/**
* Verification Results Panel Component.
*
* Embedded panel showing current verification status:
* - Overall verification pass/fail
* - Observation rate gauge with threshold indicator
* - Path coverage breakdown (expandable)
* - Unexpected symbols warning
* - Link to full verification report
*
* Sprint: SPRINT_20260122_039 (RLV-010)
*
* @example
* <stella-verification-results-panel
* [result]="latestVerification"
* [threshold]="0.95"
* (viewFullReport)="openReport($event)"
* />
*/
@Component({
selector: 'stella-verification-results-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="vr-panel" [class.vr-panel--pass]="result().passed" [class.vr-panel--fail]="!result().passed">
<!-- Header -->
<div class="vr-panel__header">
<span class="vr-status-icon">{{ result().passed ? '[OK]' : '[!]' }}</span>
<span class="vr-status-text">
{{ result().passed ? 'Verification Passed' : 'Verification Failed' }}
</span>
<span class="vr-time">{{ formatDate(result().verifiedAt) }}</span>
</div>
<!-- Observation Rate Gauge -->
<div class="vr-panel__gauge">
<div class="gauge-label">
<span>Observation Rate</span>
<span class="gauge-value" [class.below-threshold]="isBelowThreshold()">
{{ formatPercent(result().observationRate) }}
</span>
</div>
<div class="gauge-bar-container">
<div
class="gauge-bar"
[style.width.%]="result().observationRate * 100"
[class.gauge-bar--pass]="!isBelowThreshold()"
[class.gauge-bar--fail]="isBelowThreshold()"
></div>
<!-- Threshold indicator -->
<div
class="gauge-threshold"
[style.left.%]="threshold() * 100"
[title]="'Threshold: ' + formatPercent(threshold())"
></div>
</div>
<div class="gauge-labels">
<span>0%</span>
<span class="threshold-label" [style.left.%]="threshold() * 100">
{{ formatPercent(threshold()) }}
</span>
<span>100%</span>
</div>
</div>
<!-- Probe Stats -->
<div class="vr-panel__probes">
<span class="probe-stat">
Probes: {{ result().probeStats.attached }}/{{ result().probeStats.total }} attached
</span>
@if (result().probeStats.failed > 0) {
<span class="probe-failures">
({{ result().probeStats.failed }} failed)
</span>
}
</div>
<!-- Path Coverage Breakdown -->
<div class="vr-panel__coverage">
<div class="coverage-header" (click)="toggleCoverage()">
<span class="coverage-title">Path Coverage</span>
<span class="coverage-summary">
{{ passingPaths() }}/{{ totalPaths() }} paths OK
</span>
<span class="coverage-toggle">{{ showCoverage() ? '[-]' : '[+]' }}</span>
</div>
@if (showCoverage()) {
<div class="coverage-list">
@for (path of sortedPaths(); track path.pathHash) {
<div
class="coverage-item"
[class.coverage-item--ok]="path.coveragePercent >= thresholdPercent()"
[class.coverage-item--low]="path.coveragePercent < thresholdPercent()"
[class.coverage-item--optional]="path.optional"
>
<span class="coverage-entry">{{ path.entrypoint }}</span>
<div class="coverage-mini-bar">
<div
class="coverage-mini-fill"
[style.width.%]="path.coveragePercent"
></div>
</div>
<span class="coverage-pct">{{ path.coveragePercent }}%</span>
@if (path.optional) {
<span class="optional-label">opt</span>
}
</div>
}
</div>
}
</div>
<!-- Unexpected Symbols Warning -->
@if (result().unexpectedSymbols.length > 0) {
<div class="vr-panel__unexpected">
<div class="unexpected-header">
<span class="unexpected-icon">[?]</span>
<span class="unexpected-title">
{{ result().unexpectedSymbols.length }} Unexpected Symbol(s)
</span>
</div>
<div class="unexpected-list">
@for (sym of result().unexpectedSymbols.slice(0, 5); track sym.symbol) {
<div class="unexpected-item">
<span class="unexpected-symbol">{{ sym.symbol }}</span>
<span class="unexpected-lib">{{ sym.library }}</span>
<span class="unexpected-count">x{{ sym.observationCount }}</span>
</div>
}
@if (result().unexpectedSymbols.length > 5) {
<span class="unexpected-more">
+{{ result().unexpectedSymbols.length - 5 }} more
</span>
}
</div>
</div>
}
<!-- Footer -->
<div class="vr-panel__footer">
<span class="window-info">
Window: {{ formatDate(result().windowStart) }} - {{ formatDate(result().windowEnd) }}
</span>
<button
class="report-link"
(click)="viewFullReport.emit(result().id)"
type="button"
>
Full Report &rarr;
</button>
</div>
</div>
`,
styles: [`
.vr-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 8px;
border: 1px solid #E5E7EB;
background: #FFFFFF;
&--pass { border-left: 4px solid #059669; }
&--fail { border-left: 4px solid #DC2626; }
}
.vr-panel__header {
display: flex;
align-items: center;
gap: 8px;
}
.vr-status-icon {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 14px;
}
.vr-panel--pass .vr-status-icon { color: #059669; }
.vr-panel--fail .vr-status-icon { color: #DC2626; }
.vr-status-text {
font-weight: 700;
font-size: 14px;
color: #111827;
}
.vr-time {
margin-left: auto;
font-size: 11px;
color: #9CA3AF;
}
.vr-panel__gauge {
display: flex;
flex-direction: column;
gap: 4px;
}
.gauge-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #6B7280;
}
.gauge-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #059669;
&.below-threshold { color: #DC2626; }
}
.gauge-bar-container {
height: 10px;
background: #E5E7EB;
border-radius: 5px;
overflow: visible;
position: relative;
}
.gauge-bar {
height: 100%;
border-radius: 5px;
transition: width 0.5s ease;
&--pass { background: linear-gradient(90deg, #059669, #34D399); }
&--fail { background: linear-gradient(90deg, #DC2626, #F87171); }
}
.gauge-threshold {
position: absolute;
top: -2px;
width: 2px;
height: 14px;
background: #374151;
transform: translateX(-50%);
}
.gauge-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #9CA3AF;
position: relative;
}
.threshold-label {
position: absolute;
transform: translateX(-50%);
font-weight: 600;
color: #374151;
}
.vr-panel__probes {
font-size: 12px;
color: #6B7280;
}
.probe-failures {
color: #F59E0B;
font-weight: 500;
}
.vr-panel__coverage {
border: 1px solid #F3F4F6;
border-radius: 6px;
overflow: hidden;
}
.coverage-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #F9FAFB;
cursor: pointer;
font-size: 12px;
&:hover { background: #F3F4F6; }
}
.coverage-title {
font-weight: 600;
color: #374151;
}
.coverage-summary {
color: #6B7280;
}
.coverage-toggle {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
color: #9CA3AF;
font-size: 11px;
}
.coverage-list {
display: flex;
flex-direction: column;
padding: 8px 12px;
gap: 4px;
}
.coverage-item {
display: grid;
grid-template-columns: 1fr 60px 40px auto;
gap: 8px;
align-items: center;
font-size: 12px;
padding: 2px 0;
&--ok .coverage-entry { color: #374151; }
&--low .coverage-entry { color: #DC2626; font-weight: 600; }
&--optional { opacity: 0.7; }
}
.coverage-entry {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coverage-mini-bar {
height: 4px;
background: #E5E7EB;
border-radius: 2px;
overflow: hidden;
}
.coverage-mini-fill {
height: 100%;
background: #059669;
border-radius: 2px;
}
.coverage-item--low .coverage-mini-fill {
background: #DC2626;
}
.coverage-pct {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #6B7280;
text-align: right;
}
.optional-label {
font-size: 10px;
color: #9CA3AF;
font-style: italic;
}
.vr-panel__unexpected {
background: #FEF3C7;
border: 1px solid #FCD34D;
border-radius: 6px;
padding: 10px 12px;
}
.unexpected-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.unexpected-icon {
font-weight: 700;
color: #D97706;
}
.unexpected-title {
font-size: 12px;
font-weight: 600;
color: #92400E;
}
.unexpected-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.unexpected-item {
display: flex;
gap: 8px;
font-size: 11px;
}
.unexpected-symbol {
font-family: 'JetBrains Mono', monospace;
color: #374151;
}
.unexpected-lib {
color: #9CA3AF;
}
.unexpected-count {
margin-left: auto;
color: #6B7280;
font-family: 'JetBrains Mono', monospace;
}
.unexpected-more {
font-size: 11px;
color: #6B7280;
font-style: italic;
}
.vr-panel__footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid #F3F4F6;
}
.window-info {
font-size: 11px;
color: #9CA3AF;
}
.report-link {
background: none;
border: none;
font-size: 12px;
color: #2563EB;
cursor: pointer;
font-weight: 500;
&:hover { text-decoration: underline; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VerificationResultsPanelComponent {
/** Verification result */
readonly result = input.required<VerificationResult>();
/** Coverage threshold (0.0-1.0) */
readonly threshold = input(0.95);
/** Emits verification ID for full report */
readonly viewFullReport = output<string>();
/** Internal toggle for coverage expansion */
private readonly _showCoverage = signal(false);
readonly showCoverage = this._showCoverage.asReadonly();
/** Threshold as percentage */
readonly thresholdPercent = computed(() => Math.round(this.threshold() * 100));
/** Whether observation rate is below threshold */
readonly isBelowThreshold = computed(() =>
this.result().observationRate < this.threshold()
);
/** Number of passing paths */
readonly passingPaths = computed(() =>
this.result().pathCoverage.filter(p =>
p.coveragePercent >= this.thresholdPercent() || p.optional
).length
);
/** Total paths */
readonly totalPaths = computed(() => this.result().pathCoverage.length);
/** Paths sorted by coverage (lowest first) */
readonly sortedPaths = computed(() =>
[...this.result().pathCoverage].sort((a, b) => a.coveragePercent - b.coveragePercent)
);
/** Toggle coverage expansion */
toggleCoverage(): void {
this._showCoverage.update(v => !v);
}
/** Format date */
formatDate(isoDate: string): string {
try {
const d = new Date(isoDate);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return isoDate;
}
}
/** Format percentage */
formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
}

View File

@@ -17,6 +17,9 @@ import { VexStatusChipComponent } from './vex-status-chip.component';
import { ScoreBreakdownComponent } from './score-breakdown.component';
import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
// Sprint: SPRINT_20260122_037 (TSF-008) - Unknowns band indicator
import { UnknownsBandComponent } from './score/unknowns-band.component';
import { isHighUnknowns } from '../../core/api/scoring.models';
/**
* Compact row component for displaying a vulnerability finding.
@@ -44,6 +47,7 @@ import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './prove
ScoreBreakdownComponent,
ChainStatusBadgeComponent,
ProvenanceBadgeComponent,
UnknownsBandComponent,
],
template: `
<article
@@ -101,6 +105,16 @@ import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './prove
/>
</div>
<!-- Unknowns Band Indicator (TSF-008) -->
@if (showUnknownsBand() && unknownsFraction() !== null) {
<div class="finding-row__unknowns">
<stella-unknowns-band
[unknownsFraction]="unknownsFraction()!"
size="sm"
/>
</div>
}
<!-- Reachability -->
<div class="finding-row__reachability">
<stella-reachability-chip
@@ -311,7 +325,8 @@ import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './prove
.finding-row__reachability,
.finding-row__vex,
.finding-row__chain,
.finding-row__provenance {
.finding-row__provenance,
.finding-row__unknowns {
flex-shrink: 0;
}
@@ -434,6 +449,22 @@ export class FindingRowComponent {
*/
readonly showProvenanceBadge = input<boolean>(true);
/**
* Unknowns fraction for this finding (0.0-1.0), or null if not available.
* Sprint: SPRINT_20260122_037 (TSF-008)
*/
readonly unknownsFraction = input<number | null>(null);
/**
* Whether to show the unknowns band indicator.
* Only shown when unknownsFraction is provided and is high (>= 0.4).
* Sprint: SPRINT_20260122_037 (TSF-008)
*/
readonly showUnknownsBand = computed(() => {
const u = this.unknownsFraction();
return u !== null && isHighUnknowns(u);
});
/**
* Maximum number of path steps to show in preview (default: 5).
*/

View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
export { RemediationHintComponent } from './remediation-hint.component';
export { PolicyEvaluatePanelComponent } from './policy-evaluate-panel.component';
export { PolicyImportDialogComponent } from './policy-import-dialog.component';
export { PolicyExportDialogComponent } from './policy-export-dialog.component';
export { PolicyPackEditorComponent } from './policy-pack-editor.component';

View File

@@ -0,0 +1,201 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RemediationHintComponent } from './remediation-hint.component';
import { PolicyEvaluateResponse, GateEvaluation } from '../../core/api/policy-interop.models';
@Component({
selector: 'stella-policy-evaluate-panel',
standalone: true,
imports: [CommonModule, RemediationHintComponent],
template: `
<div class="evaluate-panel" *ngIf="result">
<div class="decision-banner" [class]="'decision-' + result.decision">
<span class="decision-label">Decision:</span>
<span class="decision-value">{{ result.decision | uppercase }}</span>
</div>
<div class="gates-section" *ngIf="result.gates?.length">
<h4 class="section-title">Gate Results</h4>
<table class="gates-table">
<thead>
<tr>
<th>Gate</th>
<th>Type</th>
<th>Result</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let gate of result.gates" [class.failed]="!gate.passed">
<td class="gate-id">{{ gate.gate_id }}</td>
<td class="gate-type">{{ formatGateType(gate.gate_type) }}</td>
<td>
<span class="result-badge" [class.pass]="gate.passed" [class.fail]="!gate.passed">
{{ gate.passed ? 'PASS' : 'FAIL' }}
</span>
</td>
<td class="gate-reason">{{ gate.reason || 'passed' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="remediation-section" *ngIf="result.remediation?.length">
<h4 class="section-title">Remediation</h4>
<stella-remediation-hint
*ngFor="let hint of result.remediation"
[hint]="hint"
></stella-remediation-hint>
</div>
<div class="digest-section" *ngIf="result.output_digest">
<span class="digest-label">Output Digest:</span>
<code class="digest-value">{{ result.output_digest }}</code>
</div>
</div>
`,
styles: [
`
.evaluate-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.decision-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-weight: 600;
}
.decision-allow {
background: var(--bg-success, #f0fdf4);
color: var(--color-success, #16a34a);
border: 1px solid var(--border-success, #bbf7d0);
}
.decision-warn {
background: var(--bg-warning, #fefce8);
color: var(--color-warning, #ca8a04);
border: 1px solid var(--border-warning, #fef08a);
}
.decision-block {
background: var(--bg-error, #fef2f2);
color: var(--color-error, #dc2626);
border: 1px solid var(--border-error, #fecaca);
}
.decision-label {
font-size: 0.875rem;
opacity: 0.8;
}
.decision-value {
font-size: 1.125rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--text-primary, #1f2937);
}
.gates-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.gates-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
font-weight: 600;
color: var(--text-secondary, #6b7280);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.gates-table td {
padding: 0.5rem;
border-bottom: 1px solid var(--border-light, #f3f4f6);
}
.gates-table tr.failed {
background: var(--bg-error-subtle, #fef2f2);
}
.gate-id {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.gate-type {
color: var(--text-secondary, #6b7280);
}
.gate-reason {
font-size: 0.75rem;
color: var(--text-tertiary, #9ca3af);
}
.result-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.result-badge.pass {
background: var(--bg-success, #f0fdf4);
color: var(--color-success, #16a34a);
}
.result-badge.fail {
background: var(--bg-error, #fef2f2);
color: var(--color-error, #dc2626);
}
.digest-section {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-light, #f3f4f6);
}
.digest-label {
font-size: 0.75rem;
color: var(--text-tertiary, #9ca3af);
}
.digest-value {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 2px;
color: var(--text-secondary, #6b7280);
}
`,
],
})
export class PolicyEvaluatePanelComponent {
@Input() result: PolicyEvaluateResponse | null = null;
@Output() retryEvaluate = new EventEmitter<void>();
formatGateType(type: string): string {
return type.replace(/Gate$/, '').replace(/([A-Z])/g, ' $1').trim();
}
}

View File

@@ -0,0 +1,280 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { PolicyInteropService } from '../../core/api/policy-interop.service';
import {
PolicyExportRequest,
PolicyExportResponse,
PolicyPackDocument,
} from '../../core/api/policy-interop.models';
@Component({
selector: 'stella-policy-export-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="export-dialog-backdrop" (click)="closeDialog()">
<div class="export-dialog" (click)="$event.stopPropagation()">
<div class="dialog-header">
<h3>Export Policy</h3>
<button class="close-btn" (click)="closeDialog()">&times;</button>
</div>
<div class="dialog-body">
<!-- Format Selection -->
<div class="format-section">
<label class="section-label">Export Format</label>
<div class="format-options">
<label class="format-option" [class.selected]="exportFormat === 'json'">
<input type="radio" name="format" value="json" [(ngModel)]="exportFormat" />
<div class="format-info">
<span class="format-name">JSON (PolicyPack v2)</span>
<span class="format-desc">Canonical JSON, schema-validated, deterministic</span>
</div>
</label>
<label class="format-option" [class.selected]="exportFormat === 'rego'">
<input type="radio" name="format" value="rego" [(ngModel)]="exportFormat" />
<div class="format-info">
<span class="format-name">OPA/Rego</span>
<span class="format-desc">Compatible with Open Policy Agent toolchains</span>
</div>
</label>
</div>
</div>
<!-- Environment Picker -->
<div class="environment-section">
<label class="section-label">Environment (optional)</label>
<select [(ngModel)]="selectedEnvironment" class="env-select">
<option value="">All environments</option>
<option value="development">Development</option>
<option value="staging">Staging</option>
<option value="production">Production</option>
</select>
<span class="env-hint">Export gate config for a specific environment</span>
</div>
<!-- Options -->
<div class="options-section">
<label class="option-item">
<input type="checkbox" [(ngModel)]="includeRemediation" />
Include remediation hints
</label>
</div>
<!-- Preview -->
<div class="preview-section" *ngIf="exportResult()">
<label class="section-label">Preview</label>
<pre class="preview-content">{{ previewContent() }}</pre>
<div class="digest-row" *ngIf="exportResult()!.digest">
<span class="digest-label">Digest:</span>
<code class="digest-value">{{ exportResult()!.digest }}</code>
</div>
</div>
<!-- Error -->
<div class="error-section" *ngIf="errorMessage()">
<span class="error-text">{{ errorMessage() }}</span>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" (click)="closeDialog()">Cancel</button>
<button
class="btn btn-outline"
[disabled]="loading() || !policyContent"
(click)="doExport()"
>
{{ loading() ? 'Exporting...' : 'Preview' }}
</button>
<button
class="btn btn-primary"
[disabled]="!exportResult()"
(click)="doDownload()"
>
Download
</button>
</div>
</div>
</div>
`,
styles: [
`
.export-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.export-dialog {
background: var(--surface-primary, #fff);
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 540px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.dialog-header h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-tertiary, #9ca3af); line-height: 1; }
.dialog-body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-label { display: block; font-size: 0.8125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary, #1f2937); }
.format-options { display: flex; flex-direction: column; gap: 0.5rem; }
.format-option {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.format-option.selected { border-color: var(--color-primary, #3b82f6); background: #eff6ff; }
.format-option input[type="radio"] { margin-top: 0.125rem; }
.format-info { display: flex; flex-direction: column; gap: 0.125rem; }
.format-name { font-size: 0.875rem; font-weight: 500; }
.format-desc { font-size: 0.75rem; color: var(--text-tertiary, #9ca3af); }
.env-select { width: 100%; padding: 0.375rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color, #d1d5db); font-size: 0.8125rem; }
.env-hint { display: block; margin-top: 0.25rem; font-size: 0.75rem; color: var(--text-tertiary, #9ca3af); }
.options-section { display: flex; flex-direction: column; gap: 0.5rem; }
.option-item { font-size: 0.8125rem; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.preview-section { border-top: 1px solid var(--border-light, #f3f4f6); padding-top: 1rem; }
.preview-content {
font-size: 0.6875rem;
font-family: var(--font-mono, monospace);
background: var(--surface-code, #1f2937);
color: var(--text-code, #e5e7eb);
padding: 0.75rem;
border-radius: 4px;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.digest-row { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; }
.digest-label { font-size: 0.75rem; color: var(--text-tertiary, #9ca3af); }
.digest-value { font-size: 0.6875rem; padding: 0.125rem 0.375rem; background: var(--surface-secondary, #f9fafb); border-radius: 2px; }
.error-section { padding: 0.5rem 0.75rem; background: #fef2f2; border-radius: 4px; }
.error-text { font-size: 0.8125rem; color: #dc2626; }
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.btn { padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.8125rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--surface-secondary, #f3f4f6); color: var(--text-primary, #1f2937); border-color: var(--border-color, #d1d5db); }
.btn-outline { background: transparent; color: var(--color-primary, #3b82f6); border-color: var(--color-primary, #3b82f6); }
.btn-primary { background: var(--color-primary, #3b82f6); color: #fff; }
`,
],
})
export class PolicyExportDialogComponent {
@Input() policyContent: string = '';
@Output() closed = new EventEmitter<void>();
@Output() exported = new EventEmitter<PolicyExportResponse>();
exportFormat: 'json' | 'rego' = 'json';
selectedEnvironment = '';
includeRemediation = true;
loading = signal(false);
exportResult = signal<PolicyExportResponse | null>(null);
errorMessage = signal<string>('');
previewContent = signal<string>('');
constructor(private readonly policyService: PolicyInteropService) {}
doExport(): void {
this.loading.set(true);
this.errorMessage.set('');
this.exportResult.set(null);
const request: PolicyExportRequest = {
policy_content: this.policyContent,
format: this.exportFormat,
environment: this.selectedEnvironment || undefined,
include_remediation: this.includeRemediation,
};
this.policyService.export(request).subscribe({
next: (result) => {
this.exportResult.set(result);
this.previewContent.set(
result.content
? result.content.length > 2000
? result.content.substring(0, 2000) + '\n...(truncated)'
: result.content
: ''
);
this.loading.set(false);
},
error: (err) => {
this.errorMessage.set(err.message || 'Export failed');
this.loading.set(false);
},
});
}
doDownload(): void {
const result = this.exportResult();
if (!result?.content) return;
const extension = this.exportFormat === 'json' ? '.json' : '.rego';
const mimeType = this.exportFormat === 'json' ? 'application/json' : 'text/plain';
const fileName = `policy-export${extension}`;
const blob = new Blob([result.content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
this.exported.emit(result);
}
closeDialog(): void {
this.closed.emit();
}
}

View File

@@ -0,0 +1,378 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Component, Output, EventEmitter, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { PolicyInteropService } from '../../core/api/policy-interop.service';
import {
PolicyImportRequest,
PolicyImportResponse,
PolicyValidateResponse,
PolicyDiagnostic,
} from '../../core/api/policy-interop.models';
@Component({
selector: 'stella-policy-import-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="import-dialog-backdrop" (click)="closeDialog()">
<div class="import-dialog" (click)="$event.stopPropagation()">
<div class="dialog-header">
<h3>Import Policy</h3>
<button class="close-btn" (click)="closeDialog()">&times;</button>
</div>
<div class="dialog-body">
<!-- File Upload -->
<div class="upload-section">
<label class="upload-label" for="policyFile">
<div class="upload-icon">&#x1F4C4;</div>
<span class="upload-text">
{{ fileName() || 'Drop a file here or click to browse' }}
</span>
<span class="upload-hint">Supports JSON (PolicyPack v2) and OPA/Rego files</span>
</label>
<input
type="file"
id="policyFile"
class="file-input"
accept=".json,.rego,.txt"
(change)="onFileSelected($event)"
/>
</div>
<!-- Format Detection -->
<div class="format-section" *ngIf="detectedFormat()">
<span class="format-label">Detected format:</span>
<span class="format-badge" [class]="'format-' + detectedFormat()">
{{ detectedFormat() === 'json' ? 'JSON (PolicyPack v2)' : 'OPA/Rego' }}
</span>
</div>
<!-- Options -->
<div class="options-section" *ngIf="fileContent()">
<div class="option-row">
<label>
<input type="checkbox" [(ngModel)]="validateOnly" />
Validate only (do not import)
</label>
</div>
<div class="option-row">
<label>
<input type="checkbox" [(ngModel)]="dryRun" />
Dry run (preview changes)
</label>
</div>
<div class="option-row">
<label class="merge-label">Merge strategy:</label>
<select [(ngModel)]="mergeStrategy" class="merge-select">
<option value="replace">Replace existing</option>
<option value="append">Append to existing</option>
</select>
</div>
</div>
<!-- Validation Results -->
<div class="validation-section" *ngIf="validationResult()">
<div
class="validation-banner"
[class.valid]="validationResult()!.valid"
[class.invalid]="!validationResult()!.valid"
>
{{ validationResult()!.valid ? 'Valid policy document' : 'Validation errors found' }}
</div>
<div class="diagnostics" *ngIf="allDiagnostics().length">
<div
*ngFor="let diag of allDiagnostics()"
class="diagnostic"
[class]="'diag-' + diag.severity"
>
<span class="diag-severity">{{ diag.severity }}</span>
<span class="diag-code">{{ diag.code }}</span>
<span class="diag-message">{{ diag.message }}</span>
</div>
</div>
</div>
<!-- Import Result -->
<div class="import-result" *ngIf="importResult()">
<div
class="result-banner"
[class.success]="importResult()!.success"
[class.failure]="!importResult()!.success"
>
{{ importResult()!.success ? 'Import successful' : 'Import failed' }}
</div>
<div class="result-details" *ngIf="importResult()!.success">
<div class="detail-row">
<span class="detail-label">Gates imported:</span>
<span class="detail-value">{{ importResult()!.gates_imported }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Rules imported:</span>
<span class="detail-value">{{ importResult()!.rules_imported }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Native mapped:</span>
<span class="detail-value">{{ importResult()!.native_mapped }}</span>
</div>
<div class="detail-row">
<span class="detail-label">OPA evaluated:</span>
<span class="detail-value">{{ importResult()!.opa_evaluated }}</span>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn btn-secondary" (click)="closeDialog()">Cancel</button>
<button
class="btn btn-primary"
[disabled]="!fileContent() || loading()"
(click)="validateOnly ? doValidate() : doImport()"
>
{{ loading() ? 'Processing...' : (validateOnly ? 'Validate' : 'Import') }}
</button>
</div>
</div>
</div>
`,
styles: [
`
.import-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.import-dialog {
background: var(--surface-primary, #fff);
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 540px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.dialog-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-tertiary, #9ca3af);
line-height: 1;
}
.dialog-body {
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.upload-section {
position: relative;
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
border: 2px dashed var(--border-color, #d1d5db);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s;
text-align: center;
}
.upload-label:hover {
border-color: var(--color-primary, #3b82f6);
}
.upload-icon { font-size: 2rem; }
.upload-text { font-size: 0.875rem; font-weight: 500; }
.upload-hint { font-size: 0.75rem; color: var(--text-tertiary, #9ca3af); }
.file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.format-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.format-label { font-size: 0.8125rem; color: var(--text-secondary, #6b7280); }
.format-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 3px;
}
.format-json { background: #eff6ff; color: #2563eb; }
.format-rego { background: #f0fdf4; color: #16a34a; }
.options-section { display: flex; flex-direction: column; gap: 0.5rem; }
.option-row { font-size: 0.8125rem; display: flex; align-items: center; gap: 0.5rem; }
.merge-label { color: var(--text-secondary, #6b7280); }
.merge-select { font-size: 0.8125rem; padding: 0.25rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color, #d1d5db); }
.validation-banner, .result-banner {
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
}
.valid, .success { background: #f0fdf4; color: #16a34a; }
.invalid, .failure { background: #fef2f2; color: #dc2626; }
.diagnostics { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem; }
.diagnostic { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 3px; }
.diag-error { background: #fef2f2; }
.diag-warning { background: #fefce8; }
.diag-info { background: #eff6ff; }
.diag-severity { font-weight: 600; text-transform: uppercase; font-size: 0.625rem; }
.diag-code { font-family: monospace; color: var(--text-secondary, #6b7280); }
.diag-message { color: var(--text-primary, #1f2937); }
.result-details { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem; }
.detail-row { display: flex; justify-content: space-between; font-size: 0.8125rem; }
.detail-label { color: var(--text-secondary, #6b7280); }
.detail-value { font-weight: 600; }
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--surface-secondary, #f3f4f6); color: var(--text-primary, #1f2937); border-color: var(--border-color, #d1d5db); }
.btn-primary { background: var(--color-primary, #3b82f6); color: #fff; }
`,
],
})
export class PolicyImportDialogComponent {
@Output() closed = new EventEmitter<void>();
@Output() imported = new EventEmitter<PolicyImportResponse>();
fileName = signal<string>('');
fileContent = signal<string>('');
detectedFormat = signal<'json' | 'rego' | null>(null);
loading = signal(false);
validationResult = signal<PolicyValidateResponse | null>(null);
importResult = signal<PolicyImportResponse | null>(null);
validateOnly = false;
dryRun = false;
mergeStrategy: 'replace' | 'append' = 'replace';
allDiagnostics = computed<PolicyDiagnostic[]>(() => {
const result = this.validationResult();
if (!result) return [];
return [...(result.errors || []), ...(result.warnings || [])];
});
constructor(private readonly policyService: PolicyInteropService) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.fileName.set(file.name);
this.validationResult.set(null);
this.importResult.set(null);
const reader = new FileReader();
reader.onload = () => {
const content = reader.result as string;
this.fileContent.set(content);
this.detectedFormat.set(this.detectFormat(content));
};
reader.readAsText(file);
}
doValidate(): void {
this.loading.set(true);
const request: PolicyImportRequest = {
content: this.fileContent(),
format: this.detectedFormat() ?? undefined,
validate_only: true,
};
this.policyService.validate({ content: request.content, format: request.format }).subscribe({
next: (result) => {
this.validationResult.set(result);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
doImport(): void {
this.loading.set(true);
const request: PolicyImportRequest = {
content: this.fileContent(),
format: this.detectedFormat() ?? undefined,
validate_only: false,
merge_strategy: this.mergeStrategy,
dry_run: this.dryRun,
};
this.policyService.import(request).subscribe({
next: (result) => {
this.importResult.set(result);
this.loading.set(false);
if (result.success) {
this.imported.emit(result);
}
},
error: () => this.loading.set(false),
});
}
closeDialog(): void {
this.closed.emit();
}
private detectFormat(content: string): 'json' | 'rego' | null {
const trimmed = content.trim();
if (trimmed.startsWith('{') && trimmed.includes('"apiVersion"')) return 'json';
if (trimmed.startsWith('package ') || trimmed.includes('\npackage ')) return 'rego';
return null;
}
}

View File

@@ -0,0 +1,378 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
PolicyPackDocument,
PolicyGateDefinition,
PolicyRuleDefinition,
PolicyGateTypes,
} from '../../core/api/policy-interop.models';
interface GateEditState {
gate: PolicyGateDefinition;
expanded: boolean;
}
@Component({
selector: 'stella-policy-pack-editor',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="editor-container">
<!-- Header -->
<div class="editor-header">
<div class="pack-info" *ngIf="document()">
<h3 class="pack-name">{{ document()!.metadata.name }}</h3>
<span class="pack-version">v{{ document()!.metadata.version }}</span>
</div>
<div class="header-actions">
<select [(ngModel)]="activeEnvironment" class="env-select">
<option value="">Default</option>
<option *ngFor="let env of environments()" [value]="env">{{ env }}</option>
</select>
<button class="btn btn-sm btn-primary" (click)="addGate()">+ Add Gate</button>
</div>
</div>
<!-- Settings -->
<div class="settings-section" *ngIf="document()">
<h4 class="section-title">Settings</h4>
<div class="settings-grid">
<div class="setting-item">
<label>Default action</label>
<select
[(ngModel)]="document()!.spec.settings.default_action"
(ngModelChange)="emitChange()"
class="setting-select"
>
<option value="allow">Allow</option>
<option value="warn">Warn</option>
<option value="block">Block</option>
</select>
</div>
<div class="setting-item">
<label>
<input
type="checkbox"
[ngModel]="document()!.spec.settings.deterministic_mode"
(ngModelChange)="document()!.spec.settings.deterministic_mode = $event; emitChange()"
/>
Deterministic mode
</label>
</div>
</div>
</div>
<!-- Gates -->
<div class="gates-section" *ngIf="gateStates().length">
<h4 class="section-title">
Gates ({{ gateStates().length }})
</h4>
<div class="gates-list">
<div
*ngFor="let state of gateStates(); let i = index"
class="gate-card"
[class.expanded]="state.expanded"
[class.disabled]="!state.gate.enabled"
>
<div class="gate-header" (click)="toggleGate(i)">
<div class="gate-info">
<span class="gate-id">{{ state.gate.id }}</span>
<span class="gate-type-badge">{{ formatGateType(state.gate.type) }}</span>
</div>
<div class="gate-controls">
<label class="enable-toggle" (click)="$event.stopPropagation()">
<input
type="checkbox"
[(ngModel)]="state.gate.enabled"
(ngModelChange)="emitChange()"
/>
</label>
<button
class="btn-icon delete-btn"
(click)="$event.stopPropagation(); removeGate(i)"
title="Remove gate"
>
&times;
</button>
<span class="expand-icon">{{ state.expanded ? '&#9660;' : '&#9654;' }}</span>
</div>
</div>
<div class="gate-body" *ngIf="state.expanded">
<!-- Gate Config -->
<div class="config-section">
<label class="config-label">Configuration</label>
<div class="config-entries">
<div
*ngFor="let key of getConfigKeys(state.gate)"
class="config-entry"
>
<label class="config-key">{{ key }}</label>
<input
class="config-value-input"
[ngModel]="getConfigValue(state.gate, key)"
(ngModelChange)="setConfigValue(state.gate, key, $event)"
/>
</div>
<button class="btn btn-xs btn-outline" (click)="addConfigKey(state.gate)">
+ Add config
</button>
</div>
</div>
<!-- Environment Overrides -->
<div class="env-overrides-section" *ngIf="activeEnvironment">
<label class="config-label">
Environment override: {{ activeEnvironment }}
</label>
<div class="config-entries">
<div
*ngFor="let key of getEnvOverrideKeys(state.gate)"
class="config-entry"
>
<label class="config-key">{{ key }}</label>
<input
class="config-value-input"
[ngModel]="getEnvOverrideValue(state.gate, key)"
(ngModelChange)="setEnvOverrideValue(state.gate, key, $event)"
/>
</div>
<button class="btn btn-xs btn-outline" (click)="addEnvOverrideKey(state.gate)">
+ Add override
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Rules -->
<div class="rules-section" *ngIf="document()?.spec?.rules?.length">
<h4 class="section-title">
Rules ({{ document()!.spec.rules!.length }})
</h4>
<div class="rules-list">
<div *ngFor="let rule of document()!.spec.rules; let i = index" class="rule-card">
<div class="rule-header">
<span class="rule-name">{{ rule.name }}</span>
<span class="rule-action" [class]="'action-' + rule.action">{{ rule.action }}</span>
<button class="btn-icon delete-btn" (click)="removeRule(i)">&times;</button>
</div>
</div>
</div>
</div>
</div>
`,
styles: [
`
.editor-container { display: flex; flex-direction: column; gap: 1.25rem; }
.editor-header { display: flex; align-items: center; justify-content: space-between; }
.pack-info { display: flex; align-items: baseline; gap: 0.5rem; }
.pack-name { margin: 0; font-size: 1rem; font-weight: 600; }
.pack-version { font-size: 0.75rem; color: var(--text-tertiary, #9ca3af); font-family: monospace; }
.header-actions { display: flex; align-items: center; gap: 0.5rem; }
.env-select { font-size: 0.8125rem; padding: 0.25rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color, #d1d5db); }
.section-title { font-size: 0.8125rem; font-weight: 600; margin: 0 0 0.5rem; color: var(--text-primary, #1f2937); }
.settings-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
.setting-item { font-size: 0.8125rem; display: flex; align-items: center; gap: 0.5rem; }
.setting-select { font-size: 0.8125rem; padding: 0.25rem 0.5rem; border-radius: 4px; border: 1px solid var(--border-color, #d1d5db); }
.gates-list { display: flex; flex-direction: column; gap: 0.5rem; }
.gate-card {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s;
}
.gate-card.disabled { opacity: 0.6; }
.gate-card.expanded { border-color: var(--color-primary, #3b82f6); }
.gate-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.75rem;
cursor: pointer;
background: var(--surface-secondary, #f9fafb);
}
.gate-header:hover { background: var(--surface-hover, #f3f4f6); }
.gate-info { display: flex; align-items: center; gap: 0.5rem; }
.gate-id { font-family: monospace; font-size: 0.75rem; font-weight: 600; }
.gate-type-badge { font-size: 0.6875rem; padding: 0.125rem 0.375rem; border-radius: 3px; background: #eff6ff; color: #2563eb; }
.gate-controls { display: flex; align-items: center; gap: 0.5rem; }
.expand-icon { font-size: 0.625rem; color: var(--text-tertiary, #9ca3af); }
.gate-body { padding: 0.75rem; border-top: 1px solid var(--border-light, #f3f4f6); }
.config-section, .env-overrides-section { margin-bottom: 0.75rem; }
.config-label { display: block; font-size: 0.75rem; font-weight: 600; margin-bottom: 0.375rem; color: var(--text-secondary, #6b7280); }
.config-entries { display: flex; flex-direction: column; gap: 0.375rem; }
.config-entry { display: flex; align-items: center; gap: 0.5rem; }
.config-key { font-family: monospace; font-size: 0.75rem; min-width: 120px; color: var(--text-secondary, #6b7280); }
.config-value-input { font-size: 0.8125rem; padding: 0.25rem 0.5rem; border-radius: 3px; border: 1px solid var(--border-color, #d1d5db); flex: 1; }
.enable-toggle input { cursor: pointer; }
.btn-icon { background: none; border: none; cursor: pointer; font-size: 1.125rem; color: var(--text-tertiary, #9ca3af); line-height: 1; }
.delete-btn:hover { color: #dc2626; }
.rules-list { display: flex; flex-direction: column; gap: 0.375rem; }
.rule-card { display: flex; align-items: center; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e5e7eb); border-radius: 4px; }
.rule-header { display: flex; align-items: center; gap: 0.5rem; width: 100%; }
.rule-name { font-size: 0.8125rem; font-weight: 500; flex: 1; }
.rule-action { font-size: 0.6875rem; font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 3px; text-transform: uppercase; }
.action-allow { background: #f0fdf4; color: #16a34a; }
.action-warn { background: #fefce8; color: #ca8a04; }
.action-block { background: #fef2f2; color: #dc2626; }
.btn { padding: 0.375rem 0.75rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
.btn-xs { padding: 0.125rem 0.5rem; font-size: 0.6875rem; }
.btn-primary { background: var(--color-primary, #3b82f6); color: #fff; }
.btn-outline { background: transparent; color: var(--color-primary, #3b82f6); border-color: var(--color-primary, #3b82f6); }
`,
],
})
export class PolicyPackEditorComponent {
@Input() set policyPack(value: PolicyPackDocument | null) {
if (value) {
this._document.set(value);
this._gateStates.set(
(value.spec.gates || []).map((g) => ({ gate: { ...g }, expanded: false }))
);
}
}
@Output() policyChanged = new EventEmitter<PolicyPackDocument>();
activeEnvironment = '';
private _document = signal<PolicyPackDocument | null>(null);
private _gateStates = signal<GateEditState[]>([]);
document = this._document;
gateStates = this._gateStates;
environments = computed<string[]>(() => {
const doc = this._document();
if (!doc) return [];
const envs = new Set<string>();
for (const gate of doc.spec.gates || []) {
if (gate.environments) {
Object.keys(gate.environments).forEach((e) => envs.add(e));
}
}
return Array.from(envs);
});
formatGateType(type: string): string {
return type.replace(/Gate$/, '').replace(/([A-Z])/g, ' $1').trim();
}
toggleGate(index: number): void {
const states = [...this._gateStates()];
states[index] = { ...states[index], expanded: !states[index].expanded };
this._gateStates.set(states);
}
addGate(): void {
const newGate: PolicyGateDefinition = {
id: `gate-${Date.now()}`,
type: PolicyGateTypes.CvssThreshold,
enabled: true,
config: { threshold: 7.0 },
};
const doc = this._document();
if (!doc) return;
doc.spec.gates.push(newGate);
this._gateStates.set([
...this._gateStates(),
{ gate: newGate, expanded: true },
]);
this.emitChange();
}
removeGate(index: number): void {
const doc = this._document();
if (!doc) return;
doc.spec.gates.splice(index, 1);
const states = [...this._gateStates()];
states.splice(index, 1);
this._gateStates.set(states);
this.emitChange();
}
removeRule(index: number): void {
const doc = this._document();
if (!doc?.spec.rules) return;
doc.spec.rules.splice(index, 1);
this.emitChange();
}
getConfigKeys(gate: PolicyGateDefinition): string[] {
return Object.keys(gate.config || {});
}
getConfigValue(gate: PolicyGateDefinition, key: string): string {
return String(gate.config?.[key] ?? '');
}
setConfigValue(gate: PolicyGateDefinition, key: string, value: string): void {
if (!gate.config) gate.config = {};
const num = Number(value);
gate.config[key] = isNaN(num) ? value : num;
this.emitChange();
}
addConfigKey(gate: PolicyGateDefinition): void {
const key = `param_${Object.keys(gate.config || {}).length + 1}`;
if (!gate.config) gate.config = {};
gate.config[key] = '';
this.emitChange();
}
getEnvOverrideKeys(gate: PolicyGateDefinition): string[] {
const env = this.activeEnvironment;
if (!env || !gate.environments?.[env]) return [];
return Object.keys(gate.environments[env]);
}
getEnvOverrideValue(gate: PolicyGateDefinition, key: string): string {
const env = this.activeEnvironment;
return String(gate.environments?.[env]?.[key] ?? '');
}
setEnvOverrideValue(gate: PolicyGateDefinition, key: string, value: string): void {
const env = this.activeEnvironment;
if (!env) return;
if (!gate.environments) gate.environments = {};
if (!gate.environments[env]) gate.environments[env] = {};
const num = Number(value);
gate.environments[env][key] = isNaN(num) ? value : num;
this.emitChange();
}
addEnvOverrideKey(gate: PolicyGateDefinition): void {
const env = this.activeEnvironment;
if (!env) return;
if (!gate.environments) gate.environments = {};
if (!gate.environments[env]) gate.environments[env] = {};
const key = `override_${Object.keys(gate.environments[env]).length + 1}`;
gate.environments[env][key] = '';
this.emitChange();
}
emitChange(): void {
const doc = this._document();
if (doc) {
this.policyChanged.emit({ ...doc });
}
}
}

View File

@@ -0,0 +1,140 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
// Task: TASK-08 - Web UI Components
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RemediationHint } from '../../core/api/policy-interop.models';
@Component({
selector: 'stella-remediation-hint',
standalone: true,
imports: [CommonModule],
template: `
<div class="remediation-hint" [class]="'severity-' + hint.severity">
<div class="hint-header">
<span class="hint-code">{{ hint.code }}</span>
<span class="hint-severity" [class]="'badge-' + hint.severity">
{{ hint.severity }}
</span>
</div>
<div class="hint-title">{{ hint.title }}</div>
<div class="hint-actions" *ngIf="hint.actions?.length">
<div class="action" *ngFor="let action of hint.actions">
<span class="action-type">{{ action.type }}</span>
<span class="action-desc">{{ action.description }}</span>
<code class="action-command" *ngIf="action.command">
{{ action.command }}
</code>
</div>
</div>
</div>
`,
styles: [
`
.remediation-hint {
border-left: 3px solid var(--border-color, #e0e0e0);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
border-radius: 0 4px 4px 0;
background: var(--surface-secondary, #fafafa);
}
.severity-critical {
border-left-color: var(--color-critical, #dc2626);
}
.severity-high {
border-left-color: var(--color-high, #ea580c);
}
.severity-medium {
border-left-color: var(--color-medium, #ca8a04);
}
.severity-low {
border-left-color: var(--color-low, #2563eb);
}
.hint-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hint-code {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
}
.hint-severity {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 2px;
font-weight: 600;
}
.badge-critical {
background: var(--bg-critical, #fef2f2);
color: var(--color-critical, #dc2626);
}
.badge-high {
background: var(--bg-high, #fff7ed);
color: var(--color-high, #ea580c);
}
.badge-medium {
background: var(--bg-medium, #fefce8);
color: var(--color-medium, #ca8a04);
}
.badge-low {
background: var(--bg-low, #eff6ff);
color: var(--color-low, #2563eb);
}
.hint-title {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.hint-actions {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.action {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.action-type {
font-size: 0.75rem;
text-transform: capitalize;
color: var(--text-tertiary, #9ca3af);
}
.action-desc {
font-size: 0.8125rem;
}
.action-command {
display: block;
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
background: var(--surface-code, #1f2937);
color: var(--text-code, #e5e7eb);
border-radius: 3px;
overflow-x: auto;
white-space: nowrap;
}
`,
],
})
export class RemediationHintComponent {
@Input({ required: true }) hint!: RemediationHint;
}

View File

@@ -0,0 +1,230 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { DeltaIfPresent } from '../../../core/api/scoring.models';
/**
* Displays delta-if-present information for missing signals.
*
* Shows which evidence dimensions are missing and how they would
* affect the overall score if they were present. Helps operators
* understand what evidence to collect to improve score confidence.
*
* @example
* <stella-delta-if-present [deltas]="unifiedResult.deltaIfPresent" />
*/
@Component({
selector: 'stella-delta-if-present',
standalone: true,
imports: [CommonModule],
template: `
<div class="delta-container" *ngIf="missingSignals().length > 0">
<div class="delta-header">
<span class="delta-title">Missing Signals</span>
<span class="delta-count">{{ missingSignals().length }} of {{ totalCount() }}</span>
</div>
<div class="delta-list">
<div
class="delta-item"
*ngFor="let item of missingSignals(); trackBy: trackByDimension"
[attr.aria-label]="getItemAriaLabel(item)"
>
<span class="delta-dimension">{{ item.label }}</span>
<span class="delta-bar-container">
<span
class="delta-bar"
[class.positive]="item.delta > 0"
[class.negative]="item.delta < 0"
[style.width]="getBarWidth(item)"
></span>
</span>
<span
class="delta-value"
[class.positive]="item.delta > 0"
[class.negative]="item.delta < 0"
>
{{ formatDelta(item.delta) }}
</span>
</div>
</div>
<div class="delta-summary" *ngIf="totalPotentialDelta() !== 0">
<span class="summary-label">Total potential change:</span>
<span
class="summary-value"
[class.positive]="totalPotentialDelta() > 0"
[class.negative]="totalPotentialDelta() < 0"
>
{{ formatDelta(totalPotentialDelta()) }}
</span>
</div>
</div>
`,
styles: [`
.delta-container {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.delta-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.delta-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6B7280;
}
.delta-count {
font-size: 11px;
color: #9CA3AF;
}
.delta-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.delta-item {
display: grid;
grid-template-columns: 80px 1fr 44px;
gap: 8px;
align-items: center;
font-size: 12px;
}
.delta-dimension {
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delta-bar-container {
height: 6px;
background-color: #E5E7EB;
border-radius: 3px;
overflow: hidden;
}
.delta-bar {
height: 100%;
border-radius: 3px;
transition: width 0.2s ease;
min-width: 2px;
&.positive {
background: linear-gradient(90deg, #10B981, #34D399);
}
&.negative {
background: linear-gradient(90deg, #EF4444, #F87171);
}
}
.delta-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
font-weight: 600;
text-align: right;
&.positive {
color: #059669;
}
&.negative {
color: #DC2626;
}
}
.delta-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 4px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
margin-top: 2px;
}
.summary-label {
font-size: 11px;
color: #6B7280;
}
.summary-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
font-weight: 700;
&.positive {
color: #059669;
}
&.negative {
color: #DC2626;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeltaIfPresentComponent {
/** Delta-if-present entries */
readonly deltas = input.required<DeltaIfPresent[]>();
/** Only show entries where isMissing=true */
readonly missingSignals = computed(() =>
this.deltas().filter(d => d.isMissing)
);
/** Total dimension count */
readonly totalCount = computed(() => this.deltas().length);
/** Sum of all positive deltas */
readonly totalPotentialDelta = computed(() =>
this.missingSignals().reduce((sum, d) => sum + d.delta, 0)
);
/** Max absolute delta for bar scaling */
private readonly maxAbsDelta = computed(() => {
const deltas = this.missingSignals().map(d => Math.abs(d.delta));
return deltas.length > 0 ? Math.max(...deltas) : 1;
});
/** Track by dimension key */
trackByDimension(_index: number, item: DeltaIfPresent): string {
return item.dimension;
}
/** Format delta as signed string */
formatDelta(delta: number): string {
const sign = delta >= 0 ? '+' : '';
return `${sign}${delta.toFixed(1)}`;
}
/** Get bar width as percentage */
getBarWidth(item: DeltaIfPresent): string {
const pct = (Math.abs(item.delta) / this.maxAbsDelta()) * 100;
return `${Math.max(pct, 3)}%`;
}
/** ARIA label for item */
getItemAriaLabel(item: DeltaIfPresent): string {
const direction = item.delta >= 0 ? 'increase' : 'decrease';
return `${item.label}: would ${direction} score by ${Math.abs(item.delta).toFixed(1)} points`;
}
}

View File

@@ -70,6 +70,31 @@ $badge-hard-fail-text: #FFFFFF;
$badge-hard-fail-light: #FEE2E2; // red-100
$badge-hard-fail-border: #B91C1C; // red-700 (for emphasis)
// =============================================================================
// Unknowns Band Colors
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra (TSF-008)
// =============================================================================
// Complete band (U: 0.0-0.2) - High confidence
$band-complete-bg: #059669; // emerald-600
$band-complete-text: #FFFFFF;
$band-complete-light: #D1FAE5; // emerald-100
// Adequate band (U: 0.2-0.4) - Reasonable confidence
$band-adequate-bg: #CA8A04; // yellow-600
$band-adequate-text: #FFFFFF;
$band-adequate-light: #FEF9C3; // yellow-100
// Sparse band (U: 0.4-0.6) - Limited confidence
$band-sparse-bg: #EA580C; // orange-600
$band-sparse-text: #FFFFFF;
$band-sparse-light: #FFEDD5; // orange-100
// Insufficient band (U: 0.6-1.0) - Unreliable
$band-insufficient-bg: #DC2626; // red-600
$band-insufficient-text: #FFFFFF;
$band-insufficient-light: #FEE2E2; // red-100
// =============================================================================
// Dimension Bar Colors
// =============================================================================
@@ -144,6 +169,13 @@ $z-toast: 1200;
--ews-badge-anchored: #{$badge-anchored-bg};
--ews-badge-hard-fail: #{$badge-hard-fail-bg};
// Unknowns band colors
// Sprint: SPRINT_20260122_037 (TSF-008)
--ews-band-complete: #{$band-complete-bg};
--ews-band-adequate: #{$band-adequate-bg};
--ews-band-sparse: #{$band-sparse-bg};
--ews-band-insufficient: #{$band-insufficient-bg};
// Chart colors
--ews-chart-line: #{$chart-line};
--ews-chart-grid: #{$chart-grid};

View File

@@ -7,4 +7,9 @@ export {
PopoverPosition,
} from './score-breakdown-popover.component';
export { ScoreBadgeComponent, ScoreBadgeSize } from './score-badge.component';
export { ScoreHistoryChartComponent } from './score-history-chart.component';
export { ScoreHistoryChartComponent, UnknownsHistoryEntry } from './score-history-chart.component';
// Sprint: SPRINT_20260122_037 (TSF-008)
export { UnknownsBandComponent, UnknownsBandSize } from './unknowns-band.component';
export { DeltaIfPresentComponent } from './delta-if-present.component';
export { UnknownsTooltipComponent } from './unknowns-tooltip.component';

View File

@@ -58,6 +58,32 @@
</div>
</section>
<!-- Unknowns Fraction (U) - Sprint: SPRINT_20260122_037 (TSF-008) -->
@if (hasUnifiedResult()) {
<section class="unknowns-section" aria-label="Unknowns fraction">
<h3 class="section-title">Unknowns (U)</h3>
<div class="unknowns-row">
<stella-unknowns-band
[unknownsFraction]="unifiedResult()!.unknownsFraction"
size="md"
[showLabel]="true"
/>
<span class="unknowns-coverage">
{{ unifiedResult()!.knownDimensions }}/{{ unifiedResult()!.totalDimensions }} signals
</span>
</div>
@if (unifiedResult()!.deltaIfPresent.length > 0) {
<stella-delta-if-present [deltas]="unifiedResult()!.deltaIfPresent" />
}
@if (unifiedResult()!.weightManifestVersion) {
<div class="manifest-info">
<span class="manifest-label">Weights:</span>
<span class="manifest-value">v{{ unifiedResult()!.weightManifestVersion }}</span>
</div>
}
</section>
}
<!-- Flags -->
@if (flags().length > 0) {
<section class="flags-section" aria-label="Score flags">

View File

@@ -564,3 +564,43 @@
}
}
// Sprint: SPRINT_20260122_037 (TSF-008)
// Unknowns fraction (U) section styles
.unknowns-section {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.unknowns-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.unknowns-coverage {
font-size: 12px;
color: #6b7280;
}
.manifest-info {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #f3f4f6;
}
.manifest-label {
font-size: 11px;
color: #9ca3af;
}
.manifest-value {
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #6b7280;
}

View File

@@ -28,7 +28,14 @@ import {
wasShortCircuited,
hasReduction,
getReductionPercent,
// Sprint: SPRINT_20260122_037 (TSF-008)
UnifiedScoreResult,
getBandForUnknowns,
formatUnknownsPercent,
isHighUnknowns,
} from '../../../core/api/scoring.models';
import { UnknownsBandComponent } from './unknowns-band.component';
import { DeltaIfPresentComponent } from './delta-if-present.component';
/**
* Popover position relative to anchor.
@@ -55,7 +62,7 @@ export type PopoverPosition = 'top' | 'bottom' | 'left' | 'right' | 'auto';
@Component({
selector: 'stella-score-breakdown-popover',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, UnknownsBandComponent, DeltaIfPresentComponent],
templateUrl: './score-breakdown-popover.component.html',
styleUrls: ['./score-breakdown-popover.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -70,6 +77,9 @@ export class ScoreBreakdownPopoverComponent {
/** Preferred position (auto will use smart placement) */
readonly preferredPosition = input<PopoverPosition>('auto');
/** Optional unified score result for U metric display */
readonly unifiedResult = input<UnifiedScoreResult | null>(null);
/** Emits when popover should close */
readonly close = output<void>();
@@ -187,6 +197,33 @@ export class ScoreBreakdownPopoverComponent {
};
});
// Sprint: SPRINT_20260122_037 (TSF-008)
// Unknowns fraction (U) computed properties
/** Whether unified result is available */
readonly hasUnifiedResult = computed(() => this.unifiedResult() !== null);
/** U metric band info */
readonly unknownsBandInfo = computed(() => {
const unified = this.unifiedResult();
if (!unified) return null;
return getBandForUnknowns(unified.unknownsFraction);
});
/** Formatted U value */
readonly formattedUnknowns = computed(() => {
const unified = this.unifiedResult();
if (!unified) return '';
return formatUnknownsPercent(unified.unknownsFraction);
});
/** Whether unknowns is high */
readonly isHighUnknowns = computed(() => {
const unified = this.unifiedResult();
if (!unified) return false;
return isHighUnknowns(unified.unknownsFraction);
});
/** Truncate digest for display */
private truncateDigest(digest: string): string {
if (digest.length <= 24) return digest;

View File

@@ -140,6 +140,31 @@
stroke-linejoin="round"
/>
<!-- Unknowns overlay line (TSF-008) -->
@if (showUnknownsOverlay() && unknownsLinePath()) {
<path
[attr.d]="unknownsLinePath()"
class="unknowns-line"
fill="none"
stroke="#EA580C"
stroke-width="1.5"
stroke-dasharray="4 3"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.8"
/>
<!-- Unknowns data point dots -->
@for (point of unknownsDataPoints(); track $index) {
<circle
[attr.cx]="point.x"
[attr.cy]="point.y"
r="3"
[attr.fill]="point.color"
class="unknowns-point"
/>
}
}
<!-- Data points -->
<g class="data-points">
@for (point of dataPoints(); track point.entry.calculatedAt) {

View File

@@ -13,8 +13,20 @@ import {
BUCKET_DISPLAY,
getBucketForScore,
ScoreChangeTrigger,
getBandForUnknowns,
} from '../../../core/api/scoring.models';
/**
* History entry for unknowns fraction over time.
* Sprint: SPRINT_20260122_037 (TSF-008)
*/
export interface UnknownsHistoryEntry {
/** Timestamp */
calculatedAt: string;
/** Unknowns fraction (0.0-1.0) */
unknownsFraction: number;
}
/**
* Date range preset options.
*/
@@ -104,6 +116,18 @@ export class ScoreHistoryChartComponent {
/** Default date range preset */
readonly defaultRange = input<DateRangePreset>('30d');
/**
* Optional unknowns history entries for U overlay.
* Sprint: SPRINT_20260122_037 (TSF-008)
*/
readonly unknownsHistory = input<UnknownsHistoryEntry[]>([]);
/**
* Whether to show the unknowns overlay line.
* Sprint: SPRINT_20260122_037 (TSF-008)
*/
readonly showUnknownsOverlay = input(false);
/** Emits when a data point is clicked */
readonly pointClick = output<ScoreHistoryEntry>();
@@ -272,6 +296,44 @@ export class ScoreHistoryChartComponent {
});
});
// Sprint: SPRINT_20260122_037 (TSF-008) - Unknowns overlay
/** Unknowns overlay data points */
readonly unknownsDataPoints = computed(() => {
if (!this.showUnknownsOverlay() || this.unknownsHistory().length === 0) {
return [];
}
const entries = this.unknownsHistory();
const { min, max } = this.timeRange();
const timeSpan = max - min || 1;
return entries
.filter(e => {
const t = new Date(e.calculatedAt).getTime();
return t >= min && t <= max;
})
.sort((a, b) => new Date(a.calculatedAt).getTime() - new Date(b.calculatedAt).getTime())
.map(entry => {
const time = new Date(entry.calculatedAt).getTime();
const x = this.padding.left + ((time - min) / timeSpan) * this.innerWidth();
// U is 0-1, map to full chart height (0 at top, 1 at bottom)
const y = this.padding.top + entry.unknownsFraction * this.innerHeight();
const band = getBandForUnknowns(entry.unknownsFraction);
return { x, y, unknownsFraction: entry.unknownsFraction, color: band.backgroundColor };
});
});
/** SVG path for the unknowns overlay line */
readonly unknownsLinePath = computed(() => {
const points = this.unknownsDataPoints();
if (points.length === 0) return '';
return points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ');
});
/** Y-axis tick values */
readonly yTicks = computed(() => {
return [0, 25, 50, 75, 100].map((value) => ({

View File

@@ -0,0 +1,140 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
getBandForUnknowns,
formatUnknownsPercent,
isHighUnknowns,
UnknownsBand,
} from '../../../core/api/scoring.models';
/**
* Size variants for the unknowns band indicator.
*/
export type UnknownsBandSize = 'sm' | 'md' | 'lg';
/**
* Compact unknowns fraction display with band-based color coding.
*
* Shows the U metric (unknowns fraction) as a colored indicator.
* Color coding:
* - Complete (0.0-0.2): Green - all critical signals present
* - Adequate (0.2-0.4): Yellow - most signals present
* - Sparse (0.4-0.6): Orange - significant gaps
* - Insufficient (0.6-1.0): Red - score unreliable
*
* @example
* <stella-unknowns-band [unknownsFraction]="0.35" size="md" />
*/
@Component({
selector: 'stella-unknowns-band',
standalone: true,
imports: [CommonModule],
template: `
<span
class="unknowns-band"
[class]="sizeClass()"
[class.high-unknowns]="isHigh()"
[style.backgroundColor]="bandInfo().backgroundColor"
[style.color]="bandInfo().textColor"
[attr.aria-label]="ariaLabel()"
[attr.title]="showTooltip() ? tooltipText() : null"
role="status"
>
<span class="band-value">U:{{ formattedValue() }}</span>
<span class="band-label" *ngIf="showLabel()">{{ bandInfo().label }}</span>
</span>
`,
styles: [`
.unknowns-band {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 4px;
font-weight: 600;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
white-space: nowrap;
vertical-align: middle;
}
.band-sm {
padding: 1px 4px;
font-size: 11px;
line-height: 16px;
}
.band-md {
padding: 2px 6px;
font-size: 12px;
line-height: 18px;
}
.band-lg {
padding: 3px 8px;
font-size: 14px;
line-height: 20px;
}
.band-value {
letter-spacing: -0.02em;
}
.band-label {
font-weight: 500;
opacity: 0.9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.high-unknowns {
animation: pulse-subtle 3s infinite;
}
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UnknownsBandComponent {
/** Unknowns fraction value (0.0 - 1.0) */
readonly unknownsFraction = input.required<number>();
/** Size variant */
readonly size = input<UnknownsBandSize>('md');
/** Whether to show the band label alongside the value */
readonly showLabel = input(false);
/** Whether to show tooltip on hover */
readonly showTooltip = input(true);
/** Computed band information */
readonly bandInfo = computed(() => getBandForUnknowns(this.unknownsFraction()));
/** Formatted percentage value */
readonly formattedValue = computed(() => formatUnknownsPercent(this.unknownsFraction()));
/** Whether the unknowns fraction is high */
readonly isHigh = computed(() => isHighUnknowns(this.unknownsFraction()));
/** CSS class for size */
readonly sizeClass = computed(() => `band-${this.size()}`);
/** Tooltip text */
readonly tooltipText = computed(() => {
const info = this.bandInfo();
return `${info.label}: ${info.description} (U=${this.unknownsFraction().toFixed(2)})`;
});
/** ARIA label */
readonly ariaLabel = computed(() => {
const u = this.unknownsFraction();
const band = this.bandInfo().label;
return `Unknowns fraction ${Math.round(u * 100)} percent, band: ${band}`;
});
}

View File

@@ -0,0 +1,307 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
HostListener,
input,
output,
signal,
} from '@angular/core';
import {
UnifiedScoreResult,
UNKNOWNS_BAND_DISPLAY,
getBandForUnknowns,
formatUnknownsPercent,
} from '../../../core/api/scoring.models';
import { DeltaIfPresentComponent } from './delta-if-present.component';
/**
* Tooltip/popover explaining the unknowns fraction (U) metric.
*
* Shows:
* - Current U value and band classification
* - Band scale with current position highlighted
* - Delta-if-present breakdown
* - Weight manifest version
*
* @example
* <stella-unknowns-tooltip
* [unifiedResult]="result"
* (close)="onClose()"
* />
*/
@Component({
selector: 'stella-unknowns-tooltip',
standalone: true,
imports: [CommonModule, DeltaIfPresentComponent],
template: `
<div class="unknowns-tooltip" role="tooltip" aria-labelledby="tooltip-title">
<!-- Header -->
<div class="tooltip-header">
<span id="tooltip-title" class="tooltip-title">Unknowns Fraction (U)</span>
<button
class="tooltip-close"
(click)="close.emit()"
aria-label="Close tooltip"
>&times;</button>
</div>
<!-- Explanation -->
<p class="tooltip-description">
The unknowns fraction measures how many evidence dimensions lack data.
A lower U means higher confidence in the score.
</p>
<!-- Current value -->
<div class="current-value-row">
<span class="current-label">Current:</span>
<span
class="current-value"
[style.color]="currentBand().backgroundColor"
>
U = {{ currentFormatted() }}
</span>
<span
class="current-band-tag"
[style.backgroundColor]="currentBand().lightBackground"
[style.color]="currentBand().backgroundColor"
>
{{ currentBand().label }}
</span>
</div>
<!-- Coverage info -->
<div class="coverage-row">
<span class="coverage-text">
{{ knownCount() }} of {{ totalCount() }} dimensions have data
</span>
</div>
<!-- Band scale -->
<div class="band-scale" aria-label="Unknowns band scale">
<div
class="band-segment"
*ngFor="let band of bands"
[style.backgroundColor]="band.backgroundColor"
[style.flex]="getSegmentFlex(band)"
[class.active]="band.band === currentBand().band"
[attr.title]="band.label + ': ' + band.description"
>
<span class="segment-label">{{ band.label }}</span>
</div>
<!-- Position indicator -->
<div
class="position-indicator"
[style.left]="positionPercent()"
aria-hidden="true"
></div>
</div>
<div class="band-labels">
<span>0%</span>
<span>100%</span>
</div>
<!-- Delta if present -->
<div class="delta-section" *ngIf="hasMissingSignals()">
<stella-delta-if-present [deltas]="unifiedResult().deltaIfPresent" />
</div>
<!-- Weight manifest -->
<div class="manifest-row" *ngIf="unifiedResult().weightManifestVersion">
<span class="manifest-label">Weight manifest:</span>
<span class="manifest-value">v{{ unifiedResult().weightManifestVersion }}</span>
</div>
</div>
`,
styles: [`
.unknowns-tooltip {
width: 320px;
padding: 12px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
font-size: 13px;
color: #374151;
display: flex;
flex-direction: column;
gap: 10px;
}
.tooltip-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.tooltip-title {
font-size: 13px;
font-weight: 700;
color: #111827;
}
.tooltip-close {
background: none;
border: none;
font-size: 18px;
color: #9CA3AF;
cursor: pointer;
padding: 0 4px;
line-height: 1;
&:hover {
color: #374151;
}
}
.tooltip-description {
font-size: 12px;
color: #6B7280;
margin: 0;
line-height: 1.4;
}
.current-value-row {
display: flex;
align-items: center;
gap: 8px;
}
.current-label {
font-size: 12px;
color: #6B7280;
}
.current-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
font-weight: 700;
}
.current-band-tag {
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
}
.coverage-row {
padding: 0;
}
.coverage-text {
font-size: 11px;
color: #9CA3AF;
}
.band-scale {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.band-segment {
height: 100%;
position: relative;
opacity: 0.6;
transition: opacity 0.15s ease;
&.active {
opacity: 1;
}
}
.segment-label {
display: none;
}
.position-indicator {
position: absolute;
top: -2px;
width: 3px;
height: 12px;
background: #111827;
border-radius: 2px;
transform: translateX(-50%);
box-shadow: 0 0 0 1px #FFFFFF;
}
.band-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #9CA3AF;
margin-top: -4px;
}
.delta-section {
margin-top: 2px;
}
.manifest-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 6px;
border-top: 1px solid #F3F4F6;
}
.manifest-label {
font-size: 11px;
color: #9CA3AF;
}
.manifest-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: #6B7280;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UnknownsTooltipComponent {
/** Unified score result */
readonly unifiedResult = input.required<UnifiedScoreResult>();
/** Emits when tooltip should close */
readonly close = output<void>();
/** Band display data */
readonly bands = UNKNOWNS_BAND_DISPLAY;
/** Current band info */
readonly currentBand = computed(() => getBandForUnknowns(this.unifiedResult().unknownsFraction));
/** Formatted unknowns value */
readonly currentFormatted = computed(() => formatUnknownsPercent(this.unifiedResult().unknownsFraction));
/** Known dimension count */
readonly knownCount = computed(() => this.unifiedResult().knownDimensions);
/** Total dimension count */
readonly totalCount = computed(() => this.unifiedResult().totalDimensions);
/** Whether there are missing signals */
readonly hasMissingSignals = computed(() =>
this.unifiedResult().deltaIfPresent.some(d => d.isMissing)
);
/** Position of current U on the scale (as CSS percentage) */
readonly positionPercent = computed(() =>
`${Math.min(100, Math.max(0, this.unifiedResult().unknownsFraction * 100))}%`
);
/** Get flex value for band segment width */
getSegmentFlex(band: { minU: number; maxU: number }): string {
return `${(band.maxU - band.minU) * 100}`;
}
/** Close on Escape key */
@HostListener('document:keydown.escape')
onEscape(): void {
this.close.emit();
}
}