finish off sprint advisories and sprints
This commit is contained in:
319
src/Web/StellaOps.Web/src/app/core/api/function-map.models.ts
Normal file
319
src/Web/StellaOps.Web/src/app/core/api/function-map.models.ts
Normal 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/*',
|
||||
];
|
||||
210
src/Web/StellaOps.Web/src/app/core/api/policy-interop.models.ts
Normal file
210
src/Web/StellaOps.Web/src/app/core/api/policy-interop.models.ts
Normal 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];
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
← 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>×</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)}%`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 →
|
||||
</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)}%`;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()">×</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();
|
||||
}
|
||||
}
|
||||
@@ -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()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<!-- File Upload -->
|
||||
<div class="upload-section">
|
||||
<label class="upload-label" for="policyFile">
|
||||
<div class="upload-icon">📄</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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span class="expand-icon">{{ state.expanded ? '▼' : '▶' }}</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)">×</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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
>×</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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user