feat: add entropy policy banner and policy gate indicator components
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented EntropyPolicyBannerComponent with configuration for entropy policies, including thresholds, current scores, and mitigation steps.
- Created PolicyGateIndicatorComponent to display the status of policy gates, including passed, failed, and warning gates, with detailed views for determinism and entropy gates.
- Added HTML and SCSS for both components to ensure proper styling and layout.
- Introduced computed properties and signals for reactive state management in Angular.
- Included remediation hints and actions for user interaction within the policy gate indicator.
This commit is contained in:
master
2025-11-27 16:44:29 +02:00
parent e950474a77
commit 4c55b01222
61 changed files with 12747 additions and 52 deletions

View File

@@ -1,6 +1,13 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
@@ -15,26 +22,26 @@ export const routes: Routes = [
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',

View File

@@ -66,9 +66,32 @@ export interface AocViolationDetail {
field?: string;
expected?: string;
actual?: string;
provenance?: {
sourceId: string;
ingestedAt: string;
digest: string;
};
provenance?: AocProvenance;
}
export interface AocProvenance {
sourceId: string;
ingestedAt: string;
digest: string;
sourceType?: 'registry' | 'git' | 'upload' | 'api';
sourceUrl?: string;
submitter?: string;
}
export interface AocViolationGroup {
code: string;
description: string;
severity: 'critical' | 'high' | 'medium' | 'low';
violations: AocViolationDetail[];
affectedDocuments: number;
remediation?: string;
}
export interface AocDocumentView {
documentId: string;
documentType: string;
violations: AocViolationDetail[];
provenance: AocProvenance;
rawContent?: Record<string, unknown>;
highlightedFields: string[];
}

View File

@@ -0,0 +1,77 @@
/**
* Determinism verification models for SBOM scan details.
*/
export interface DeterminismStatus {
/** Overall determinism status */
status: 'verified' | 'warning' | 'failed' | 'unknown';
/** Merkle root from _composition.json */
merkleRoot: string | null;
/** Whether Merkle root matches computed hash */
merkleConsistent: boolean;
/** Fragment hashes with verification status */
fragments: DeterminismFragment[];
/** Composition metadata */
composition: CompositionMeta | null;
/** Timestamp of verification */
verifiedAt: string;
/** Any issues found */
issues: DeterminismIssue[];
}
export interface DeterminismFragment {
/** Fragment identifier (e.g., layer digest) */
id: string;
/** Fragment type */
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
/** Expected hash from composition */
expectedHash: string;
/** Computed hash */
computedHash: string;
/** Whether hashes match */
matches: boolean;
/** Size in bytes */
size: number;
}
export interface CompositionMeta {
/** Composition schema version */
schemaVersion: string;
/** Scanner version that produced this */
scannerVersion: string;
/** Build timestamp */
buildTimestamp: string;
/** Total fragments */
fragmentCount: number;
/** Composition file hash */
compositionHash: string;
}
export interface DeterminismIssue {
/** Issue severity */
severity: 'error' | 'warning' | 'info';
/** Issue code */
code: string;
/** Human-readable message */
message: string;
/** Affected fragment ID if applicable */
fragmentId?: string;
}

View File

@@ -0,0 +1,95 @@
/**
* Entropy analysis models for image security visualization.
*/
export interface EntropyAnalysis {
/** Image digest */
imageDigest: string;
/** Overall entropy score (0-10, higher = more suspicious) */
overallScore: number;
/** Risk level classification */
riskLevel: 'low' | 'medium' | 'high' | 'critical';
/** Per-layer entropy breakdown */
layers: LayerEntropy[];
/** Files with high entropy (potential secrets/malware) */
highEntropyFiles: HighEntropyFile[];
/** Detector hints for suspicious patterns */
detectorHints: DetectorHint[];
/** Analysis timestamp */
analyzedAt: string;
/** Link to raw entropy report */
reportUrl: string;
}
export interface LayerEntropy {
/** Layer digest */
digest: string;
/** Layer command (e.g., COPY, RUN) */
command: string;
/** Layer size in bytes */
size: number;
/** Average entropy for this layer (0-8 bits) */
avgEntropy: number;
/** Percentage of opaque bytes (high entropy) */
opaqueByteRatio: number;
/** Number of high-entropy files */
highEntropyFileCount: number;
/** Risk contribution to overall score */
riskContribution: number;
}
export interface HighEntropyFile {
/** File path in container */
path: string;
/** Layer where file was added */
layerDigest: string;
/** File size in bytes */
size: number;
/** File entropy (0-8 bits) */
entropy: number;
/** Classification */
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
/** Why this file is flagged */
reason: string;
}
export interface DetectorHint {
/** Hint ID */
id: string;
/** Severity */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Pattern type */
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
/** Human-readable description */
description: string;
/** Affected file paths */
affectedPaths: string[];
/** Confidence (0-100) */
confidence: number;
/** Remediation suggestion */
remediation: string;
}

View File

@@ -0,0 +1,205 @@
/**
* Exception management models for the Exception Center.
*/
export type ExceptionStatus = 'draft' | 'pending' | 'approved' | 'active' | 'expired' | 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
export interface Exception {
/** Unique exception ID */
id: string;
/** Short title */
title: string;
/** Detailed justification */
justification: string;
/** Exception type */
type: ExceptionType;
/** Current status */
status: ExceptionStatus;
/** Severity being excepted */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Scope definition */
scope: ExceptionScope;
/** Time constraints */
timebox: ExceptionTimebox;
/** Workflow history */
workflow: ExceptionWorkflow;
/** Audit trail */
auditLog: ExceptionAuditEntry[];
/** Associated findings/violations */
findings: string[];
/** Tags for filtering */
tags: string[];
/** Created timestamp */
createdAt: string;
/** Last updated timestamp */
updatedAt: string;
}
export interface ExceptionScope {
/** Affected images (glob patterns allowed) */
images?: string[];
/** Affected CVEs */
cves?: string[];
/** Affected packages */
packages?: string[];
/** Affected licenses */
licenses?: string[];
/** Affected policy rules */
policyRules?: string[];
/** Tenant scope */
tenantId?: string;
/** Environment scope */
environments?: string[];
}
export interface ExceptionTimebox {
/** Start date */
startsAt: string;
/** Expiration date */
expiresAt: string;
/** Remaining days */
remainingDays: number;
/** Is expired */
isExpired: boolean;
/** Warning threshold (days before expiry) */
warnDays: number;
/** Is in warning period */
isWarning: boolean;
}
export interface ExceptionWorkflow {
/** Current workflow state */
state: ExceptionStatus;
/** Requested by */
requestedBy: string;
/** Requested at */
requestedAt: string;
/** Approved by */
approvedBy?: string;
/** Approved at */
approvedAt?: string;
/** Revoked by */
revokedBy?: string;
/** Revoked at */
revokedAt?: string;
/** Revocation reason */
revocationReason?: string;
/** Required approvers */
requiredApprovers: string[];
/** Current approvals */
approvals: ExceptionApproval[];
}
export interface ExceptionApproval {
/** Approver identity */
approver: string;
/** Decision */
decision: 'approved' | 'rejected';
/** Timestamp */
at: string;
/** Optional comment */
comment?: string;
}
export interface ExceptionAuditEntry {
/** Entry ID */
id: string;
/** Action performed */
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
/** Actor */
actor: string;
/** Timestamp */
at: string;
/** Details */
details?: string;
/** Previous values (for edits) */
previousValues?: Record<string, unknown>;
/** New values (for edits) */
newValues?: Record<string, unknown>;
}
export interface ExceptionFilter {
status?: ExceptionStatus[];
type?: ExceptionType[];
severity?: string[];
search?: string;
tags?: string[];
expiringSoon?: boolean;
createdAfter?: string;
createdBefore?: string;
}
export interface ExceptionSortOption {
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
direction: 'asc' | 'desc';
}
export interface ExceptionTransition {
from: ExceptionStatus;
to: ExceptionStatus;
action: string;
requiresApproval: boolean;
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'active', action: 'Activate', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'active', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'pending', to: 'revoked', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending', label: 'Pending Approval', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'active', label: 'Active', color: '#10b981' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];

View File

@@ -0,0 +1,163 @@
/**
* Policy gate models for release flow indicators.
*/
export interface PolicyGateStatus {
/** Overall gate status */
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
/** Policy evaluation ID */
evaluationId: string;
/** Target artifact (image, SBOM, etc.) */
targetRef: string;
/** Policy set that was evaluated */
policySetId: string;
/** Individual gate results */
gates: PolicyGate[];
/** Blocking issues preventing publish */
blockingIssues: PolicyBlockingIssue[];
/** Warning-level issues */
warnings: PolicyWarning[];
/** Remediation hints for failures */
remediationHints: PolicyRemediationHint[];
/** Evaluation timestamp */
evaluatedAt: string;
/** Can the artifact be published? */
canPublish: boolean;
/** Reason if publish is blocked */
blockReason?: string;
}
export interface PolicyGate {
/** Gate identifier */
gateId: string;
/** Human-readable name */
name: string;
/** Gate type */
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
/** Gate result */
result: 'passed' | 'failed' | 'warning' | 'skipped';
/** Is this gate required for publish? */
required: boolean;
/** Gate-specific details */
details?: Record<string, unknown>;
/** Evidence references */
evidenceRefs?: string[];
}
export interface PolicyBlockingIssue {
/** Issue code */
code: string;
/** Gate that produced this issue */
gateId: string;
/** Issue severity */
severity: 'critical' | 'high';
/** Issue description */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyWarning {
/** Warning code */
code: string;
/** Gate that produced this warning */
gateId: string;
/** Warning message */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyRemediationHint {
/** Which gate/issue this remediates */
forGate: string;
/** Which issue code */
forCode?: string;
/** Hint title */
title: string;
/** Step-by-step instructions */
steps: string[];
/** Documentation link */
docsUrl?: string;
/** CLI command to run */
cliCommand?: string;
/** Estimated effort */
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
}
export interface DeterminismGateDetails {
/** Merkle root consistency */
merkleRootConsistent: boolean;
/** Expected Merkle root */
expectedMerkleRoot?: string;
/** Computed Merkle root */
computedMerkleRoot?: string;
/** Fragment verification results */
fragmentResults: {
fragmentId: string;
expected: string;
computed: string;
match: boolean;
}[];
/** Composition file present */
compositionPresent: boolean;
/** Total fragments */
totalFragments: number;
/** Matching fragments */
matchingFragments: number;
}
export interface EntropyGateDetails {
/** Overall entropy score */
entropyScore: number;
/** Score threshold for warning */
warnThreshold: number;
/** Score threshold for block */
blockThreshold: number;
/** Action taken based on score */
action: 'allow' | 'warn' | 'block';
/** High entropy files count */
highEntropyFileCount: number;
/** Suspicious patterns detected */
suspiciousPatterns: string[];
}

View File

@@ -0,0 +1,179 @@
<div class="verify-action" [class]="'state-' + state()">
<!-- Action Header -->
<div class="action-header">
<div class="action-info">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="action-text">
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
<p class="action-desc">{{ statusLabel() }}</p>
</div>
</div>
<div class="action-buttons">
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
<button class="btn-verify" (click)="runVerification()">
@if (state() === 'idle') {
Run Verification
} @else {
Re-run
}
</button>
}
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
CLI
</button>
</div>
</div>
<!-- Progress Bar -->
@if (state() === 'running') {
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" [style.width]="progress() + '%'"></div>
</div>
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
</div>
}
<!-- Error State -->
@if (state() === 'error' && error()) {
<div class="error-banner">
<span class="error-icon">[X]</span>
<span class="error-message">{{ error() }}</span>
<button class="btn-retry" (click)="runVerification()">Retry</button>
</div>
}
<!-- Results -->
@if (state() === 'completed' && result()) {
<div class="results-section">
<!-- Summary Stats -->
<div class="results-summary">
<div class="stat-card" [class.success]="result()!.status === 'passed'">
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
<span class="stat-label">Documents Checked</span>
</div>
<div class="stat-card success">
<span class="stat-value">{{ result()!.passedCount | number }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat-card" [class.error]="result()!.failedCount > 0">
<span class="stat-value">{{ result()!.failedCount | number }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
<span class="stat-label">Pass Rate</span>
</div>
</div>
<!-- Violations Preview -->
@if (result()!.violations.length > 0) {
<div class="violations-preview">
<h5 class="preview-title">
Violations Found
<span class="violation-count">{{ result()!.violations.length }}</span>
</h5>
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<span class="code-chip">
{{ code }}
<span class="code-count">
{{ result()!.violations.filter(v => v.violationCode === code).length }}
</span>
</span>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
<li class="violation-item">
<button class="violation-btn" (click)="onSelectViolation(v)">
<span class="v-code">{{ v.violationCode }}</span>
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
@if (v.field) {
<span class="v-field">{{ v.field }}</span>
}
</button>
</li>
}
@if (result()!.violations.length > 3) {
<li class="more-violations">
+ {{ result()!.violations.length - 3 }} more violations
</li>
}
</ul>
</div>
} @else {
<div class="no-violations">
<span class="success-icon">[+]</span>
<span>No violations found in the last {{ windowHours() }} hours</span>
</div>
}
<!-- Completion Info -->
<div class="completion-info">
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
</div>
</div>
}
<!-- CLI Guidance Panel -->
@if (showCliGuidance()) {
<div class="cli-guidance">
<h5 class="cli-title">CLI Parity</h5>
<p class="cli-desc">{{ cliGuidance.description }}</p>
<!-- Current Command -->
<div class="cli-command-section">
<label class="cli-label">Equivalent Command</label>
<div class="cli-command">
<code>{{ getCliCommand() }}</code>
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
[C]
</button>
</div>
</div>
<!-- Available Flags -->
<div class="cli-flags-section">
<label class="cli-label">Available Flags</label>
<table class="flags-table">
<tbody>
@for (flag of cliGuidance.flags; track flag.flag) {
<tr>
<td class="flag-name"><code>{{ flag.flag }}</code></td>
<td class="flag-desc">{{ flag.description }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Examples -->
<div class="cli-examples-section">
<label class="cli-label">Examples</label>
<div class="examples-list">
@for (example of cliGuidance.examples; track example) {
<div class="example-item">
<code>{{ example }}</code>
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
[C]
</button>
</div>
}
</div>
</div>
<!-- Install hint -->
<div class="install-hint">
<span class="hint-icon">[i]</span>
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,517 @@
.verify-action {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.state-running {
.action-header {
background: var(--color-info-bg, #f0f9ff);
border-left: 4px solid var(--color-info, #2563eb);
}
.status-icon { color: var(--color-info, #2563eb); }
}
&.state-completed {
.action-header {
background: var(--color-success-bg, #ecfdf5);
border-left: 4px solid var(--color-success, #059669);
}
.status-icon { color: var(--color-success, #059669); }
}
&.state-error {
.action-header {
background: var(--color-error-bg, #fef2f2);
border-left: 4px solid var(--color-error, #dc2626);
}
.status-icon { color: var(--color-error, #dc2626); }
}
}
.action-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-left: 4px solid var(--color-border, #e5e7eb);
flex-wrap: wrap;
}
.action-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-icon {
font-family: monospace;
font-weight: 700;
font-size: 1rem;
color: var(--color-text-muted, #6b7280);
}
.action-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.action-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.action-desc {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-verify {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
.btn-cli {
padding: 0.5rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
font-family: monospace;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
}
// Progress
.progress-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary, #2563eb);
border-radius: 4px;
transition: width 0.2s ease;
}
.progress-text {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
min-width: 40px;
text-align: right;
}
// Error
.error-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-top: 1px solid var(--color-error-border, #fecaca);
}
.error-icon {
font-family: monospace;
font-weight: 700;
color: var(--color-error, #dc2626);
}
.error-message {
flex: 1;
font-size: 0.8125rem;
color: var(--color-error, #dc2626);
}
.btn-retry {
padding: 0.25rem 0.75rem;
background: var(--color-error, #dc2626);
border: none;
border-radius: 4px;
font-size: 0.75rem;
color: white;
cursor: pointer;
&:hover {
background: var(--color-error-dark, #b91c1c);
}
}
// Results
.results-section {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.stat-card {
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 6px;
text-align: center;
&.success {
background: var(--color-success-bg, #ecfdf5);
.stat-value { color: var(--color-success, #059669); }
}
&.error {
background: var(--color-error-bg, #fef2f2);
.stat-value { color: var(--color-error, #dc2626); }
}
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #111827);
}
.stat-label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.violations-preview {
margin-bottom: 1rem;
}
.preview-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.violation-count {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
border-radius: 10px;
font-weight: normal;
}
.code-breakdown {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.code-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
}
.code-count {
font-size: 0.625rem;
padding: 0 0.25rem;
background: var(--color-error, #dc2626);
color: white;
border-radius: 8px;
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
}
.violation-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.v-code {
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-error, #dc2626);
}
.v-doc {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.v-field {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
background: var(--color-warning-bg, #fef3c7);
border-radius: 2px;
color: var(--color-warning-dark, #92400e);
}
.more-violations {
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.no-violations {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--color-success-bg, #ecfdf5);
border-radius: 4px;
font-size: 0.875rem;
color: var(--color-success, #059669);
margin-bottom: 1rem;
}
.success-icon {
font-family: monospace;
font-weight: 700;
}
.completion-info {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.verify-id {
font-family: monospace;
}
// CLI Guidance
.cli-guidance {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.cli-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
}
.cli-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 0 0 1rem;
}
.cli-command-section,
.cli-flags-section,
.cli-examples-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.cli-label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin-bottom: 0.375rem;
}
.cli-command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.8125rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
}
.btn-copy {
padding: 0.25rem 0.375rem;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 3px;
font-family: monospace;
font-size: 0.625rem;
color: #9ca3af;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
}
.flags-table {
width: 100%;
font-size: 0.8125rem;
border-collapse: collapse;
tr {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
}
td {
padding: 0.375rem 0;
}
.flag-name {
width: 140px;
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
.flag-desc {
color: var(--color-text-muted, #6b7280);
}
}
.examples-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.example-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #d1d5db;
white-space: nowrap;
overflow-x: auto;
}
}
.install-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-info, #0284c7);
margin-top: 1rem;
code {
background: var(--color-bg-code, #e0f2fe);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
.hint-icon {
font-family: monospace;
font-weight: 600;
}

View File

@@ -0,0 +1,184 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocVerificationRequest,
AocVerificationResult,
AocViolationDetail,
} from '../../core/api/aoc.models';
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
export interface CliParityGuidance {
command: string;
description: string;
flags: { flag: string; description: string }[];
examples: string[];
}
@Component({
selector: 'app-verify-action',
standalone: true,
imports: [CommonModule],
templateUrl: './verify-action.component.html',
styleUrls: ['./verify-action.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VerifyActionComponent {
private readonly aocClient = inject(AocClient);
/** Tenant ID to verify */
readonly tenantId = input.required<string>();
/** Time window in hours (default 24h) */
readonly windowHours = input(24);
/** Maximum documents to check */
readonly limit = input(10000);
/** Emits when verification completes */
readonly verified = output<AocVerificationResult>();
/** Emits when user clicks on a violation */
readonly selectViolation = output<AocViolationDetail>();
readonly state = signal<VerifyState>('idle');
readonly result = signal<AocVerificationResult | null>(null);
readonly error = signal<string | null>(null);
readonly progress = signal(0);
readonly showCliGuidance = signal(false);
readonly statusIcon = computed(() => {
switch (this.state()) {
case 'idle':
return '[ ]';
case 'running':
return '[~]';
case 'completed':
return this.result()?.status === 'passed' ? '[+]' : '[!]';
case 'error':
return '[X]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.state()) {
case 'idle':
return 'Ready to verify';
case 'running':
return 'Verification in progress...';
case 'completed':
const r = this.result();
if (!r) return 'Completed';
return r.status === 'passed'
? 'Verification passed'
: r.status === 'failed'
? 'Verification failed'
: 'Verification completed with warnings';
case 'error':
return 'Verification error';
default:
return '';
}
});
readonly resultSummary = computed(() => {
const r = this.result();
if (!r) return null;
return {
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
violationCount: r.violations.length,
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
};
});
readonly cliGuidance: CliParityGuidance = {
command: 'stella aoc verify',
description:
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
flags: [
{ flag: '--tenant', description: 'Tenant ID to verify' },
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
{ flag: '--limit', description: 'Maximum documents to check' },
{ flag: '--output', description: 'Output format: json, table, summary' },
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
{ flag: '--verbose', description: 'Show detailed violation information' },
],
examples: [
'stella aoc verify --tenant $TENANT_ID --since 24h',
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
],
};
async runVerification(): Promise<void> {
if (this.state() === 'running') return;
this.state.set('running');
this.error.set(null);
this.result.set(null);
this.progress.set(0);
// Simulate progress updates
const progressInterval = setInterval(() => {
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
}, 200);
const since = new Date();
since.setHours(since.getHours() - this.windowHours());
const request: AocVerificationRequest = {
tenantId: this.tenantId(),
since: since.toISOString(),
limit: this.limit(),
};
this.aocClient.verify(request).subscribe({
next: (result) => {
clearInterval(progressInterval);
this.progress.set(100);
this.result.set(result);
this.state.set('completed');
this.verified.emit(result);
},
error: (err) => {
clearInterval(progressInterval);
this.state.set('error');
this.error.set(err.message || 'Verification failed');
},
});
}
reset(): void {
this.state.set('idle');
this.result.set(null);
this.error.set(null);
this.progress.set(0);
}
toggleCliGuidance(): void {
this.showCliGuidance.update((v) => !v);
}
onSelectViolation(violation: AocViolationDetail): void {
this.selectViolation.emit(violation);
}
copyCommand(command: string): void {
navigator.clipboard.writeText(command);
}
getCliCommand(): string {
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
}
}

View File

@@ -0,0 +1,279 @@
<div class="violation-drilldown">
<!-- Header with Summary -->
<header class="drilldown-header">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{ totalViolations() }}</span>
<span class="stat-label">Violations</span>
</div>
<div class="stat">
<span class="stat-value">{{ totalDocuments() }}</span>
<span class="stat-label">Documents</span>
</div>
<div class="severity-breakdown">
@if (severityCounts().critical > 0) {
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
}
@if (severityCounts().high > 0) {
<span class="severity-chip high">{{ severityCounts().high }} high</span>
}
@if (severityCounts().medium > 0) {
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
}
@if (severityCounts().low > 0) {
<span class="severity-chip low">{{ severityCounts().low }} low</span>
}
</div>
</div>
<div class="controls">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-violation'"
(click)="setViewMode('by-violation')"
>
By Violation
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-document'"
(click)="setViewMode('by-document')"
>
By Document
</button>
</div>
<input
type="search"
class="search-input"
placeholder="Filter violations..."
[value]="searchFilter()"
(input)="onSearch($event)"
/>
</div>
</header>
<!-- By Violation View -->
@if (viewMode() === 'by-violation') {
<div class="violation-list">
@for (group of filteredGroups(); track group.code) {
<div class="violation-group" [class]="'severity-' + group.severity">
<button
class="group-header"
(click)="toggleGroup(group.code)"
[attr.aria-expanded]="expandedCode() === group.code"
>
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
<div class="group-info">
<span class="violation-code">{{ group.code }}</span>
<span class="violation-desc">{{ group.description }}</span>
</div>
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
</button>
@if (expandedCode() === group.code) {
<div class="group-details">
@if (group.remediation) {
<div class="remediation-hint">
<strong>Remediation:</strong> {{ group.remediation }}
</div>
}
<table class="violations-table">
<thead>
<tr>
<th>Document</th>
<th>Field</th>
<th>Expected</th>
<th>Actual</th>
<th>Provenance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of group.violations; track v.documentId + v.field) {
<tr class="violation-row">
<td class="doc-cell">
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
{{ v.documentId | slice:0:20 }}...
</button>
</td>
<td class="field-cell">
@if (v.field) {
<code class="field-path highlighted">{{ v.field }}</code>
} @else {
<span class="no-field">-</span>
}
</td>
<td class="expected-cell">
@if (v.expected) {
<code class="value expected">{{ v.expected }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="actual-cell">
@if (v.actual) {
<code class="value actual error">{{ v.actual }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="provenance-cell">
@if (v.provenance) {
<div class="provenance-info">
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
<span class="source-id" [title]="v.provenance.sourceId">
{{ v.provenance.sourceId | slice:0:15 }}
</span>
<span class="digest" [title]="v.provenance.digest">
{{ formatDigest(v.provenance.digest) }}
</span>
</div>
} @else {
<span class="no-provenance">No provenance</span>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
{ }
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
@if (filteredGroups().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No violations match "{{ searchFilter() }}"</p>
} @else {
<p>No violations to display</p>
}
</div>
}
</div>
}
<!-- By Document View -->
@if (viewMode() === 'by-document') {
<div class="document-list">
@for (doc of filteredDocuments(); track doc.documentId) {
<div class="document-card">
<button
class="doc-header"
(click)="toggleDocument(doc.documentId)"
[attr.aria-expanded]="expandedDocId() === doc.documentId"
>
<span class="doc-type-badge">{{ doc.documentType }}</span>
<span class="doc-id">{{ doc.documentId }}</span>
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
</button>
@if (expandedDocId() === doc.documentId) {
<div class="doc-details">
<!-- Provenance Section -->
<div class="provenance-section">
<h4 class="section-title">Provenance</h4>
<dl class="provenance-grid">
<div class="prov-item">
<dt>Source</dt>
<dd>
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
{{ doc.provenance.sourceId }}
</dd>
</div>
<div class="prov-item">
<dt>Digest</dt>
<dd><code>{{ doc.provenance.digest }}</code></dd>
</div>
<div class="prov-item">
<dt>Ingested</dt>
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
</div>
@if (doc.provenance.submitter) {
<div class="prov-item">
<dt>Submitter</dt>
<dd>{{ doc.provenance.submitter }}</dd>
</div>
}
@if (doc.provenance.sourceUrl) {
<div class="prov-item">
<dt>Source URL</dt>
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
</div>
}
</dl>
</div>
<!-- Violations Section -->
<div class="violations-section">
<h4 class="section-title">Violations</h4>
<ul class="doc-violations-list">
@for (v of doc.violations; track v.violationCode + v.field) {
<li class="doc-violation-item">
<div class="violation-header">
<code class="violation-code">{{ v.violationCode }}</code>
@if (v.field) {
<span class="at-field">at</span>
<code class="field-path highlighted">{{ v.field }}</code>
}
</div>
@if (v.expected || v.actual) {
<div class="value-diff">
<div class="expected-row">
<span class="label">Expected:</span>
<code class="value">{{ v.expected || 'N/A' }}</code>
</div>
<div class="actual-row">
<span class="label">Actual:</span>
<code class="value error">{{ v.actual || 'N/A' }}</code>
</div>
</div>
}
</li>
}
</ul>
</div>
<!-- Raw Content Preview -->
@if (doc.rawContent) {
<div class="raw-content-section">
<h4 class="section-title">
Document Fields
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
</h4>
<div class="field-preview">
@for (field of doc.highlightedFields; track field) {
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
<span class="field-name">{{ field }}</span>
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
@if (filteredDocuments().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No documents match "{{ searchFilter() }}"</p>
} @else {
<p>No documents to display</p>
}
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,585 @@
.violation-drilldown {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
}
.drilldown-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
flex-wrap: wrap;
}
.summary-stats {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text, #111827);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.severity-breakdown {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.severity-chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-weight: 500;
&.critical {
background: var(--color-critical-bg, #fef2f2);
color: var(--color-critical, #dc2626);
}
&.high {
background: var(--color-error-bg, #fff7ed);
color: var(--color-error, #ea580c);
}
&.medium {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning, #d97706);
}
&.low {
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
}
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: none;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
}
&:not(:last-child) {
border-right: 1px solid var(--color-border, #e5e7eb);
}
}
.search-input {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
min-width: 200px;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
// Violation List (By Violation View)
.violation-list {
max-height: 600px;
overflow-y: auto;
}
.violation-group {
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
&.severity-critical {
.group-header { border-left: 3px solid var(--color-critical, #dc2626); }
.severity-icon { color: var(--color-critical, #dc2626); }
}
&.severity-high {
.group-header { border-left: 3px solid var(--color-error, #ea580c); }
.severity-icon { color: var(--color-error, #ea580c); }
}
&.severity-medium {
.group-header { border-left: 3px solid var(--color-warning, #d97706); }
.severity-icon { color: var(--color-warning, #d97706); }
}
&.severity-low {
.group-header { border-left: 3px solid var(--color-info, #0284c7); }
.severity-icon { color: var(--color-info, #0284c7); }
}
}
.group-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.severity-icon {
font-weight: 700;
font-size: 0.875rem;
width: 1.5rem;
text-align: center;
}
.group-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.violation-code {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.violation-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.affected-count {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
white-space: nowrap;
}
.expand-icon {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.group-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.remediation-hint {
font-size: 0.8125rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
color: var(--color-text, #374151);
}
.violations-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
th {
text-align: left;
padding: 0.5rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
border-bottom: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 0.5rem;
vertical-align: top;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
tr:last-child td {
border-bottom: none;
}
}
.doc-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-family: monospace;
font-size: 0.75rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.field-path {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
&.highlighted {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning-dark, #92400e);
}
}
.value {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: var(--color-bg-code, #f3f4f6);
max-width: 150px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.expected {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
&.actual.error {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
}
.no-field,
.no-value,
.no-provenance {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.provenance-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
}
.source-type {
font-family: monospace;
font-weight: 600;
}
.source-id,
.digest {
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.btn-icon {
background: none;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-family: monospace;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
}
}
// Document List (By Document View)
.document-list {
max-height: 600px;
overflow-y: auto;
}
.document-card {
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
}
.doc-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.doc-type-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
font-weight: 500;
}
.doc-id {
flex: 1;
font-family: monospace;
font-size: 0.8125rem;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.violation-count {
font-size: 0.75rem;
color: var(--color-error, #dc2626);
font-weight: 500;
}
.doc-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
text-transform: none;
letter-spacing: normal;
font-weight: normal;
&:hover {
text-decoration: underline;
}
}
.provenance-section,
.violations-section,
.raw-content-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.provenance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
margin: 0;
}
.prov-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
&.url {
font-size: 0.75rem;
word-break: break-all;
}
}
}
.doc-violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.doc-violation-item {
padding: 0.5rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.violation-header {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.at-field {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
}
.value-diff {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 4px;
}
.expected-row,
.actual-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.8125rem;
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
min-width: 60px;
}
}
.actual-row {
margin-top: 0.25rem;
}
.field-preview {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.field-row {
display: flex;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
font-size: 0.8125rem;
&:last-child {
border-bottom: none;
}
&.error {
background: var(--color-error-bg, #fef2f2);
.field-name {
color: var(--color-error, #dc2626);
}
}
}
.field-name {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
min-width: 120px;
}
.field-value {
font-size: 0.75rem;
color: var(--color-text, #374151);
word-break: break-all;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}

View File

@@ -0,0 +1,182 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
AocViolationDetail,
AocViolationGroup,
AocDocumentView,
AocProvenance,
} from '../../core/api/aoc.models';
type ViewMode = 'by-violation' | 'by-document';
@Component({
selector: 'app-violation-drilldown',
standalone: true,
imports: [CommonModule],
templateUrl: './violation-drilldown.component.html',
styleUrls: ['./violation-drilldown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViolationDrilldownComponent {
/** Violation groups to display */
readonly violationGroups = input.required<AocViolationGroup[]>();
/** Document views for by-document mode */
readonly documentViews = input<AocDocumentView[]>([]);
/** Emits when user clicks on a document */
readonly selectDocument = output<string>();
/** Emits when user wants to view raw document */
readonly viewRawDocument = output<string>();
/** Current view mode */
readonly viewMode = signal<ViewMode>('by-violation');
/** Currently expanded violation code */
readonly expandedCode = signal<string | null>(null);
/** Currently expanded document ID */
readonly expandedDocId = signal<string | null>(null);
/** Search filter */
readonly searchFilter = signal('');
readonly filteredGroups = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.violationGroups();
return this.violationGroups().filter(
(g) =>
g.code.toLowerCase().includes(filter) ||
g.description.toLowerCase().includes(filter) ||
g.violations.some(
(v) =>
v.documentId.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly filteredDocuments = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.documentViews();
return this.documentViews().filter(
(d) =>
d.documentId.toLowerCase().includes(filter) ||
d.documentType.toLowerCase().includes(filter) ||
d.violations.some(
(v) =>
v.violationCode.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly totalViolations = computed(() =>
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
);
readonly totalDocuments = computed(() => {
const docIds = new Set<string>();
for (const group of this.violationGroups()) {
for (const v of group.violations) {
docIds.add(v.documentId);
}
}
return docIds.size;
});
readonly severityCounts = computed(() => {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const group of this.violationGroups()) {
counts[group.severity] += group.violations.length;
}
return counts;
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleGroup(code: string): void {
this.expandedCode.update((current) => (current === code ? null : code));
}
toggleDocument(docId: string): void {
this.expandedDocId.update((current) => (current === docId ? null : docId));
}
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchFilter.set(input.value);
}
onSelectDocument(docId: string): void {
this.selectDocument.emit(docId);
}
onViewRaw(docId: string): void {
this.viewRawDocument.emit(docId);
}
getSeverityIcon(severity: string): string {
switch (severity) {
case 'critical':
return '!!';
case 'high':
return '!';
case 'medium':
return '~';
default:
return '-';
}
}
getSourceTypeIcon(sourceType?: string): string {
switch (sourceType) {
case 'registry':
return '[R]';
case 'git':
return '[G]';
case 'upload':
return '[U]';
case 'api':
return '[A]';
default:
return '[?]';
}
}
formatDigest(digest: string, length = 12): string {
if (digest.length <= length) return digest;
return digest.slice(0, length) + '...';
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
return doc.highlightedFields.includes(field);
}
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
if (!content) return 'N/A';
const parts = path.split('.');
let current: unknown = content;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 'N/A';
current = (current as Record<string, unknown>)[part];
}
if (current == null) return 'null';
if (typeof current === 'object') return JSON.stringify(current);
return String(current);
}
}

View File

@@ -0,0 +1,148 @@
<div class="sources-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="verifying()"
(click)="onVerifyLast24h()"
>
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
</button>
<button class="btn btn-secondary" (click)="loadMetrics()">
Refresh
</button>
</div>
</header>
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading AOC metrics...</p>
</div>
}
@if (error()) {
<div class="error-state">
<p class="error-message">{{ error() }}</p>
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
</div>
}
@if (metrics(); as m) {
<div class="metrics-grid">
<!-- Pass/Fail Tile -->
<div class="tile tile-pass-fail" [class]="passRateClass()">
<h2 class="tile-title">AOC Pass/Fail</h2>
<div class="tile-content">
<div class="metric-large">
<span class="value">{{ passRate() }}%</span>
<span class="label">Pass Rate</span>
</div>
<div class="metric-details">
<div class="detail">
<span class="count pass">{{ m.passCount | number }}</span>
<span class="label">Passed</span>
</div>
<div class="detail">
<span class="count fail">{{ m.failCount | number }}</span>
<span class="label">Failed</span>
</div>
<div class="detail">
<span class="count total">{{ m.totalCount | number }}</span>
<span class="label">Total</span>
</div>
</div>
</div>
</div>
<!-- Recent Violations Tile -->
<div class="tile tile-violations">
<h2 class="tile-title">Recent Violations</h2>
<div class="tile-content">
@if (m.recentViolations.length === 0) {
<p class="empty-state">No violations in time window</p>
} @else {
<ul class="violations-list">
@for (v of m.recentViolations; track v.code) {
<li class="violation-item" [class]="getSeverityClass(v.severity)">
<div class="violation-header">
<code class="violation-code">{{ v.code }}</code>
<span class="violation-count">{{ v.count }}x</span>
</div>
<p class="violation-desc">{{ v.description }}</p>
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Ingest Throughput Tile -->
<div class="tile tile-throughput" [class]="throughputStatus()">
<h2 class="tile-title">Ingest Throughput</h2>
<div class="tile-content">
<div class="throughput-grid">
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
<span class="label">docs/min</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
<span class="label">avg ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
<span class="label">p95 ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
<span class="label">queue</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
<span class="label">errors</span>
</div>
</div>
</div>
</div>
</div>
<!-- Verification Result -->
@if (verificationResult(); as result) {
<div class="verification-result" [class]="'status-' + result.status">
<h3>Verification Complete</h3>
<div class="result-summary">
<span class="status-badge">{{ result.status | titlecase }}</span>
<span>Checked: {{ result.checkedCount | number }}</span>
<span>Passed: {{ result.passedCount | number }}</span>
<span>Failed: {{ result.failedCount | number }}</span>
</div>
@if (result.violations.length > 0) {
<details class="violations-details">
<summary>View {{ result.violations.length }} violation(s)</summary>
<ul class="violation-list">
@for (v of result.violations; track v.documentId) {
<li>
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
@if (v.field) {
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
}
</li>
}
</ul>
</details>
}
<p class="cli-hint">
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
</p>
</div>
}
<p class="time-window">
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
</p>
}
</div>

View File

@@ -0,0 +1,325 @@
.sources-dashboard {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.5rem;
}
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.2s, border-color 0.2s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&-primary {
background-color: var(--color-primary, #2563eb);
color: white;
&:hover:not(:disabled) {
background-color: var(--color-primary-hover, #1d4ed8);
}
}
&-secondary {
background-color: transparent;
border-color: var(--color-border, #d1d5db);
color: var(--color-text, #374151);
&:hover:not(:disabled) {
background-color: var(--color-bg-hover, #f3f4f6);
}
}
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem;
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #2563eb);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
color: var(--color-error, #dc2626);
margin-bottom: 1rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.tile {
background: var(--color-bg-card, white);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
overflow: hidden;
&-title {
font-size: 0.875rem;
font-weight: 600;
padding: 0.75rem 1rem;
margin: 0;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
&-content {
padding: 1rem;
}
}
.tile-pass-fail {
&.excellent .metric-large .value { color: var(--color-success, #059669); }
&.good .metric-large .value { color: var(--color-success-muted, #10b981); }
&.warning .metric-large .value { color: var(--color-warning, #d97706); }
&.critical .metric-large .value { color: var(--color-error, #dc2626); }
}
.metric-large {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1rem;
.value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
}
.metric-details {
display: flex;
justify-content: space-around;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
.detail {
display: flex;
flex-direction: column;
align-items: center;
}
.count {
font-size: 1.25rem;
font-weight: 600;
&.pass { color: var(--color-success, #059669); }
&.fail { color: var(--color-error, #dc2626); }
&.total { color: var(--color-text, #374151); }
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
&.severity-critical { border-left: 3px solid var(--color-error, #dc2626); }
&.severity-high { border-left: 3px solid var(--color-warning, #d97706); }
&.severity-medium { border-left: 3px solid var(--color-info, #2563eb); }
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
}
.violation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.violation-code {
font-family: monospace;
font-size: 0.8125rem;
font-weight: 600;
}
.violation-count {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.violation-desc {
font-size: 0.8125rem;
margin: 0 0 0.25rem;
color: var(--color-text, #374151);
}
.violation-time {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.empty-state {
color: var(--color-text-muted, #6b7280);
font-style: italic;
text-align: center;
padding: 1rem;
}
.throughput-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 1rem;
text-align: center;
}
.throughput-item {
display: flex;
flex-direction: column;
.value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
}
}
.tile-throughput {
&.critical .throughput-item .value { color: var(--color-error, #dc2626); }
&.warning .throughput-item .value { color: var(--color-warning, #d97706); }
}
.verification-result {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
&.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); }
&.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); }
&.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); }
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.result-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
&.status-passed .status-badge { background: var(--color-success, #059669); color: white; }
&.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; }
&.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; }
}
.violations-details {
margin: 0.75rem 0;
summary {
cursor: pointer;
color: var(--color-primary, #2563eb);
font-size: 0.875rem;
}
.violation-list {
margin-top: 0.5rem;
padding-left: 1.25rem;
font-size: 0.8125rem;
li {
margin-bottom: 0.5rem;
}
}
}
.cli-hint {
margin: 0.75rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
code {
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.6875rem;
}
}
.time-window {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-align: center;
}

View File

@@ -0,0 +1,302 @@
<div class="exception-center">
<!-- Header -->
<header class="center-header">
<div class="header-left">
<h2 class="center-title">Exception Center</h2>
<div class="status-chips">
@for (col of kanbanColumns; track col.status) {
<span
class="status-chip"
[style.borderColor]="col.color"
[class.active]="filter().status?.includes(col.status)"
(click)="updateFilter('status', filter().status?.includes(col.status)
? filter().status?.filter(s => s !== col.status)
: [...(filter().status || []), col.status])"
>
{{ col.label }}
<span class="chip-count">{{ statusCounts()[col.status] || 0 }}</span>
</span>
}
</div>
</div>
<div class="header-right">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'list'"
(click)="setViewMode('list')"
title="List view"
>
=
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'kanban'"
(click)="setViewMode('kanban')"
title="Kanban view"
>
#
</button>
</div>
<button class="btn-filter" (click)="toggleFilters()" [class.active]="showFilters()">
Filters
</button>
<button class="btn-create" (click)="onCreate()">
+ New Exception
</button>
</div>
</header>
<!-- Filters Panel -->
@if (showFilters()) {
<div class="filters-panel">
<div class="filter-row">
<div class="filter-group">
<label class="filter-label">Search</label>
<input
type="search"
class="filter-input"
placeholder="Search exceptions..."
[value]="filter().search || ''"
(input)="updateFilter('search', $any($event.target).value)"
/>
</div>
<div class="filter-group">
<label class="filter-label">Type</label>
<div class="filter-chips">
@for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) {
<button
class="filter-chip"
[class.active]="filter().type?.includes($any(type))"
(click)="updateFilter('type', filter().type?.includes($any(type))
? filter().type?.filter(t => t !== type)
: [...(filter().type || []), type])"
>
{{ type | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Severity</label>
<div class="filter-chips">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="filter-chip"
[class]="'sev-' + sev"
[class.active]="filter().severity?.includes(sev)"
(click)="updateFilter('severity', filter().severity?.includes(sev)
? filter().severity?.filter(s => s !== sev)
: [...(filter().severity || []), sev])"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Tags</label>
<div class="filter-chips tags">
@for (tag of allTags().slice(0, 8); track tag) {
<button
class="filter-chip tag"
[class.active]="filter().tags?.includes(tag)"
(click)="updateFilter('tags', filter().tags?.includes(tag)
? filter().tags?.filter(t => t !== tag)
: [...(filter().tags || []), tag])"
>
{{ tag }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="filter().expiringSoon"
(change)="updateFilter('expiringSoon', $any($event.target).checked)"
/>
Expiring soon
</label>
</div>
</div>
<button class="btn-clear-filters" (click)="clearFilters()">Clear filters</button>
</div>
}
<!-- List View -->
@if (viewMode() === 'list') {
<div class="list-view">
<!-- Sort Header -->
<div class="list-header">
<button class="sort-btn" (click)="setSort('title')">
Title
@if (sort().field === 'title') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('severity')">
Severity
@if (sort().field === 'severity') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Status</span>
<button class="sort-btn" (click)="setSort('expiresAt')">
Expires
@if (sort().field === 'expiresAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('updatedAt')">
Updated
@if (sort().field === 'updatedAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Actions</span>
</div>
<!-- Exception Rows -->
<div class="list-body">
@for (exc of filteredExceptions(); track exc.id) {
<div class="exception-row" [class]="'status-' + exc.status">
<button class="row-main" (click)="onSelect(exc)">
<div class="exc-title-cell">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<div class="exc-title-info">
<span class="exc-title">{{ exc.title }}</span>
<span class="exc-id">{{ exc.id }}</span>
</div>
</div>
<div class="exc-severity-cell">
<span class="severity-badge" [class]="getSeverityClass(exc.severity)">
{{ exc.severity | titlecase }}
</span>
</div>
<div class="exc-status-cell">
<span class="status-badge" [class]="'status-' + exc.status">
{{ getStatusIcon(exc.status) }} {{ exc.status | titlecase }}
</span>
</div>
<div class="exc-expires-cell">
<span
class="expires-text"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
<div class="exc-updated-cell">
{{ formatDate(exc.updatedAt) }}
</div>
</button>
<div class="row-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="action-btn"
[title]="trans.action"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
<button class="action-btn audit" (click)="onViewAudit(exc)" title="View audit log">
[A]
</button>
</div>
</div>
}
@if (filteredExceptions().length === 0) {
<div class="empty-state">
<p>No exceptions match the current filters</p>
<button class="btn-link" (click)="clearFilters()">Clear filters</button>
</div>
}
</div>
</div>
}
<!-- Kanban View -->
@if (viewMode() === 'kanban') {
<div class="kanban-view">
@for (col of kanbanColumns; track col.status) {
<div class="kanban-column">
<div class="column-header" [style.borderColor]="col.color">
<span class="column-title">{{ col.label }}</span>
<span class="column-count">{{ exceptionsByStatus().get(col.status)?.length || 0 }}</span>
</div>
<div class="column-body">
@for (exc of exceptionsByStatus().get(col.status) || []; track exc.id) {
<div class="kanban-card" [class]="getSeverityClass(exc.severity)">
<button class="card-main" (click)="onSelect(exc)">
<div class="card-header">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<span class="severity-dot" [class]="getSeverityClass(exc.severity)"></span>
</div>
<h4 class="card-title">{{ exc.title }}</h4>
<p class="card-id">{{ exc.id }}</p>
<div class="card-meta">
<span
class="expires-badge"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
@if (exc.tags.length > 0) {
<div class="card-tags">
@for (tag of exc.tags.slice(0, 3); track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
}
</button>
<div class="card-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="card-action-btn"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
</div>
</div>
}
@if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) {
<div class="column-empty">No exceptions</div>
}
</div>
</div>
}
</div>
}
<!-- Footer Stats -->
<footer class="center-footer">
<span class="total-count">
{{ filteredExceptions().length }} of {{ exceptions().length }} exceptions
</span>
</footer>
</div>

View File

@@ -0,0 +1,636 @@
.exception-center {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg, #f9fafb);
}
// Header
.center-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-card, white);
border-bottom: 1px solid var(--color-border, #e5e7eb);
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.center-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #111827);
}
.status-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.status-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid;
border-radius: 4px;
font-size: 0.6875rem;
cursor: pointer;
background: var(--color-bg-card, white);
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-bg-subtle, #f3f4f6);
font-weight: 600;
}
}
.chip-count {
font-size: 0.625rem;
padding: 0 0.25rem;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 8px;
}
.header-right {
display: flex;
gap: 0.5rem;
align-items: center;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.625rem;
background: var(--color-bg-card, white);
border: none;
font-family: monospace;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
}
&:not(:last-child) {
border-right: 1px solid var(--color-border, #e5e7eb);
}
}
.btn-filter {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary-bg, #eff6ff);
border-color: var(--color-primary, #2563eb);
color: var(--color-primary, #2563eb);
}
}
.btn-create {
padding: 0.375rem 1rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
// Filters Panel
.filters-panel {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.filter-row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.filter-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
}
.filter-input {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
min-width: 200px;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
.filter-chips {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
&.tags {
max-width: 300px;
}
}
.filter-chip {
padding: 0.25rem 0.5rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
&.sev-critical.active { background: var(--color-critical, #dc2626); border-color: var(--color-critical, #dc2626); }
&.sev-high.active { background: var(--color-error, #ea580c); border-color: var(--color-error, #ea580c); }
&.sev-medium.active { background: var(--color-warning, #d97706); border-color: var(--color-warning, #d97706); }
&.sev-low.active { background: var(--color-info, #0284c7); border-color: var(--color-info, #0284c7); }
&.tag {
font-size: 0.6875rem;
}
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
cursor: pointer;
}
.btn-clear-filters {
margin-top: 0.75rem;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
// List View
.list-view {
flex: 1;
overflow: auto;
background: var(--color-bg-card, white);
}
.list-header {
display: grid;
grid-template-columns: 2fr 100px 120px 100px 100px 150px;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
position: sticky;
top: 0;
z-index: 1;
}
.sort-btn {
background: none;
border: none;
padding: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
gap: 0.25rem;
&:hover {
color: var(--color-text, #374151);
}
}
.sort-icon {
font-size: 0.5rem;
}
.col-header {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
padding: 0.25rem;
}
.list-body {
display: flex;
flex-direction: column;
}
.exception-row {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
&.status-draft { border-left: 3px solid #9ca3af; }
&.status-pending { border-left: 3px solid #f59e0b; }
&.status-approved { border-left: 3px solid #3b82f6; }
&.status-active { border-left: 3px solid #10b981; }
&.status-expired { border-left: 3px solid #6b7280; }
&.status-revoked { border-left: 3px solid #ef4444; }
}
.row-main {
display: grid;
grid-template-columns: 2fr 100px 120px 100px 100px;
gap: 0.5rem;
flex: 1;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.exc-title-cell {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.type-badge {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
flex-shrink: 0;
}
.exc-title-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.exc-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exc-id {
font-size: 0.6875rem;
font-family: monospace;
color: var(--color-text-muted, #9ca3af);
}
.severity-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 600;
&.severity-critical { background: #fef2f2; color: #dc2626; }
&.severity-high { background: #fff7ed; color: #ea580c; }
&.severity-medium { background: #fffbeb; color: #d97706; }
&.severity-low { background: #f0f9ff; color: #0284c7; }
}
.status-badge {
font-size: 0.75rem;
font-family: monospace;
&.status-draft { color: #6b7280; }
&.status-pending { color: #d97706; }
&.status-approved { color: #2563eb; }
&.status-active { color: #059669; }
&.status-expired { color: #6b7280; }
&.status-revoked { color: #dc2626; }
}
.expires-text {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
&.warning { color: var(--color-warning, #d97706); font-weight: 500; }
&.expired { color: var(--color-error, #dc2626); font-weight: 500; }
}
.exc-updated-cell {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
}
.row-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 3px;
font-size: 0.6875rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #e5e7eb);
}
&.audit {
font-family: monospace;
}
}
.empty-state {
padding: 3rem;
text-align: center;
color: var(--color-text-muted, #9ca3af);
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
// Kanban View
.kanban-view {
display: flex;
gap: 1rem;
padding: 1rem;
flex: 1;
overflow-x: auto;
}
.kanban-column {
flex: 0 0 280px;
display: flex;
flex-direction: column;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 8px;
max-height: 100%;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 3px solid;
background: var(--color-bg-card, white);
border-radius: 8px 8px 0 0;
}
.column-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.column-count {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 10px;
color: var(--color-text-muted, #6b7280);
}
.column-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kanban-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
&.severity-critical { border-left: 3px solid #dc2626; }
&.severity-high { border-left: 3px solid #ea580c; }
&.severity-medium { border-left: 3px solid #d97706; }
&.severity-low { border-left: 3px solid #0284c7; }
}
.card-main {
display: block;
width: 100%;
padding: 0.75rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.severity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.severity-critical { background: #dc2626; }
&.severity-high { background: #ea580c; }
&.severity-medium { background: #d97706; }
&.severity-low { background: #0284c7; }
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text, #111827);
}
.card-id {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-family: monospace;
color: var(--color-text-muted, #9ca3af);
}
.card-meta {
margin-bottom: 0.5rem;
}
.expires-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
color: var(--color-text-muted, #6b7280);
&.warning {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
&.expired {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
}
.card-tags {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 2px;
color: var(--color-text-muted, #6b7280);
}
.card-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.card-action-btn {
flex: 1;
padding: 0.25rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 3px;
font-size: 0.625rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.column-empty {
padding: 1rem;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
// Footer
.center-footer {
padding: 0.5rem 1rem;
background: var(--color-bg-card, white);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.total-count {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}

View File

@@ -0,0 +1,246 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionStatus,
ExceptionType,
ExceptionFilter,
ExceptionSortOption,
ExceptionTransition,
EXCEPTION_TRANSITIONS,
KANBAN_COLUMNS,
} from '../../core/api/exception.models';
type ViewMode = 'list' | 'kanban';
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionCenterComponent {
/** All exceptions */
readonly exceptions = input.required<Exception[]>();
/** Current user role for transition permissions */
readonly userRole = input<string>('user');
/** Emits when creating new exception */
readonly create = output<void>();
/** Emits when selecting an exception */
readonly select = output<Exception>();
/** Emits when performing a workflow transition */
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
/** Emits when viewing audit log */
readonly viewAudit = output<Exception>();
readonly viewMode = signal<ViewMode>('list');
readonly filter = signal<ExceptionFilter>({});
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
readonly expandedId = signal<string | null>(null);
readonly showFilters = signal(false);
readonly kanbanColumns = KANBAN_COLUMNS;
readonly filteredExceptions = computed(() => {
let result = [...this.exceptions()];
const f = this.filter();
// Apply filters
if (f.status && f.status.length > 0) {
result = result.filter((e) => f.status!.includes(e.status));
}
if (f.type && f.type.length > 0) {
result = result.filter((e) => f.type!.includes(e.type));
}
if (f.severity && f.severity.length > 0) {
result = result.filter((e) => f.severity!.includes(e.severity));
}
if (f.search) {
const search = f.search.toLowerCase();
result = result.filter(
(e) =>
e.title.toLowerCase().includes(search) ||
e.justification.toLowerCase().includes(search) ||
e.id.toLowerCase().includes(search)
);
}
if (f.tags && f.tags.length > 0) {
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
}
if (f.expiringSoon) {
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
}
// Apply sort
const s = this.sort();
result.sort((a, b) => {
let cmp = 0;
switch (s.field) {
case 'createdAt':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case 'updatedAt':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
case 'expiresAt':
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
break;
case 'severity':
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
cmp = sevOrder[a.severity] - sevOrder[b.severity];
break;
case 'title':
cmp = a.title.localeCompare(b.title);
break;
}
return s.direction === 'asc' ? cmp : -cmp;
});
return result;
});
readonly exceptionsByStatus = computed(() => {
const byStatus = new Map<ExceptionStatus, Exception[]>();
for (const col of KANBAN_COLUMNS) {
byStatus.set(col.status, []);
}
for (const exc of this.filteredExceptions()) {
const list = byStatus.get(exc.status) || [];
list.push(exc);
byStatus.set(exc.status, list);
}
return byStatus;
});
readonly statusCounts = computed(() => {
const counts: Record<string, number> = {};
for (const exc of this.exceptions()) {
counts[exc.status] = (counts[exc.status] || 0) + 1;
}
return counts;
});
readonly allTags = computed(() => {
const tags = new Set<string>();
for (const exc of this.exceptions()) {
for (const tag of exc.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
this.filter.update((f) => ({ ...f, [key]: value }));
}
clearFilters(): void {
this.filter.set({});
}
setSort(field: ExceptionSortOption['field']): void {
this.sort.update((s) => ({
field,
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
}));
}
toggleExpand(id: string): void {
this.expandedId.update((current) => (current === id ? null : id));
}
onCreate(): void {
this.create.emit();
}
onSelect(exc: Exception): void {
this.select.emit(exc);
}
onTransition(exc: Exception, to: ExceptionStatus): void {
this.transition.emit({ exception: exc, to });
}
onViewAudit(exc: Exception): void {
this.viewAudit.emit(exc);
}
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
return EXCEPTION_TRANSITIONS.filter(
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
);
}
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending':
return '[?]';
case 'approved':
return '[+]';
case 'active':
return '[*]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
}
}
getTypeIcon(type: ExceptionType): string {
switch (type) {
case 'vulnerability':
return 'V';
case 'license':
return 'L';
case 'policy':
return 'P';
case 'entropy':
return 'E';
case 'determinism':
return 'D';
default:
return '?';
}
}
getSeverityClass(severity: string): string {
return 'severity-' + severity;
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatRemainingDays(days: number): string {
if (days < 0) return 'Expired';
if (days === 0) return 'Expires today';
if (days === 1) return '1 day left';
return days + ' days left';
}
}

View File

@@ -0,0 +1,406 @@
<div class="exception-wizard">
<!-- Progress Steps -->
<div class="wizard-progress">
@for (step of steps; track step; let i = $index) {
<button
class="progress-step"
[class.active]="currentStep() === step"
[class.completed]="i < currentStepIndex()"
[class.disabled]="i > currentStepIndex()"
(click)="goToStep(step)"
[disabled]="i > currentStepIndex()"
>
<span class="step-number">{{ i + 1 }}</span>
<span class="step-label">{{ step | titlecase }}</span>
</button>
@if (i < steps.length - 1) {
<div class="step-connector" [class.completed]="i < currentStepIndex()"></div>
}
}
</div>
<!-- Step Content -->
<div class="wizard-content">
<!-- Step 1: Type Selection -->
@if (currentStep() === 'type') {
<div class="step-panel">
<h3 class="step-title">What type of exception do you need?</h3>
<p class="step-desc">Select the category that best matches your exception request.</p>
<div class="type-grid">
@for (type of exceptionTypes; track type.type) {
<button
class="type-card"
[class.selected]="draft().type === type.type"
(click)="selectType(type.type)"
>
<span class="type-icon">{{ type.icon }}</span>
<div class="type-info">
<span class="type-label">{{ type.label }}</span>
<span class="type-desc">{{ type.description }}</span>
</div>
@if (draft().type === type.type) {
<span class="selected-check">[+]</span>
}
</button>
}
</div>
</div>
}
<!-- Step 2: Scope Definition -->
@if (currentStep() === 'scope') {
<div class="step-panel">
<h3 class="step-title">Define the exception scope</h3>
<p class="step-desc">Specify what this exception applies to. Be as specific as possible.</p>
<div class="scope-form">
@if (draft().type === 'vulnerability') {
<div class="scope-field">
<label class="field-label">CVEs</label>
<textarea
class="field-textarea"
placeholder="Enter CVE IDs, one per line (e.g., CVE-2024-1234)"
[value]="draft().scope.cves?.join('\n') || ''"
(input)="updateScope('cves', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
<div class="scope-field">
<label class="field-label">Packages (optional)</label>
<textarea
class="field-textarea"
placeholder="Package names to scope (e.g., lodash, express)"
[value]="draft().scope.packages?.join('\n') || ''"
(input)="updateScope('packages', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'license') {
<div class="scope-field">
<label class="field-label">Licenses</label>
<textarea
class="field-textarea"
placeholder="License identifiers (e.g., GPL-3.0, AGPL-3.0)"
[value]="draft().scope.licenses?.join('\n') || ''"
(input)="updateScope('licenses', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'policy') {
<div class="scope-field">
<label class="field-label">Policy Rules</label>
<textarea
class="field-textarea"
placeholder="Policy rule IDs (e.g., SEC-001, COMP-002)"
[value]="draft().scope.policyRules?.join('\n') || ''"
(input)="updateScope('policyRules', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
<div class="scope-field">
<label class="field-label">Images (optional - limits scope to specific images)</label>
<textarea
class="field-textarea"
placeholder="Image references (e.g., myregistry/myimage:*, myregistry/app:v1.0)"
[value]="draft().scope.images?.join('\n') || ''"
(input)="updateScope('images', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
<span class="field-hint">Use * for wildcards. Leave empty to apply to all images.</span>
</div>
<div class="scope-field">
<label class="field-label">Environments (optional)</label>
<div class="env-chips">
@for (env of ['development', 'staging', 'production']; track env) {
<button
class="env-chip"
[class.selected]="draft().scope.environments?.includes(env)"
(click)="updateScope('environments',
draft().scope.environments?.includes(env)
? draft().scope.environments?.filter(e => e !== env)
: [...(draft().scope.environments || []), env])"
>
{{ env | titlecase }}
</button>
}
</div>
</div>
@if (scopePreview().length > 0) {
<div class="scope-preview">
<span class="preview-label">Scope preview:</span>
<span class="preview-text">{{ scopePreview().join(', ') }}</span>
</div>
}
</div>
</div>
}
<!-- Step 3: Justification -->
@if (currentStep() === 'justification') {
<div class="step-panel">
<h3 class="step-title">Provide justification</h3>
<p class="step-desc">Explain why this exception is needed. Use a template or write your own.</p>
<div class="justification-form">
<div class="form-field">
<label class="field-label">Title</label>
<input
type="text"
class="field-input"
placeholder="Brief descriptive title for this exception"
[value]="draft().title"
(input)="updateDraft('title', $any($event.target).value)"
/>
</div>
<div class="form-field">
<label class="field-label">Severity</label>
<div class="severity-options">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="severity-btn"
[class]="'sev-' + sev"
[class.selected]="draft().severity === sev"
(click)="updateDraft('severity', $any(sev))"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
@if (applicableTemplates().length > 0) {
<div class="form-field">
<label class="field-label">Templates</label>
<div class="template-list">
@for (tpl of applicableTemplates(); track tpl.id) {
<button
class="template-btn"
[class.selected]="selectedTemplate() === tpl.id"
(click)="selectTemplate(tpl.id)"
>
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-desc">{{ tpl.description }}</span>
</button>
}
</div>
</div>
}
<div class="form-field">
<label class="field-label">
Justification
<span class="char-count">{{ draft().justification.length }} chars (min 20)</span>
</label>
<textarea
class="field-textarea large"
placeholder="Provide detailed justification for this exception..."
[value]="draft().justification"
(input)="updateDraft('justification', $any($event.target).value)"
></textarea>
</div>
<div class="form-field">
<label class="field-label">Tags (optional)</label>
<div class="tags-input">
<div class="current-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">
{{ tag }}
<button class="tag-remove" (click)="removeTag(tag)">x</button>
</span>
}
</div>
<input
type="text"
class="tag-input"
placeholder="Add tag..."
[value]="newTag()"
(input)="onTagInput($event)"
(keydown.enter)="addTag(); $event.preventDefault()"
/>
</div>
</div>
</div>
</div>
}
<!-- Step 4: Timebox -->
@if (currentStep() === 'timebox') {
<div class="step-panel">
<h3 class="step-title">Set exception duration</h3>
<p class="step-desc">
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
</p>
<div class="timebox-form">
<div class="timebox-presets">
@for (preset of timeboxPresets; track preset.days) {
<button
class="preset-btn"
[class.selected]="draft().expiresInDays === preset.days"
[disabled]="preset.days > maxDurationDays()"
(click)="selectTimebox(preset.days)"
>
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-desc">{{ preset.description }}</span>
</button>
}
</div>
<div class="custom-duration">
<label class="field-label">Or set custom duration (days)</label>
<input
type="number"
class="field-input duration-input"
min="1"
[max]="maxDurationDays()"
[value]="draft().expiresInDays"
(input)="updateDraft('expiresInDays', +$any($event.target).value)"
/>
</div>
<div class="timebox-preview">
<div class="preview-row">
<span class="preview-label">Expires on:</span>
<span class="preview-value">{{ formatDate(expirationDate()) }}</span>
</div>
<div class="preview-row">
<span class="preview-label">Duration:</span>
<span class="preview-value">{{ draft().expiresInDays }} days</span>
</div>
</div>
@if (timeboxWarning()) {
<div class="timebox-warning">
<span class="warning-icon">[!]</span>
<span>{{ timeboxWarning() }}</span>
</div>
}
</div>
</div>
}
<!-- Step 5: Review -->
@if (currentStep() === 'review') {
<div class="step-panel">
<h3 class="step-title">Review and submit</h3>
<p class="step-desc">Please review your exception request before submitting.</p>
<div class="review-summary">
<div class="review-section">
<h4 class="section-title">Type & Severity</h4>
<div class="review-row">
<span class="review-label">Type:</span>
<span class="review-value">{{ draft().type | titlecase }}</span>
</div>
<div class="review-row">
<span class="review-label">Severity:</span>
<span class="review-value severity-badge" [class]="'sev-' + draft().severity">
{{ draft().severity | titlecase }}
</span>
</div>
</div>
<div class="review-section">
<h4 class="section-title">Scope</h4>
@if (draft().scope.cves?.length) {
<div class="review-row">
<span class="review-label">CVEs:</span>
<span class="review-value">{{ draft().scope.cves?.join(', ') }}</span>
</div>
}
@if (draft().scope.packages?.length) {
<div class="review-row">
<span class="review-label">Packages:</span>
<span class="review-value">{{ draft().scope.packages?.join(', ') }}</span>
</div>
}
@if (draft().scope.licenses?.length) {
<div class="review-row">
<span class="review-label">Licenses:</span>
<span class="review-value">{{ draft().scope.licenses?.join(', ') }}</span>
</div>
}
@if (draft().scope.policyRules?.length) {
<div class="review-row">
<span class="review-label">Policy Rules:</span>
<span class="review-value">{{ draft().scope.policyRules?.join(', ') }}</span>
</div>
}
@if (draft().scope.images?.length) {
<div class="review-row">
<span class="review-label">Images:</span>
<span class="review-value">{{ draft().scope.images?.join(', ') }}</span>
</div>
}
@if (draft().scope.environments?.length) {
<div class="review-row">
<span class="review-label">Environments:</span>
<span class="review-value">{{ draft().scope.environments?.join(', ') }}</span>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Details</h4>
<div class="review-row">
<span class="review-label">Title:</span>
<span class="review-value">{{ draft().title }}</span>
</div>
<div class="review-row full">
<span class="review-label">Justification:</span>
<p class="review-justification">{{ draft().justification }}</p>
</div>
@if (draft().tags.length > 0) {
<div class="review-row">
<span class="review-label">Tags:</span>
<div class="review-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Timebox</h4>
<div class="review-row">
<span class="review-label">Duration:</span>
<span class="review-value">{{ draft().expiresInDays }} days</span>
</div>
<div class="review-row">
<span class="review-label">Expires:</span>
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Footer Actions -->
<div class="wizard-footer">
<button class="btn-cancel" (click)="onCancel()">Cancel</button>
<div class="footer-right">
@if (canGoBack()) {
<button class="btn-back" (click)="goBack()">Back</button>
}
@if (currentStep() !== 'review') {
<button class="btn-next" [disabled]="!canGoNext()" (click)="goNext()">
Next
</button>
} @else {
<button class="btn-submit" [disabled]="!canGoNext()" (click)="onSubmit()">
Submit Exception
</button>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,652 @@
.exception-wizard {
display: flex;
flex-direction: column;
height: 100%;
max-width: 800px;
margin: 0 auto;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
// Progress Steps
.wizard-progress {
display: flex;
align-items: center;
padding: 1.5rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
&:disabled {
cursor: not-allowed;
}
&.active .step-number {
background: var(--color-primary, #2563eb);
color: white;
}
&.completed .step-number {
background: var(--color-success, #059669);
color: white;
}
&.disabled {
.step-number { background: var(--color-bg-subtle, #e5e7eb); }
.step-label { color: var(--color-text-muted, #9ca3af); }
}
}
.step-number {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-border, #e5e7eb);
border-radius: 50%;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
}
.step-label {
font-size: 0.6875rem;
color: var(--color-text, #374151);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.step-connector {
flex: 1;
height: 2px;
background: var(--color-border, #e5e7eb);
margin: 0 0.5rem;
&.completed {
background: var(--color-success, #059669);
}
}
// Content
.wizard-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.step-panel {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.step-title {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.step-desc {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--color-text-muted, #6b7280);
}
// Type Selection
.type-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.type-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-card, white);
border: 2px solid var(--color-border, #e5e7eb);
border-radius: 8px;
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--color-primary-light, #93c5fd);
}
&.selected {
border-color: var(--color-primary, #2563eb);
background: var(--color-primary-bg, #eff6ff);
}
}
.type-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 8px;
font-family: monospace;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-muted, #6b7280);
.selected & {
background: var(--color-primary, #2563eb);
color: white;
}
}
.type-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.type-label {
font-weight: 600;
color: var(--color-text, #374151);
}
.type-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.selected-check {
font-family: monospace;
font-weight: 700;
color: var(--color-primary, #2563eb);
}
// Scope Form
.scope-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.scope-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.field-textarea {
padding: 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
font-family: monospace;
min-height: 80px;
resize: vertical;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
&.large {
min-height: 200px;
font-family: inherit;
}
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
}
.env-chips {
display: flex;
gap: 0.5rem;
}
.env-chip {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.selected {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
}
.scope-preview {
padding: 0.75rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-size: 0.8125rem;
}
.preview-label {
color: var(--color-text-muted, #6b7280);
}
.preview-text {
color: var(--color-text, #374151);
font-weight: 500;
}
// Justification Form
.justification-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
.severity-options {
display: flex;
gap: 0.5rem;
}
.severity-btn {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.selected {
color: white;
&.sev-critical { background: #dc2626; border-color: #dc2626; }
&.sev-high { background: #ea580c; border-color: #ea580c; }
&.sev-medium { background: #d97706; border-color: #d97706; }
&.sev-low { background: #0284c7; border-color: #0284c7; }
}
}
.template-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.template-btn {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
&.selected {
border-color: var(--color-primary, #2563eb);
background: var(--color-primary-bg, #eff6ff);
}
}
.tpl-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text, #374151);
}
.tpl-desc {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.char-count {
font-weight: normal;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
margin-left: 0.5rem;
}
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
min-height: 44px;
}
.current-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-text, #374151);
}
.tag-remove {
background: none;
border: none;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, #9ca3af);
padding: 0;
&:hover {
color: var(--color-error, #dc2626);
}
}
.tag-input {
flex: 1;
min-width: 100px;
border: none;
outline: none;
font-size: 0.875rem;
}
// Timebox Form
.timebox-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.timebox-presets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.preset-btn {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.75rem;
background: var(--color-bg-card, white);
border: 2px solid var(--color-border, #e5e7eb);
border-radius: 6px;
cursor: pointer;
text-align: left;
&:hover:not(:disabled) {
border-color: var(--color-primary-light, #93c5fd);
}
&.selected {
border-color: var(--color-primary, #2563eb);
background: var(--color-primary-bg, #eff6ff);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.preset-label {
font-weight: 600;
font-size: 0.9375rem;
color: var(--color-text, #374151);
}
.preset-desc {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.custom-duration {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.duration-input {
max-width: 120px;
}
.timebox-preview {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 6px;
}
.preview-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.preview-label {
color: var(--color-text-muted, #6b7280);
}
.preview-value {
font-weight: 500;
color: var(--color-text, #374151);
}
.timebox-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-warning-bg, #fef3c7);
border-radius: 4px;
font-size: 0.875rem;
color: var(--color-warning-dark, #92400e);
}
.warning-icon {
font-family: monospace;
font-weight: 700;
}
// Review
.review-summary {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.review-section {
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.75rem;
}
.review-row {
display: flex;
gap: 1rem;
padding: 0.25rem 0;
&.full {
flex-direction: column;
gap: 0.25rem;
}
}
.review-label {
min-width: 100px;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.review-value {
font-size: 0.8125rem;
color: var(--color-text, #374151);
&.severity-badge {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
&.sev-critical { background: #fef2f2; color: #dc2626; }
&.sev-high { background: #fff7ed; color: #ea580c; }
&.sev-medium { background: #fffbeb; color: #d97706; }
&.sev-low { background: #f0f9ff; color: #0284c7; }
}
}
.review-justification {
margin: 0;
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 4px;
font-size: 0.8125rem;
white-space: pre-wrap;
}
.review-tags {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
// Footer
.wizard-footer {
display: flex;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.footer-right {
display: flex;
gap: 0.5rem;
}
.btn-cancel {
padding: 0.5rem 1rem;
background: none;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-back {
padding: 0.5rem 1rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-next,
.btn-submit {
padding: 0.5rem 1.5rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
color: white;
&:hover:not(:disabled) {
background: var(--color-primary-dark, #1d4ed8);
}
&:disabled {
background: var(--color-text-muted, #9ca3af);
cursor: not-allowed;
}
}
.btn-submit {
background: var(--color-success, #059669);
&:hover:not(:disabled) {
background: var(--color-success-dark, #047857);
}
}

View File

@@ -0,0 +1,296 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionType,
ExceptionScope,
} from '../../core/api/exception.models';
type WizardStep = 'type' | 'scope' | 'justification' | 'timebox' | 'review';
export interface JustificationTemplate {
id: string;
name: string;
description: string;
template: string;
type: ExceptionType[];
}
export interface TimeboxPreset {
label: string;
days: number;
description: string;
}
export interface ExceptionDraft {
type: ExceptionType | null;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
justification: string;
scope: Partial<ExceptionScope>;
expiresInDays: number;
tags: string[];
}
@Component({
selector: 'app-exception-wizard',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-wizard.component.html',
styleUrls: ['./exception-wizard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionWizardComponent {
/** Pre-selected type (e.g., from vulnerability view) */
readonly preselectedType = input<ExceptionType>();
/** Pre-filled scope (e.g., specific CVE) */
readonly prefilledScope = input<Partial<ExceptionScope>>();
/** Available justification templates */
readonly templates = input<JustificationTemplate[]>(this.defaultTemplates);
/** Maximum allowed exception duration in days */
readonly maxDurationDays = input(90);
/** Emits when wizard is cancelled */
readonly cancel = output<void>();
/** Emits when exception is created */
readonly create = output<ExceptionDraft>();
readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review'];
readonly currentStep = signal<WizardStep>('type');
readonly draft = signal<ExceptionDraft>({
type: null,
severity: 'medium',
title: '',
justification: '',
scope: {},
expiresInDays: 30,
tags: [],
});
readonly scopePreview = signal<string[]>([]);
readonly selectedTemplate = signal<string | null>(null);
readonly newTag = signal('');
readonly timeboxPresets: TimeboxPreset[] = [
{ label: '7 days', days: 7, description: 'Short-term exception for urgent fixes' },
{ label: '14 days', days: 14, description: 'Sprint-length exception' },
{ label: '30 days', days: 30, description: 'Standard exception duration' },
{ label: '60 days', days: 60, description: 'Extended exception for complex remediation' },
{ label: '90 days', days: 90, description: 'Maximum allowed duration' },
];
readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [
{ type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' },
{ type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' },
{ type: 'policy', label: 'Policy', icon: 'P', description: 'Exception for policy rule violations' },
{ type: 'entropy', label: 'Entropy', icon: 'E', description: 'Exception for high entropy findings' },
{ type: 'determinism', label: 'Determinism', icon: 'D', description: 'Exception for determinism check failures' },
];
readonly defaultTemplates: JustificationTemplate[] = [
{
id: 'false-positive',
name: 'False Positive',
description: 'The finding is a false positive and does not represent a real risk',
template: 'This finding has been determined to be a false positive because:\n\n[Explain why this is a false positive]\n\nEvidence:\n- [Evidence 1]\n- [Evidence 2]',
type: ['vulnerability', 'entropy', 'license'],
},
{
id: 'mitigated',
name: 'Mitigating Controls',
description: 'Risk is mitigated by other security controls',
template: 'The risk associated with this finding is mitigated by the following controls:\n\n1. [Control 1]\n2. [Control 2]\n\nResidual risk assessment: [Low/Medium]',
type: ['vulnerability', 'policy'],
},
{
id: 'planned-fix',
name: 'Planned Remediation',
description: 'Fix is planned but requires time to implement',
template: 'Remediation is planned with the following timeline:\n\nPlanned fix date: [Date]\nAssigned to: [Team/Person]\nTracking ticket: [Ticket ID]\n\nReason for delay:\n[Explain why immediate fix is not possible]',
type: ['vulnerability', 'license', 'policy', 'entropy', 'determinism'],
},
{
id: 'business-need',
name: 'Business Requirement',
description: 'Required for critical business functionality',
template: 'This exception is required for the following business reason:\n\n[Explain business requirement]\n\nImpact if not granted:\n- [Impact 1]\n- [Impact 2]\n\nApproved by: [Business Owner]',
type: ['license', 'policy'],
},
];
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
readonly canGoNext = computed(() => {
const step = this.currentStep();
const d = this.draft();
switch (step) {
case 'type':
return d.type !== null;
case 'scope':
return this.hasValidScope();
case 'justification':
return d.title.trim().length > 0 && d.justification.trim().length > 20;
case 'timebox':
return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays();
case 'review':
return true;
default:
return false;
}
});
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
readonly applicableTemplates = computed(() => {
const type = this.draft().type;
if (!type) return [];
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
});
readonly expirationDate = computed(() => {
const days = this.draft().expiresInDays;
const date = new Date();
date.setDate(date.getDate() + days);
return date;
});
readonly timeboxWarning = computed(() => {
const days = this.draft().expiresInDays;
if (days > 60) return 'Extended exceptions require additional justification';
if (days > 30) return 'Consider if a shorter duration is sufficient';
return null;
});
ngOnInit(): void {
// Apply preselected values
if (this.preselectedType()) {
this.updateDraft('type', this.preselectedType()!);
this.currentStep.set('scope');
}
if (this.prefilledScope()) {
this.updateDraft('scope', this.prefilledScope()!);
}
}
private hasValidScope(): boolean {
const scope = this.draft().scope;
return !!(
(scope.cves && scope.cves.length > 0) ||
(scope.packages && scope.packages.length > 0) ||
(scope.images && scope.images.length > 0) ||
(scope.licenses && scope.licenses.length > 0) ||
(scope.policyRules && scope.policyRules.length > 0)
);
}
updateDraft<K extends keyof ExceptionDraft>(key: K, value: ExceptionDraft[K]): void {
this.draft.update((d) => ({ ...d, [key]: value }));
}
updateScope<K extends keyof ExceptionScope>(key: K, value: ExceptionScope[K]): void {
this.draft.update((d) => ({
...d,
scope: { ...d.scope, [key]: value },
}));
this.updateScopePreview();
}
private updateScopePreview(): void {
const scope = this.draft().scope;
const preview: string[] = [];
if (scope.cves?.length) preview.push(`${scope.cves.length} CVE(s)`);
if (scope.packages?.length) preview.push(`${scope.packages.length} package(s)`);
if (scope.images?.length) preview.push(`${scope.images.length} image(s)`);
if (scope.licenses?.length) preview.push(`${scope.licenses.length} license(s)`);
if (scope.policyRules?.length) preview.push(`${scope.policyRules.length} rule(s)`);
this.scopePreview.set(preview);
}
selectType(type: ExceptionType): void {
this.updateDraft('type', type);
}
selectTemplate(templateId: string): void {
const template = this.applicableTemplates().find((t) => t.id === templateId);
if (template) {
this.selectedTemplate.set(templateId);
this.updateDraft('justification', template.template);
}
}
selectTimebox(days: number): void {
this.updateDraft('expiresInDays', days);
}
addTag(): void {
const tag = this.newTag().trim();
if (tag && !this.draft().tags.includes(tag)) {
this.updateDraft('tags', [...this.draft().tags, tag]);
this.newTag.set('');
}
}
removeTag(tag: string): void {
this.updateDraft('tags', this.draft().tags.filter((t) => t !== tag));
}
goNext(): void {
if (!this.canGoNext()) return;
const idx = this.currentStepIndex();
if (idx < this.steps.length - 1) {
this.currentStep.set(this.steps[idx + 1]);
}
}
goBack(): void {
if (!this.canGoBack()) return;
const idx = this.currentStepIndex();
if (idx > 0) {
this.currentStep.set(this.steps[idx - 1]);
}
}
goToStep(step: WizardStep): void {
const targetIdx = this.steps.indexOf(step);
if (targetIdx <= this.currentStepIndex()) {
this.currentStep.set(step);
}
}
onCancel(): void {
this.cancel.emit();
}
onSubmit(): void {
if (this.canGoNext()) {
this.create.emit(this.draft());
}
}
formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
onTagInput(event: Event): void {
this.newTag.set((event.target as HTMLInputElement).value);
}
}

View File

@@ -0,0 +1,130 @@
<div class="determinism-badge" [class]="'status-' + status().status">
<!-- Compact Badge -->
<button
class="badge-trigger"
(click)="toggleExpanded()"
[attr.aria-expanded]="isExpanded()"
aria-controls="determinism-details"
>
<span class="badge-icon">{{ statusIcon() }}</span>
<span class="badge-label">{{ statusLabel() }}</span>
<span class="badge-stats">
{{ fragmentStats().matched }}/{{ fragmentStats().total }} fragments
</span>
<span class="badge-expand-icon" [class.expanded]="isExpanded()"></span>
</button>
<!-- Expanded Details -->
@if (isExpanded()) {
<div id="determinism-details" class="badge-details" role="region" aria-label="Determinism details">
<!-- Merkle Root Section -->
<div class="detail-section">
<h4 class="section-title">Merkle Root</h4>
<div class="merkle-info">
@if (status().merkleRoot) {
<code class="hash" [title]="status().merkleRoot">
{{ formatHash(status().merkleRoot!, 24) }}
</code>
<span class="consistency-badge" [class.consistent]="status().merkleConsistent">
{{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }}
</span>
} @else {
<span class="no-data">No Merkle root available</span>
}
</div>
</div>
<!-- Composition Metadata -->
@if (status().composition; as comp) {
<div class="detail-section">
<h4 class="section-title">Composition</h4>
<dl class="composition-meta">
<div class="meta-item">
<dt>Schema</dt>
<dd>{{ comp.schemaVersion }}</dd>
</div>
<div class="meta-item">
<dt>Scanner</dt>
<dd>{{ comp.scannerVersion }}</dd>
</div>
<div class="meta-item">
<dt>Built</dt>
<dd>{{ comp.buildTimestamp | date:'short' }}</dd>
</div>
<div class="meta-item">
<dt>Hash</dt>
<dd><code [title]="comp.compositionHash">{{ formatHash(comp.compositionHash) }}</code></dd>
</div>
</dl>
<button class="btn-link" (click)="onViewComposition()">
View _composition.json →
</button>
</div>
}
<!-- Fragment Hashes -->
<div class="detail-section">
<h4 class="section-title">
Fragment Hashes
<span class="fragment-count">
({{ fragmentStats().percentage | number:'1.0-0' }}% match)
</span>
</h4>
<div class="fragments-list">
@for (fragment of status().fragments; track fragment.id) {
<div class="fragment-item" [class.mismatch]="!fragment.matches">
<span class="fragment-icon">{{ getFragmentIcon(fragment) }}</span>
<div class="fragment-info">
<span class="fragment-id" [title]="fragment.id">
{{ fragment.type }}: {{ formatHash(fragment.id, 16) }}
</span>
<span class="fragment-size">{{ formatBytes(fragment.size) }}</span>
</div>
<div class="fragment-hashes">
<code class="hash expected" [title]="fragment.expectedHash">
E: {{ formatHash(fragment.expectedHash) }}
</code>
<code class="hash computed" [title]="fragment.computedHash">
C: {{ formatHash(fragment.computedHash) }}
</code>
</div>
</div>
}
</div>
</div>
<!-- Issues -->
@if (status().issues.length > 0) {
<div class="detail-section issues-section">
<h4 class="section-title">
Issues
@if (issuesByLevel().errors.length > 0) {
<span class="issue-count error">{{ issuesByLevel().errors.length }} errors</span>
}
@if (issuesByLevel().warnings.length > 0) {
<span class="issue-count warning">{{ issuesByLevel().warnings.length }} warnings</span>
}
</h4>
<ul class="issues-list">
@for (issue of status().issues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-icon">{{ getIssueIcon(issue) }}</span>
<div class="issue-content">
<code class="issue-code">{{ issue.code }}</code>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.fragmentId) {
<span class="issue-fragment">Fragment: {{ formatHash(issue.fragmentId) }}</span>
}
</div>
</li>
}
</ul>
</div>
}
<p class="verified-at">
Verified {{ status().verifiedAt | date:'medium' }}
</p>
</div>
}
</div>

View File

@@ -0,0 +1,322 @@
.determinism-badge {
font-size: 0.875rem;
border-radius: 6px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-bg-card, white);
overflow: hidden;
&.status-verified {
.badge-trigger { border-left: 3px solid var(--color-success, #059669); }
.badge-icon { color: var(--color-success, #059669); }
}
&.status-warning {
.badge-trigger { border-left: 3px solid var(--color-warning, #d97706); }
.badge-icon { color: var(--color-warning, #d97706); }
}
&.status-failed {
.badge-trigger { border-left: 3px solid var(--color-error, #dc2626); }
.badge-icon { color: var(--color-error, #dc2626); }
}
&.status-unknown {
.badge-trigger { border-left: 3px solid var(--color-text-muted, #9ca3af); }
.badge-icon { color: var(--color-text-muted, #9ca3af); }
}
}
.badge-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
&:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
}
.badge-icon {
font-size: 1rem;
font-weight: bold;
}
.badge-label {
font-weight: 600;
color: var(--color-text, #374151);
}
.badge-stats {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
margin-left: auto;
}
.badge-expand-icon {
color: var(--color-text-muted, #9ca3af);
font-size: 0.625rem;
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.badge-details {
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
}
.detail-section {
margin-bottom: 1rem;
&:last-of-type {
margin-bottom: 0.5rem;
}
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.fragment-count {
font-weight: normal;
text-transform: none;
}
.merkle-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hash {
font-family: monospace;
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: var(--color-text, #374151);
}
.consistency-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-weight: 600;
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
&.consistent {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
}
.no-data {
font-style: italic;
color: var(--color-text-muted, #9ca3af);
}
.composition-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin: 0 0 0.5rem;
}
.meta-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
}
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.fragments-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 200px;
overflow-y: auto;
}
.fragment-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.375rem;
border-radius: 4px;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
&.mismatch {
border-color: var(--color-error, #dc2626);
background: var(--color-error-bg, #fef2f2);
}
}
.fragment-icon {
font-size: 0.75rem;
font-weight: bold;
color: var(--color-success, #059669);
.mismatch & {
color: var(--color-error, #dc2626);
}
}
.fragment-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.fragment-id {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text, #374151);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fragment-size {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.fragment-hashes {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.hash.expected {
opacity: 0.7;
}
.hash.computed {
.mismatch & {
color: var(--color-error, #dc2626);
}
}
.issues-section {
.section-title {
flex-wrap: wrap;
}
}
.issue-count {
font-size: 0.625rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-weight: normal;
text-transform: none;
&.error {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
&.warning {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning, #d97706);
}
}
.issues-list {
list-style: none;
padding: 0;
margin: 0;
}
.issue-item {
display: flex;
gap: 0.375rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
&.severity-error .issue-icon { color: var(--color-error, #dc2626); }
&.severity-warning .issue-icon { color: var(--color-warning, #d97706); }
&.severity-info .issue-icon { color: var(--color-info, #2563eb); }
}
.issue-icon {
font-size: 0.75rem;
}
.issue-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.issue-code {
font-family: monospace;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.issue-message {
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.issue-fragment {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.verified-at {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin: 0;
text-align: right;
}

View File

@@ -0,0 +1,118 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
DeterminismStatus,
DeterminismFragment,
DeterminismIssue,
} from '../../core/api/determinism.models';
@Component({
selector: 'app-determinism-badge',
standalone: true,
imports: [CommonModule],
templateUrl: './determinism-badge.component.html',
styleUrls: ['./determinism-badge.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeterminismBadgeComponent {
/** Determinism status data */
readonly status = input.required<DeterminismStatus>();
/** Whether to show expanded details by default */
readonly expanded = input(false);
/** Emits when user clicks to view full composition */
readonly viewComposition = output<void>();
/** Local expanded state */
readonly isExpanded = signal(false);
readonly statusIcon = computed(() => {
switch (this.status().status) {
case 'verified':
return '✓';
case 'warning':
return '⚠';
case 'failed':
return '✗';
default:
return '?';
}
});
readonly statusLabel = computed(() => {
switch (this.status().status) {
case 'verified':
return 'Deterministic';
case 'warning':
return 'Partial';
case 'failed':
return 'Non-deterministic';
default:
return 'Unknown';
}
});
readonly fragmentStats = computed(() => {
const fragments = this.status().fragments;
const matched = fragments.filter((f) => f.matches).length;
const total = fragments.length;
return { matched, total, percentage: total > 0 ? (matched / total) * 100 : 0 };
});
readonly issuesByLevel = computed(() => {
const issues = this.status().issues;
return {
errors: issues.filter((i) => i.severity === 'error'),
warnings: issues.filter((i) => i.severity === 'warning'),
info: issues.filter((i) => i.severity === 'info'),
};
});
constructor() {
// Initialize expanded state from input
this.isExpanded.set(this.expanded());
}
toggleExpanded(): void {
this.isExpanded.update((v) => !v);
}
onViewComposition(): void {
this.viewComposition.emit();
}
formatHash(hash: string, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.substring(0, length) + '...';
}
formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
getFragmentIcon(fragment: DeterminismFragment): string {
return fragment.matches ? '✓' : '✗';
}
getIssueIcon(issue: DeterminismIssue): string {
switch (issue.severity) {
case 'error':
return '✗';
case 'warning':
return '⚠';
default:
return '';
}
}
}

View File

@@ -0,0 +1,160 @@
<div class="entropy-panel" [class]="riskClass()">
<!-- Header with Overall Score -->
<header class="panel-header">
<div class="score-section">
<div class="score-ring" [attr.data-score]="analysis().overallScore">
<svg viewBox="0 0 100 100" class="score-svg">
<circle class="score-bg" cx="50" cy="50" r="45" />
<circle
class="score-fill"
cx="50"
cy="50"
r="45"
[style.strokeDasharray]="(analysis().overallScore / 10 * 283) + ' 283'"
/>
</svg>
<span class="score-value">{{ analysis().overallScore | number:'1.1-1' }}</span>
</div>
<div class="score-info">
<h3 class="risk-label">{{ analysis().riskLevel | titlecase }} Risk</h3>
<p class="score-desc">{{ scoreDescription() }}</p>
</div>
</div>
<button class="btn-report" (click)="onViewReport()">
View entropy.report.json →
</button>
</header>
<div class="panel-content">
<!-- Layer Donut Chart -->
<section class="section layer-section">
<h4 class="section-title">Layer Entropy Distribution</h4>
<div class="layer-visualization">
<div class="donut-chart">
<svg viewBox="-50 -50 100 100" class="donut-svg">
@for (layer of layerDonutData(); track layer.digest) {
<path
class="donut-segment"
[attr.d]="'M 0 -40 A 40 40 0 ' + (layer.percentage > 50 ? 1 : 0) + ' 1 ' + (Math.sin(layer.percentage * 3.6 * Math.PI / 180) * 40) + ' ' + (-Math.cos(layer.percentage * 3.6 * Math.PI / 180) * 40)"
[style.stroke]="layer.color"
[style.transform]="'rotate(' + layer.startAngle + 'deg)'"
(click)="onSelectLayer(layer.digest)"
/>
}
</svg>
<span class="donut-center">
{{ analysis().layers.length }} layers
</span>
</div>
<div class="layer-legend">
@for (layer of analysis().layers; track layer.digest) {
<button
class="legend-item"
(click)="onSelectLayer(layer.digest)"
>
<span class="legend-color" [style.background]="layerDonutData()[analysis().layers.indexOf(layer)]?.color"></span>
<span class="legend-label" [title]="layer.command">
{{ formatPath(layer.command, 20) }}
</span>
<span class="legend-value">{{ layer.opaqueByteRatio | number:'1.0-0' }}% opaque</span>
</button>
}
</div>
</div>
</section>
<!-- High Entropy Files Heatmap -->
<section class="section files-section">
<h4 class="section-title">
High Entropy Files
<span class="count-badge">{{ analysis().highEntropyFiles.length }}</span>
</h4>
@if (topHighEntropyFiles().length === 0) {
<p class="empty-state">No high entropy files detected</p>
} @else {
<div class="files-heatmap">
@for (file of topHighEntropyFiles(); track file.path) {
<button
class="file-item"
[class]="getEntropyClass(file.entropy)"
(click)="onSelectFile(file.path)"
>
<span class="file-icon">{{ getClassificationIcon(file.classification) }}</span>
<div class="file-info">
<span class="file-path" [title]="file.path">{{ formatPath(file.path) }}</span>
<div class="file-meta">
<span class="file-size">{{ formatBytes(file.size) }}</span>
<span class="file-class">{{ file.classification }}</span>
</div>
</div>
<div class="entropy-bar-container">
<div class="entropy-bar" [style.width]="getEntropyBarWidth(file.entropy)"></div>
<span class="entropy-value">{{ file.entropy | number:'1.2-2' }} bits</span>
</div>
</button>
}
</div>
@if (analysis().highEntropyFiles.length > 10) {
<p class="more-files">
+ {{ analysis().highEntropyFiles.length - 10 }} more files
</p>
}
}
</section>
<!-- Why Risky? Detector Hints -->
<section class="section hints-section">
<h4 class="section-title">Why Risky?</h4>
@if (analysis().detectorHints.length === 0) {
<p class="empty-state">No specific risks detected</p>
} @else {
<div class="hint-chips">
@for (group of detectorHintsByType(); track group.type) {
<button class="hint-chip" [class]="'severity-' + group.maxSeverity">
<span class="chip-icon">{{ getHintTypeIcon(group.type) }}</span>
<span class="chip-label">{{ group.type | titlecase }}</span>
<span class="chip-count">{{ group.count }}</span>
</button>
}
</div>
<ul class="hints-list">
@for (hint of analysis().detectorHints.slice(0, 5); track hint.id) {
<li class="hint-item" [class]="'severity-' + hint.severity">
<div class="hint-header">
<span class="hint-icon">{{ getHintTypeIcon(hint.type) }}</span>
<span class="hint-type">{{ hint.type | titlecase }}</span>
<span class="hint-confidence">{{ hint.confidence }}% confidence</span>
</div>
<p class="hint-desc">{{ hint.description }}</p>
<p class="hint-remediation">
<strong>Fix:</strong> {{ hint.remediation }}
</p>
@if (hint.affectedPaths.length > 0) {
<details class="affected-paths">
<summary>{{ hint.affectedPaths.length }} affected file(s)</summary>
<ul>
@for (path of hint.affectedPaths.slice(0, 3); track path) {
<li><code>{{ formatPath(path, 50) }}</code></li>
}
@if (hint.affectedPaths.length > 3) {
<li class="more">+ {{ hint.affectedPaths.length - 3 }} more</li>
}
</ul>
</details>
}
</li>
}
</ul>
@if (analysis().detectorHints.length > 5) {
<p class="more-hints">
+ {{ analysis().detectorHints.length - 5 }} more hints in report
</p>
}
}
</section>
</div>
<footer class="panel-footer">
<span class="analyzed-at">Analyzed {{ analysis().analyzedAt | date:'medium' }}</span>
</footer>
</div>

View File

@@ -0,0 +1,431 @@
.entropy-panel {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.risk-low { --risk-color: var(--color-success, #059669); }
&.risk-medium { --risk-color: var(--color-warning, #d97706); }
&.risk-high { --risk-color: var(--color-error, #ea580c); }
&.risk-critical { --risk-color: var(--color-critical, #dc2626); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.score-section {
display: flex;
align-items: center;
gap: 1rem;
}
.score-ring {
position: relative;
width: 64px;
height: 64px;
}
.score-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.score-bg {
fill: none;
stroke: var(--color-border, #e5e7eb);
stroke-width: 8;
}
.score-fill {
fill: none;
stroke: var(--risk-color);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease;
}
.score-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.25rem;
font-weight: 700;
color: var(--risk-color);
}
.score-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.risk-label {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--risk-color);
}
.score-desc {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.btn-report {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.8125rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
&:hover {
text-decoration: underline;
}
}
.panel-content {
padding: 1rem;
}
.section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.count-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text, #374151);
font-weight: normal;
}
.layer-visualization {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
align-items: start;
}
.donut-chart {
position: relative;
width: 100px;
height: 100px;
}
.donut-svg {
width: 100%;
height: 100%;
}
.donut-segment {
fill: none;
stroke-width: 16;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.donut-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-align: center;
}
.layer-legend {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.75rem;
color: var(--color-text, #374151);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legend-value {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.empty-state {
font-style: italic;
color: var(--color-text-muted, #9ca3af);
text-align: center;
padding: 1rem;
}
.files-heatmap {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
background: var(--color-bg-subtle, #f9fafb);
border: 1px solid transparent;
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--color-border, #e5e7eb);
}
&.entropy-low { border-left: 3px solid var(--color-success, #059669); }
&.entropy-medium { border-left: 3px solid var(--color-warning, #d97706); }
&.entropy-high { border-left: 3px solid var(--color-error, #ea580c); }
&.entropy-critical { border-left: 3px solid var(--color-critical, #dc2626); }
}
.file-icon {
font-size: 1.25rem;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-path {
display: block;
font-size: 0.8125rem;
font-family: monospace;
color: var(--color-text, #374151);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.entropy-bar-container {
width: 100px;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.entropy-bar {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--color-success, #059669), var(--color-warning, #d97706), var(--color-critical, #dc2626));
}
.entropy-value {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
text-align: right;
}
.more-files,
.more-hints {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
text-align: center;
margin: 0.5rem 0 0;
}
.hint-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.hint-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 16px;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
}
&.severity-critical { border-color: var(--color-critical, #dc2626); background: #fef2f2; }
&.severity-high { border-color: var(--color-error, #ea580c); background: #fff7ed; }
&.severity-medium { border-color: var(--color-warning, #d97706); background: #fffbeb; }
}
.chip-icon {
font-size: 0.875rem;
}
.chip-label {
font-weight: 500;
}
.chip-count {
background: rgba(0, 0, 0, 0.1);
padding: 0 0.25rem;
border-radius: 8px;
font-size: 0.625rem;
}
.hints-list {
list-style: none;
padding: 0;
margin: 0;
}
.hint-item {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
&.severity-critical { border-left: 3px solid var(--color-critical, #dc2626); }
&.severity-high { border-left: 3px solid var(--color-error, #ea580c); }
&.severity-medium { border-left: 3px solid var(--color-warning, #d97706); }
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
}
.hint-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hint-icon {
font-size: 1rem;
}
.hint-type {
font-weight: 600;
font-size: 0.8125rem;
}
.hint-confidence {
margin-left: auto;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.hint-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.hint-remediation {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.affected-paths {
margin-top: 0.5rem;
summary {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
cursor: pointer;
}
ul {
margin: 0.25rem 0 0;
padding-left: 1rem;
font-size: 0.75rem;
}
code {
font-family: monospace;
background: var(--color-bg-code, #f3f4f6);
padding: 0 0.25rem;
border-radius: 2px;
}
.more {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
list-style: none;
}
}
.panel-footer {
padding: 0.5rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
background: var(--color-bg-subtle, #f9fafb);
}
.analyzed-at {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}

View File

@@ -0,0 +1,179 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import {
EntropyAnalysis,
LayerEntropy,
HighEntropyFile,
DetectorHint,
} from '../../core/api/entropy.models';
@Component({
selector: 'app-entropy-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './entropy-panel.component.html',
styleUrls: ['./entropy-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPanelComponent {
/** Entropy analysis data */
readonly analysis = input.required<EntropyAnalysis>();
/** Emits when user wants to view raw report */
readonly viewReport = output<void>();
/** Emits when user clicks on a layer */
readonly selectLayer = output<string>();
/** Emits when user clicks on a file */
readonly selectFile = output<string>();
readonly riskClass = computed(() => 'risk-' + this.analysis().riskLevel);
readonly scoreDescription = computed(() => {
const score = this.analysis().overallScore;
if (score <= 2) return 'Minimal entropy detected';
if (score <= 4) return 'Normal entropy levels';
if (score <= 6) return 'Elevated entropy - review recommended';
if (score <= 8) return 'High entropy - potential secrets detected';
return 'Critical entropy - likely obfuscated content';
});
readonly layerDonutData = computed(() => {
const layers = this.analysis().layers;
const total = layers.reduce((sum, l) => sum + l.riskContribution, 0);
return layers.map((layer, index) => ({
...layer,
percentage: total > 0 ? (layer.riskContribution / total) * 100 : 0,
startAngle: this.calculateStartAngle(layers, index, total),
color: this.getLayerColor(layer.riskContribution, total / layers.length),
}));
});
readonly topHighEntropyFiles = computed(() => {
return [...this.analysis().highEntropyFiles]
.sort((a, b) => b.entropy - a.entropy)
.slice(0, 10);
});
readonly detectorHintsByType = computed(() => {
const hints = this.analysis().detectorHints;
const byType = new Map<string, DetectorHint[]>();
for (const hint of hints) {
const existing = byType.get(hint.type) || [];
existing.push(hint);
byType.set(hint.type, existing);
}
return Array.from(byType.entries()).map(([type, items]) => ({
type,
items,
count: items.length,
maxSeverity: this.getMaxSeverity(items),
}));
});
private calculateStartAngle(
layers: LayerEntropy[],
index: number,
total: number
): number {
let angle = 0;
for (let i = 0; i < index; i++) {
angle += total > 0 ? (layers[i].riskContribution / total) * 360 : 0;
}
return angle;
}
private getLayerColor(contribution: number, avg: number): string {
const ratio = contribution / (avg || 1);
if (ratio > 2) return 'var(--color-entropy-critical, #dc2626)';
if (ratio > 1.5) return 'var(--color-entropy-high, #ea580c)';
if (ratio > 1) return 'var(--color-entropy-medium, #d97706)';
return 'var(--color-entropy-low, #65a30d)';
}
private getMaxSeverity(hints: DetectorHint[]): string {
const severityOrder = ['critical', 'high', 'medium', 'low'];
for (const severity of severityOrder) {
if (hints.some((h) => h.severity === severity)) {
return severity;
}
}
return 'low';
}
getEntropyBarWidth(entropy: number): string {
// Entropy is 0-8 bits, normalize to percentage
return Math.min(entropy / 8 * 100, 100) + '%';
}
getEntropyClass(entropy: number): string {
if (entropy >= 7) return 'entropy-critical';
if (entropy >= 6) return 'entropy-high';
if (entropy >= 4.5) return 'entropy-medium';
return 'entropy-low';
}
getClassificationIcon(classification: HighEntropyFile['classification']): string {
switch (classification) {
case 'encrypted':
return '🔐';
case 'compressed':
return '📦';
case 'binary':
return '⚙️';
case 'suspicious':
return '⚠️';
default:
return '❓';
}
}
getHintTypeIcon(type: DetectorHint['type']): string {
switch (type) {
case 'credential':
return '🔑';
case 'key':
return '🔏';
case 'token':
return '🎫';
case 'obfuscated':
return '🎭';
case 'packed':
return '📦';
case 'crypto':
return '🔐';
default:
return '❓';
}
}
formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
formatPath(path: string, maxLength = 40): string {
if (path.length <= maxLength) return path;
return '...' + path.slice(-maxLength + 3);
}
onViewReport(): void {
this.viewReport.emit();
}
onSelectLayer(digest: string): void {
this.selectLayer.emit(digest);
}
onSelectFile(path: string): void {
this.selectFile.emit(path);
}
}

View File

@@ -0,0 +1,200 @@
<div class="entropy-policy-banner" [class]="bannerClass()">
<!-- Main Banner -->
<div class="banner-main">
<span class="banner-icon">{{ bannerIcon() }}</span>
<div class="banner-content">
<h4 class="banner-title">{{ bannerTitle() }}</h4>
<p class="banner-message">{{ bannerMessage() }}</p>
</div>
<div class="banner-actions">
@if (config().reportUrl) {
<button class="btn-secondary" (click)="onDownloadReport()">
Download Report
</button>
}
<button class="btn-secondary" (click)="onViewAnalysis()">
View Analysis
</button>
@if (config().action !== 'allow') {
<button
class="btn-expand"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
>
{{ expanded() ? 'Hide' : 'Show' }} Details
</button>
}
</div>
</div>
<!-- Score Visualization -->
<div class="score-visualization">
<div class="score-bar">
<div class="score-track">
<!-- Zone backgrounds -->
<div class="zone allow" [style.width]="warnPercentage() + '%'"></div>
<div
class="zone warn"
[style.left]="warnPercentage() + '%'"
[style.width]="(blockPercentage() - warnPercentage()) + '%'"
></div>
<div
class="zone block"
[style.left]="blockPercentage() + '%'"
[style.width]="(100 - blockPercentage()) + '%'"
></div>
<!-- Threshold markers -->
<div class="threshold-line warn" [style.left]="warnPercentage() + '%'">
<span class="threshold-label">Warn</span>
</div>
<div class="threshold-line block" [style.left]="blockPercentage() + '%'">
<span class="threshold-label">Block</span>
</div>
<!-- Current score marker -->
<div
class="score-marker"
[style.left]="scorePercentage() + '%'"
[class]="'action-' + config().action"
>
<span class="score-value">{{ config().currentScore | number:'1.1-1' }}</span>
</div>
</div>
<div class="scale-labels">
<span>0</span>
<span>2</span>
<span>4</span>
<span>6</span>
<span>8</span>
<span>10</span>
</div>
</div>
<div class="score-legend">
<span class="legend-item allow">
<span class="legend-dot"></span>
Allow (&lt; {{ config().warnThreshold }})
</span>
<span class="legend-item warn">
<span class="legend-dot"></span>
Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }})
</span>
<span class="legend-item block">
<span class="legend-dot"></span>
Block (&gt; {{ config().blockThreshold }})
</span>
</div>
</div>
<!-- Expanded Details -->
@if (expanded()) {
<div class="banner-details">
<!-- Policy Info -->
<div class="policy-info">
<h5 class="section-title">Policy Information</h5>
<dl class="info-grid">
<div class="info-item">
<dt>Policy</dt>
<dd>{{ config().policyName }}</dd>
</div>
<div class="info-item">
<dt>Policy ID</dt>
<dd><code>{{ config().policyId }}</code></dd>
</div>
<div class="info-item">
<dt>Warn Threshold</dt>
<dd>{{ config().warnThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>Block Threshold</dt>
<dd>{{ config().blockThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>High Entropy Files</dt>
<dd>{{ config().highEntropyFileCount }} files</dd>
</div>
</dl>
</div>
<!-- Threshold Explanation -->
<div class="threshold-explanation">
<h5 class="section-title">Understanding Entropy Thresholds</h5>
<div class="explanation-content">
<p>
<strong>Entropy</strong> measures the randomness of data in files. High entropy often indicates:
</p>
<ul class="entropy-indicators">
<li><span class="indicator-icon">[E]</span> Encrypted content</li>
<li><span class="indicator-icon">[C]</span> Compressed files (zip, gz, etc.)</li>
<li><span class="indicator-icon">[B]</span> Binary executables</li>
<li><span class="indicator-icon">[S]</span> Potential secrets or credentials</li>
<li><span class="indicator-icon">[O]</span> Obfuscated or packed code</li>
</ul>
<p class="explanation-note">
While some high-entropy content is legitimate (fonts, images, compressed assets),
unexpected high entropy may indicate security concerns requiring review.
</p>
</div>
</div>
<!-- Mitigation Steps -->
@if (config().action !== 'allow') {
<div class="mitigation-section">
<h5 class="section-title">Mitigation Steps</h5>
<div class="mitigation-list">
@for (step of effectiveMitigationSteps(); track step.id) {
<div class="mitigation-card">
<div class="mitigation-header">
<span class="mitigation-title">{{ step.title }}</span>
<div class="mitigation-badges">
<span class="badge impact" [class]="'impact-' + step.impact">
{{ getImpactLabel(step.impact) }}
</span>
<span class="badge effort">{{ getEffortLabel(step.effort) }}</span>
</div>
</div>
<p class="mitigation-desc">{{ step.description }}</p>
@if (step.command) {
<div class="mitigation-command">
<code>{{ step.command }}</code>
<button class="btn-run" (click)="onRunMitigation(step)">
Run
</button>
</div>
}
@if (step.docsUrl) {
<a class="docs-link" [href]="step.docsUrl" target="_blank">
Learn more ->
</a>
}
</div>
}
</div>
</div>
}
<!-- Report Download -->
@if (config().reportUrl) {
<div class="report-section">
<h5 class="section-title">Raw Evidence</h5>
<div class="report-info">
<span class="report-label">entropy.report.json</span>
<p class="report-desc">
Download the full entropy analysis report containing per-file entropy scores,
detector findings, and detailed metrics.
</p>
<button class="btn-download" (click)="onDownloadReport()">
Download entropy.report.json
</button>
</div>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,489 @@
.entropy-policy-banner {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.action-allow {
border-color: var(--color-success-border, #a7f3d0);
.banner-main {
background: var(--color-success-bg, #ecfdf5);
border-left: 4px solid var(--color-success, #059669);
}
.banner-icon {
color: var(--color-success, #059669);
}
}
&.action-warn {
border-color: var(--color-warning-border, #fde68a);
.banner-main {
background: var(--color-warning-bg, #fffbeb);
border-left: 4px solid var(--color-warning, #d97706);
}
.banner-icon {
color: var(--color-warning, #d97706);
}
}
&.action-block {
border-color: var(--color-error-border, #fecaca);
.banner-main {
background: var(--color-error-bg, #fef2f2);
border-left: 4px solid var(--color-error, #dc2626);
}
.banner-icon {
color: var(--color-error, #dc2626);
}
}
}
.banner-main {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
}
.banner-icon {
font-family: monospace;
font-weight: 700;
font-size: 1.125rem;
}
.banner-content {
flex: 1;
min-width: 200px;
}
.banner-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.banner-message {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.banner-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-secondary {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-expand {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-primary, #2563eb);
&:hover {
background: var(--color-primary-bg, #eff6ff);
}
}
// Score Visualization
.score-visualization {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.score-bar {
margin-bottom: 0.5rem;
}
.score-track {
position: relative;
height: 24px;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
overflow: visible;
}
.zone {
position: absolute;
top: 0;
height: 100%;
&.allow {
background: var(--color-success-bg, #dcfce7);
border-radius: 4px 0 0 4px;
}
&.warn {
background: var(--color-warning-bg, #fef3c7);
}
&.block {
background: var(--color-error-bg, #fee2e2);
border-radius: 0 4px 4px 0;
}
}
.threshold-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: currentColor;
transform: translateX(-1px);
&.warn {
color: var(--color-warning, #d97706);
}
&.block {
color: var(--color-error, #dc2626);
}
.threshold-label {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.625rem;
font-weight: 600;
white-space: nowrap;
}
}
.score-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
&.action-allow {
.score-value {
background: var(--color-success, #059669);
}
}
&.action-warn {
.score-value {
background: var(--color-warning, #d97706);
}
}
&.action-block {
.score-value {
background: var(--color-error, #dc2626);
}
}
}
.score-value {
display: block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
color: white;
white-space: nowrap;
}
.scale-labels {
display: flex;
justify-content: space-between;
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
padding: 0 2px;
}
.score-legend {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
&.allow .legend-dot { background: var(--color-success, #059669); }
&.warn .legend-dot { background: var(--color-warning, #d97706); }
&.block .legend-dot { background: var(--color-error, #dc2626); }
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
// Expanded Details
.banner-details {
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.75rem;
}
.policy-info,
.threshold-explanation,
.mitigation-section,
.report-section {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin: 0;
}
.info-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
}
.explanation-content {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
p {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
}
.entropy-indicators {
list-style: none;
padding: 0;
margin: 0 0 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.25rem;
li {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
}
}
.indicator-icon {
font-family: monospace;
font-weight: 600;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.explanation-note {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-style: italic;
margin-bottom: 0;
}
.mitigation-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mitigation-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
}
.mitigation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.mitigation-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text, #374151);
}
.mitigation-badges {
display: flex;
gap: 0.375rem;
}
.badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
font-weight: 500;
&.impact {
&.impact-high {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
&.impact-medium {
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
}
&.impact-low {
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
}
}
&.effort {
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
}
}
.mitigation-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.mitigation-command {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
.btn-run {
padding: 0.25rem 0.5rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 3px;
font-size: 0.6875rem;
color: white;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
}
.docs-link {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.report-info {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
}
.report-label {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.report-desc {
margin: 0.5rem 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.btn-download {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}

View File

@@ -0,0 +1,215 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
export interface EntropyPolicyConfig {
/** Warn threshold (0-10) */
warnThreshold: number;
/** Block threshold (0-10) */
blockThreshold: number;
/** Current entropy score */
currentScore: number;
/** Action taken */
action: 'allow' | 'warn' | 'block';
/** Policy ID */
policyId: string;
/** Policy name */
policyName: string;
/** High entropy file count */
highEntropyFileCount: number;
/** Link to entropy report */
reportUrl?: string;
}
export interface EntropyMitigationStep {
id: string;
title: string;
description: string;
impact: 'high' | 'medium' | 'low';
effort: 'trivial' | 'easy' | 'moderate' | 'complex';
command?: string;
docsUrl?: string;
}
@Component({
selector: 'app-entropy-policy-banner',
standalone: true,
imports: [CommonModule],
templateUrl: './entropy-policy-banner.component.html',
styleUrls: ['./entropy-policy-banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPolicyBannerComponent {
/** Policy configuration and current state */
readonly config = input.required<EntropyPolicyConfig>();
/** Custom mitigation steps */
readonly mitigationSteps = input<EntropyMitigationStep[]>([]);
/** Emits when user wants to download entropy report */
readonly downloadReport = output<string>();
/** Emits when user runs a mitigation command */
readonly runMitigation = output<EntropyMitigationStep>();
/** Emits when user wants to view detailed analysis */
readonly viewAnalysis = output<void>();
/** Show expanded details */
readonly expanded = signal(false);
readonly bannerClass = computed(() => 'action-' + this.config().action);
readonly bannerIcon = computed(() => {
switch (this.config().action) {
case 'allow':
return '[OK]';
case 'warn':
return '[!]';
case 'block':
return '[X]';
default:
return '[?]';
}
});
readonly bannerTitle = computed(() => {
switch (this.config().action) {
case 'allow':
return 'Entropy Check Passed';
case 'warn':
return 'Entropy Warning';
case 'block':
return 'Entropy Block';
default:
return 'Entropy Status Unknown';
}
});
readonly bannerMessage = computed(() => {
const cfg = this.config();
switch (cfg.action) {
case 'allow':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' is within acceptable limits.';
case 'warn':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds warning threshold (' + cfg.warnThreshold + '). Review recommended.';
case 'block':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds block threshold (' + cfg.blockThreshold + '). Publication blocked.';
default:
return '';
}
});
readonly scorePercentage = computed(() =>
(this.config().currentScore / 10) * 100
);
readonly warnPercentage = computed(() =>
(this.config().warnThreshold / 10) * 100
);
readonly blockPercentage = computed(() =>
(this.config().blockThreshold / 10) * 100
);
readonly defaultMitigationSteps: EntropyMitigationStep[] = [
{
id: 'review-files',
title: 'Review High-Entropy Files',
description: 'Examine files flagged as high entropy to identify false positives or legitimate concerns.',
impact: 'high',
effort: 'easy',
docsUrl: '/docs/security/entropy-analysis',
},
{
id: 'exclude-known',
title: 'Exclude Known Binary Artifacts',
description: 'Add exclusion patterns for legitimate compressed files, fonts, or compiled assets.',
impact: 'medium',
effort: 'trivial',
command: 'stella policy entropy exclude --pattern "*.woff2" --pattern "*.gz"',
},
{
id: 'investigate-secrets',
title: 'Investigate Potential Secrets',
description: 'Check if high-entropy content contains accidentally committed secrets or credentials.',
impact: 'high',
effort: 'moderate',
command: 'stella scan secrets --image $IMAGE_REF',
docsUrl: '/docs/security/secret-detection',
},
{
id: 'adjust-threshold',
title: 'Adjust Policy Thresholds',
description: 'If false positives are common, consider adjusting warn/block thresholds for this policy.',
impact: 'medium',
effort: 'easy',
command: 'stella policy entropy set-threshold --policy $POLICY_ID --warn 7.0 --block 8.5',
},
];
readonly effectiveMitigationSteps = computed(() => {
const custom = this.mitigationSteps();
return custom.length > 0 ? custom : this.defaultMitigationSteps;
});
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
onDownloadReport(): void {
const url = this.config().reportUrl;
if (url) {
this.downloadReport.emit(url);
}
}
onViewAnalysis(): void {
this.viewAnalysis.emit();
}
onRunMitigation(step: EntropyMitigationStep): void {
this.runMitigation.emit(step);
}
getImpactLabel(impact: string): string {
switch (impact) {
case 'high':
return 'High Impact';
case 'medium':
return 'Medium Impact';
case 'low':
return 'Low Impact';
default:
return '';
}
}
getEffortLabel(effort: string): string {
switch (effort) {
case 'trivial':
return '< 5 min';
case 'easy':
return '5-15 min';
case 'moderate':
return '15-60 min';
case 'complex':
return '> 1 hour';
default:
return '';
}
}
}

View File

@@ -0,0 +1,277 @@
<div class="policy-gate-indicator" [class]="statusClass()" [class.compact]="compact()">
<!-- Status Banner -->
<div class="status-banner">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="status-info">
<span class="status-label">{{ statusLabel() }}</span>
<span class="gate-summary">
{{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed
@if (warningGates().length > 0) {
<span class="warning-count">({{ warningGates().length }} warnings)</span>
}
</span>
</div>
<div class="status-actions">
@if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) {
<button class="btn-remediation" (click)="toggleRemediation()">
{{ showRemediation() ? 'Hide' : 'Show' }} Fixes
</button>
}
<button
class="btn-publish"
[disabled]="!gateStatus().canPublish"
(click)="onPublish()"
[title]="gateStatus().blockReason || 'Publish artifact'"
>
@if (gateStatus().canPublish) {
Publish
} @else {
Blocked
}
</button>
</div>
</div>
<!-- Block Reason Banner -->
@if (!gateStatus().canPublish && gateStatus().blockReason) {
<div class="block-banner">
<span class="block-icon">[!]</span>
<span class="block-message">{{ gateStatus().blockReason }}</span>
</div>
}
<!-- Gate List -->
@if (!compact()) {
<div class="gates-list">
@for (gate of gateStatus().gates; track gate.gateId) {
<div class="gate-item" [class]="'result-' + gate.result">
<button
class="gate-header"
(click)="toggleGate(gate.gateId)"
[attr.aria-expanded]="expandedGate() === gate.gateId"
>
<span class="gate-type-icon">{{ getGateIcon(gate.type) }}</span>
<span class="result-icon">{{ getResultIcon(gate.result) }}</span>
<div class="gate-info">
<span class="gate-name">{{ gate.name }}</span>
@if (gate.required) {
<span class="required-badge">Required</span>
}
</div>
<span class="expand-icon" [class.expanded]="expandedGate() === gate.gateId">v</span>
</button>
@if (expandedGate() === gate.gateId) {
<div class="gate-details">
<!-- Determinism Gate Details -->
@if (gate.type === 'determinism' && getDeterminismDetails(gate)) {
@let det = getDeterminismDetails(gate)!;
<div class="determinism-details">
<div class="detail-row">
<span class="detail-label">Merkle Root</span>
<span class="detail-value" [class.mismatch]="!det.merkleRootConsistent">
@if (det.merkleRootConsistent) {
<span class="match-icon">[+]</span> Consistent
} @else {
<span class="mismatch-icon">[x]</span> Mismatch
}
</span>
</div>
@if (!det.merkleRootConsistent) {
<div class="hash-comparison">
<div class="hash-row">
<span class="hash-label">Expected:</span>
<code class="hash">{{ formatHash(det.expectedMerkleRoot, 24) }}</code>
</div>
<div class="hash-row">
<span class="hash-label">Computed:</span>
<code class="hash mismatch">{{ formatHash(det.computedMerkleRoot, 24) }}</code>
</div>
</div>
}
<div class="detail-row">
<span class="detail-label">Fragments</span>
<span class="detail-value">
{{ det.matchingFragments }}/{{ det.totalFragments }} verified
</span>
</div>
<div class="detail-row">
<span class="detail-label">Composition File</span>
<span class="detail-value" [class.missing]="!det.compositionPresent">
{{ det.compositionPresent ? 'Present' : 'Missing' }}
</span>
</div>
@if (det.fragmentResults.length > 0) {
<details class="fragment-details">
<summary>Fragment Verification ({{ det.fragmentResults.length }})</summary>
<ul class="fragment-list">
@for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) {
<li [class.mismatch]="!frag.match">
<span class="frag-id">{{ frag.fragmentId }}</span>
<span class="frag-status">{{ frag.match ? '+' : 'x' }}</span>
</li>
}
@if (det.fragmentResults.length > 5) {
<li class="more">+ {{ det.fragmentResults.length - 5 }} more</li>
}
</ul>
</details>
}
</div>
}
<!-- Entropy Gate Details -->
@if (gate.type === 'entropy' && getEntropyDetails(gate)) {
@let ent = getEntropyDetails(gate)!;
<div class="entropy-details">
<div class="detail-row">
<span class="detail-label">Entropy Score</span>
<span class="detail-value score" [class]="'action-' + ent.action">
{{ ent.entropyScore | number:'1.1-1' }} / 10
</span>
</div>
<div class="threshold-bar">
<div class="threshold-track">
<div class="threshold-marker warn" [style.left]="(ent.warnThreshold / 10 * 100) + '%'"></div>
<div class="threshold-marker block" [style.left]="(ent.blockThreshold / 10 * 100) + '%'"></div>
<div class="score-marker" [style.left]="(ent.entropyScore / 10 * 100) + '%'"></div>
</div>
<div class="threshold-labels">
<span>0</span>
<span class="warn-label">Warn ({{ ent.warnThreshold }})</span>
<span class="block-label">Block ({{ ent.blockThreshold }})</span>
<span>10</span>
</div>
</div>
<div class="detail-row">
<span class="detail-label">High Entropy Files</span>
<span class="detail-value">{{ ent.highEntropyFileCount }}</span>
</div>
@if (ent.suspiciousPatterns.length > 0) {
<div class="suspicious-patterns">
<span class="detail-label">Suspicious Patterns</span>
<ul class="pattern-list">
@for (pattern of ent.suspiciousPatterns; track pattern) {
<li>{{ pattern }}</li>
}
</ul>
</div>
}
</div>
}
<!-- Evidence Links -->
@if (gate.evidenceRefs && gate.evidenceRefs.length > 0) {
<div class="evidence-links">
<span class="evidence-label">Evidence:</span>
@for (ref of gate.evidenceRefs; track ref) {
<button class="evidence-link" (click)="onViewEvidence(ref)">
{{ ref | slice:0:20 }}...
</button>
}
</div>
}
<!-- Gate-specific Remediation -->
@if (gate.result === 'failed') {
@let hints = getHintsForGate(gate.gateId);
@if (hints.length > 0) {
<div class="gate-remediation">
<h5 class="remediation-title">How to Fix</h5>
@for (hint of hints; track hint.title) {
<div class="hint-card">
<div class="hint-header">
<span class="hint-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="hint-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
@if (hint.docsUrl) {
<a class="docs-link" [href]="hint.docsUrl" target="_blank">
Documentation ->
</a>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
</div>
}
<!-- Blocking Issues Summary -->
@if (gateStatus().blockingIssues.length > 0) {
<div class="blocking-issues">
<h4 class="issues-title">Blocking Issues ({{ gateStatus().blockingIssues.length }})</h4>
<ul class="issues-list">
@for (issue of gateStatus().blockingIssues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-code">{{ issue.code }}</span>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.resource) {
<span class="issue-resource">{{ issue.resource }}</span>
}
</li>
}
</ul>
</div>
}
<!-- Remediation Panel -->
@if (showRemediation() && gateStatus().remediationHints.length > 0) {
<div class="remediation-panel">
<h4 class="remediation-panel-title">Remediation Steps</h4>
@for (hint of gateStatus().remediationHints; track hint.title) {
<div class="remediation-card">
<div class="remediation-header">
<span class="remediation-for">{{ hint.forGate }}</span>
<span class="remediation-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="remediation-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
</div>
}
</div>
}
<!-- Footer -->
<footer class="indicator-footer">
<span class="eval-id">Evaluation: {{ gateStatus().evaluationId | slice:0:12 }}</span>
<span class="eval-time">{{ gateStatus().evaluatedAt | date:'medium' }}</span>
</footer>
</div>

View File

@@ -0,0 +1,673 @@
.policy-gate-indicator {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.status-passed {
.status-banner { border-left: 4px solid var(--color-success, #059669); }
.status-icon { color: var(--color-success, #059669); }
}
&.status-failed {
.status-banner { border-left: 4px solid var(--color-error, #dc2626); }
.status-icon { color: var(--color-error, #dc2626); }
}
&.status-warning {
.status-banner { border-left: 4px solid var(--color-warning, #d97706); }
.status-icon { color: var(--color-warning, #d97706); }
}
&.status-pending {
.status-banner { border-left: 4px solid var(--color-info, #2563eb); }
.status-icon { color: var(--color-info, #2563eb); }
}
&.status-skipped {
.status-banner { border-left: 4px solid var(--color-text-muted, #9ca3af); }
.status-icon { color: var(--color-text-muted, #9ca3af); }
}
&.compact {
.gates-list,
.blocking-issues,
.remediation-panel {
display: none;
}
}
}
.status-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.status-icon {
font-family: monospace;
font-weight: 700;
font-size: 1rem;
}
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.status-label {
font-weight: 600;
color: var(--color-text, #111827);
}
.gate-summary {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.warning-count {
color: var(--color-warning, #d97706);
}
.status-actions {
display: flex;
gap: 0.5rem;
}
.btn-remediation {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-publish {
padding: 0.375rem 1rem;
background: var(--color-success, #059669);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
color: white;
&:hover:not(:disabled) {
background: var(--color-success-dark, #047857);
}
&:disabled {
background: var(--color-text-muted, #9ca3af);
cursor: not-allowed;
}
}
.block-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-bottom: 1px solid var(--color-error-border, #fecaca);
}
.block-icon {
font-family: monospace;
font-weight: 700;
color: var(--color-error, #dc2626);
}
.block-message {
font-size: 0.8125rem;
color: var(--color-error, #dc2626);
}
.gates-list {
border-top: 1px solid var(--color-border, #e5e7eb);
}
.gate-item {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
&.result-passed {
.result-icon { color: var(--color-success, #059669); }
}
&.result-failed {
.result-icon { color: var(--color-error, #dc2626); }
.gate-header { background: var(--color-error-bg, #fef2f2); }
}
&.result-warning {
.result-icon { color: var(--color-warning, #d97706); }
.gate-header { background: var(--color-warning-bg, #fffbeb); }
}
&.result-skipped {
.result-icon { color: var(--color-text-muted, #9ca3af); }
.gate-name { color: var(--color-text-muted, #9ca3af); }
}
}
.gate-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.gate-type-icon {
font-family: monospace;
font-weight: 600;
font-size: 0.75rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
color: var(--color-text-muted, #6b7280);
}
.result-icon {
font-family: monospace;
font-weight: 700;
font-size: 0.875rem;
}
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.gate-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text, #374151);
}
.required-badge {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
border-radius: 2px;
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning-dark, #92400e);
text-transform: uppercase;
font-weight: 600;
}
.expand-icon {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.gate-details {
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.detail-label {
color: var(--color-text-muted, #6b7280);
}
.detail-value {
font-weight: 500;
color: var(--color-text, #374151);
&.mismatch,
&.missing {
color: var(--color-error, #dc2626);
}
&.score {
font-family: monospace;
&.action-allow { color: var(--color-success, #059669); }
&.action-warn { color: var(--color-warning, #d97706); }
&.action-block { color: var(--color-error, #dc2626); }
}
}
.match-icon {
color: var(--color-success, #059669);
}
.mismatch-icon {
color: var(--color-error, #dc2626);
}
.hash-comparison {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--color-bg-card, white);
border-radius: 4px;
border: 1px solid var(--color-border, #e5e7eb);
}
.hash-row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
.hash-label {
color: var(--color-text-muted, #9ca3af);
min-width: 70px;
}
.hash {
font-family: monospace;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
&.mismatch {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
}
.fragment-details {
margin-top: 0.5rem;
summary {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
cursor: pointer;
}
}
.fragment-list {
list-style: none;
padding: 0;
margin: 0.25rem 0 0;
li {
display: flex;
justify-content: space-between;
padding: 0.125rem 0;
font-size: 0.75rem;
&.mismatch {
color: var(--color-error, #dc2626);
}
&.more {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
}
}
.frag-id {
font-family: monospace;
}
.frag-status {
font-weight: 700;
}
// Entropy Details
.threshold-bar {
margin: 0.75rem 0;
}
.threshold-track {
position: relative;
height: 8px;
background: linear-gradient(to right,
var(--color-success, #059669) 0%,
var(--color-warning, #d97706) 50%,
var(--color-error, #dc2626) 100%
);
border-radius: 4px;
}
.threshold-marker {
position: absolute;
top: -2px;
width: 2px;
height: 12px;
background: var(--color-text, #374151);
&.warn::after,
&.block::after {
content: '';
position: absolute;
top: -4px;
left: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
}
&.warn::after {
background: var(--color-warning, #d97706);
}
&.block::after {
background: var(--color-error, #dc2626);
}
}
.score-marker {
position: absolute;
top: -4px;
width: 16px;
height: 16px;
background: white;
border: 2px solid var(--color-text, #374151);
border-radius: 50%;
transform: translateX(-50%);
}
.threshold-labels {
display: flex;
justify-content: space-between;
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
margin-top: 0.25rem;
}
.warn-label {
color: var(--color-warning, #d97706);
}
.block-label {
color: var(--color-error, #dc2626);
}
.suspicious-patterns {
margin-top: 0.5rem;
}
.pattern-list {
list-style: disc;
margin: 0.25rem 0 0 1rem;
padding: 0;
li {
font-size: 0.75rem;
color: var(--color-warning, #d97706);
}
}
.evidence-links {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
flex-wrap: wrap;
}
.evidence-label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.evidence-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
font-family: monospace;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.gate-remediation {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.remediation-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.hint-card,
.remediation-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.hint-header,
.remediation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.hint-title,
.remediation-title {
font-weight: 600;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.remediation-for {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
font-family: monospace;
}
.effort-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
border-radius: 10px;
margin-left: auto;
}
.hint-steps,
.remediation-steps {
margin: 0;
padding-left: 1.25rem;
li {
font-size: 0.8125rem;
color: var(--color-text, #374151);
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.cli-command {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
.btn-run {
padding: 0.25rem 0.5rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 3px;
font-size: 0.6875rem;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
}
.docs-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.blocking-issues {
padding: 0.75rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-top: 1px solid var(--color-error-border, #fecaca);
}
.issues-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-error, #dc2626);
margin: 0 0 0.5rem;
}
.issues-list {
list-style: none;
padding: 0;
margin: 0;
}
.issue-item {
display: flex;
gap: 0.5rem;
padding: 0.375rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid var(--color-error-border, #fecaca);
flex-wrap: wrap;
&:last-child {
border-bottom: none;
}
&.severity-critical .issue-code {
background: var(--color-critical, #dc2626);
color: white;
}
&.severity-high .issue-code {
background: var(--color-error, #ea580c);
color: white;
}
}
.issue-code {
font-family: monospace;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 2px;
font-weight: 600;
}
.issue-message {
flex: 1;
color: var(--color-text, #374151);
}
.issue-resource {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.remediation-panel {
padding: 0.75rem 1rem;
background: var(--color-info-bg, #f0f9ff);
border-top: 1px solid var(--color-info-border, #bae6fd);
}
.remediation-panel-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-info-dark, #0369a1);
margin: 0 0 0.75rem;
}
.indicator-footer {
display: flex;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.eval-id {
font-family: monospace;
}

View File

@@ -0,0 +1,190 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
PolicyGateStatus,
PolicyGate,
PolicyRemediationHint,
DeterminismGateDetails,
EntropyGateDetails,
} from '../../core/api/policy.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
templateUrl: './policy-gate-indicator.component.html',
styleUrls: ['./policy-gate-indicator.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
/** Policy gate status data */
readonly gateStatus = input.required<PolicyGateStatus>();
/** Show compact view */
readonly compact = input(false);
/** Emits when user clicks publish (if allowed) */
readonly publish = output<string>();
/** Emits when user wants to view evidence */
readonly viewEvidence = output<string>();
/** Emits when user wants to run remediation */
readonly runRemediation = output<PolicyRemediationHint>();
/** Currently expanded gate */
readonly expandedGate = signal<string | null>(null);
/** Show remediation panel */
readonly showRemediation = signal(false);
readonly statusClass = computed(() => 'status-' + this.gateStatus().status);
readonly statusIcon = computed(() => {
switch (this.gateStatus().status) {
case 'passed':
return '[OK]';
case 'failed':
return '[X]';
case 'warning':
return '[!]';
case 'pending':
return '[...]';
case 'skipped':
return '[-]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.gateStatus().status) {
case 'passed':
return 'All Gates Passed';
case 'failed':
return 'Gates Failed';
case 'warning':
return 'Gates Passed with Warnings';
case 'pending':
return 'Evaluation Pending';
case 'skipped':
return 'Gates Skipped';
default:
return 'Unknown Status';
}
});
readonly passedGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'passed')
);
readonly failedGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'failed')
);
readonly warningGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'warning')
);
readonly determinismGate = computed(() =>
this.gateStatus().gates.find((g) => g.type === 'determinism')
);
readonly entropyGate = computed(() =>
this.gateStatus().gates.find((g) => g.type === 'entropy')
);
toggleGate(gateId: string): void {
this.expandedGate.update((current) => (current === gateId ? null : gateId));
}
toggleRemediation(): void {
this.showRemediation.update((v) => !v);
}
onPublish(): void {
if (this.gateStatus().canPublish) {
this.publish.emit(this.gateStatus().evaluationId);
}
}
onViewEvidence(ref: string): void {
this.viewEvidence.emit(ref);
}
onRunRemediation(hint: PolicyRemediationHint): void {
this.runRemediation.emit(hint);
}
getGateIcon(type: string): string {
switch (type) {
case 'determinism':
return '#';
case 'vulnerability':
return '!';
case 'license':
return 'L';
case 'signature':
return 'S';
case 'entropy':
return 'E';
default:
return '?';
}
}
getResultIcon(result: string): string {
switch (result) {
case 'passed':
return '+';
case 'failed':
return 'x';
case 'warning':
return '!';
case 'skipped':
return '-';
default:
return '?';
}
}
getEffortLabel(effort?: string): string {
switch (effort) {
case 'trivial':
return '< 5 min';
case 'easy':
return '5-15 min';
case 'moderate':
return '15-60 min';
case 'complex':
return '> 1 hour';
default:
return '';
}
}
getDeterminismDetails(gate: PolicyGate): DeterminismGateDetails | null {
return gate.details as DeterminismGateDetails | null;
}
getEntropyDetails(gate: PolicyGate): EntropyGateDetails | null {
return gate.details as EntropyGateDetails | null;
}
formatHash(hash: string | undefined, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
getHintsForGate(gateId: string): PolicyRemediationHint[] {
return this.gateStatus().remediationHints.filter((h) => h.forGate === gateId);
}
}