release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -41,8 +41,8 @@ import { AuthorityAuthService } from './core/auth/authority-auth.service';
import {
ADVISORY_AI_API,
ADVISORY_AI_API_BASE_URL,
AdvisoryAiHttpClient,
MockAdvisoryAiApiService,
AdvisoryAiApiHttpClient,
MockAdvisoryAiClient,
} from './core/api/advisory-ai.client';
import {
ADVISORY_API,
@@ -114,6 +114,43 @@ import {
AiRunsHttpClient,
MockAiRunsClient,
} from './core/api/ai-runs.client';
import {
RELEASE_DASHBOARD_API,
RELEASE_DASHBOARD_API_BASE_URL,
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
} from './core/api/release-dashboard.client';
import {
RELEASE_ENVIRONMENT_API,
RELEASE_ENVIRONMENT_API_BASE_URL,
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
} from './core/api/release-environment.client';
import {
RELEASE_MANAGEMENT_API,
ReleaseManagementHttpClient,
MockReleaseManagementClient,
} from './core/api/release-management.client';
import {
WORKFLOW_API,
WorkflowHttpClient,
MockWorkflowClient,
} from './core/api/workflow.client';
import {
APPROVAL_API,
ApprovalHttpClient,
MockApprovalClient,
} from './core/api/approval.client';
import {
DEPLOYMENT_API,
DeploymentHttpClient,
MockDeploymentClient,
} from './core/api/deployment.client';
import {
RELEASE_EVIDENCE_API,
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
} from './core/api/release-evidence.client';
export const appConfig: ApplicationConfig = {
providers: [
@@ -267,12 +304,12 @@ export const appConfig: ApplicationConfig = {
}
},
},
AdvisoryAiHttpClient,
MockAdvisoryAiApiService,
AdvisoryAiApiHttpClient,
MockAdvisoryAiClient,
{
provide: ADVISORY_AI_API,
deps: [AppConfigService, AdvisoryAiHttpClient, MockAdvisoryAiApiService],
useFactory: (config: AppConfigService, http: AdvisoryAiHttpClient, mock: MockAdvisoryAiApiService) =>
deps: [AppConfigService, AdvisoryAiApiHttpClient, MockAdvisoryAiClient],
useFactory: (config: AppConfigService, http: AdvisoryAiApiHttpClient, mock: MockAdvisoryAiClient) =>
config.config.quickstartMode ? mock : http,
},
{
@@ -532,5 +569,115 @@ export const appConfig: ApplicationConfig = {
provide: NOTIFY_API,
useExisting: MockNotifyApiService,
},
// Release Dashboard API
{
provide: RELEASE_DASHBOARD_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/release-orchestrator', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/release-orchestrator`;
}
},
},
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
{
provide: RELEASE_DASHBOARD_API,
deps: [AppConfigService, ReleaseDashboardHttpClient, MockReleaseDashboardClient],
useFactory: (
config: AppConfigService,
http: ReleaseDashboardHttpClient,
mock: MockReleaseDashboardClient
) => (config.config.quickstartMode ? mock : http),
},
// Release Environment API (Sprint 111_002)
{
provide: RELEASE_ENVIRONMENT_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/release-orchestrator', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/release-orchestrator`;
}
},
},
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
{
provide: RELEASE_ENVIRONMENT_API,
deps: [AppConfigService, ReleaseEnvironmentHttpClient, MockReleaseEnvironmentClient],
useFactory: (
config: AppConfigService,
http: ReleaseEnvironmentHttpClient,
mock: MockReleaseEnvironmentClient
) => (config.config.quickstartMode ? mock : http),
},
// Release Management API (Sprint 111_003)
ReleaseManagementHttpClient,
MockReleaseManagementClient,
{
provide: RELEASE_MANAGEMENT_API,
deps: [AppConfigService, ReleaseManagementHttpClient, MockReleaseManagementClient],
useFactory: (
config: AppConfigService,
http: ReleaseManagementHttpClient,
mock: MockReleaseManagementClient
) => (config.config.quickstartMode ? mock : http),
},
// Workflow API (Sprint 111_004)
WorkflowHttpClient,
MockWorkflowClient,
{
provide: WORKFLOW_API,
deps: [AppConfigService, WorkflowHttpClient, MockWorkflowClient],
useFactory: (
config: AppConfigService,
http: WorkflowHttpClient,
mock: MockWorkflowClient
) => (config.config.quickstartMode ? mock : http),
},
// Approval API (Sprint 111_005)
ApprovalHttpClient,
MockApprovalClient,
{
provide: APPROVAL_API,
deps: [AppConfigService, ApprovalHttpClient, MockApprovalClient],
useFactory: (
config: AppConfigService,
http: ApprovalHttpClient,
mock: MockApprovalClient
) => (config.config.quickstartMode ? mock : http),
},
// Deployment API (Sprint 111_006)
DeploymentHttpClient,
MockDeploymentClient,
{
provide: DEPLOYMENT_API,
deps: [AppConfigService, DeploymentHttpClient, MockDeploymentClient],
useFactory: (
config: AppConfigService,
http: DeploymentHttpClient,
mock: MockDeploymentClient
) => (config.config.quickstartMode ? mock : http),
},
// Release Evidence API (Sprint 111_007)
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
{
provide: RELEASE_EVIDENCE_API,
deps: [AppConfigService, ReleaseEvidenceHttpClient, MockReleaseEvidenceClient],
useFactory: (
config: AppConfigService,
http: ReleaseEvidenceHttpClient,
mock: MockReleaseEvidenceClient
) => (config.config.quickstartMode ? mock : http),
},
],
};

View File

@@ -84,6 +84,15 @@ export const routes: Routes = [
(m) => m.OrchestratorQuotasComponent
),
},
// Release Orchestrator - Dashboard and management UI (SPRINT_20260110_111_001)
{
path: 'release-orchestrator',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/release-orchestrator/dashboard/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES
),
},
{
path: 'policy-studio/packs',
canMatch: [requirePolicyViewerGuard],
@@ -498,6 +507,13 @@ export const routes: Routes = [
(m) => m.AiRunViewerComponent
),
},
// Change Trace (SPRINT_20260112_200_007)
{
path: 'change-trace',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/change-trace/change-trace.routes').then((m) => m.changeTraceRoutes),
},
// Fallback for unknown routes
{
path: '**',

View File

@@ -0,0 +1,390 @@
/**
* Approval API Client
* Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import type {
ApprovalRequest,
ApprovalDetail,
PromotionPreview,
TargetEnvironment,
PromotionRequestDto,
ApprovalFilter,
} from './approval.models';
export const APPROVAL_API = new InjectionToken<ApprovalApi>('APPROVAL_API');
export interface ApprovalApi {
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]>;
getApproval(id: string): Observable<ApprovalDetail>;
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview>;
getAvailableEnvironments(releaseId: string): Observable<TargetEnvironment[]>;
submitPromotionRequest(releaseId: string, request: PromotionRequestDto): Observable<ApprovalRequest>;
approve(id: string, comment: string): Observable<ApprovalDetail>;
reject(id: string, comment: string): Observable<ApprovalDetail>;
batchApprove(ids: string[], comment: string): Observable<void>;
batchReject(ids: string[], comment: string): Observable<void>;
}
// HTTP Client Implementation
@Injectable()
export class ApprovalHttpClient implements ApprovalApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/release-orchestrator/approvals';
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.baseUrl, { params });
}
getApproval(id: string): Observable<ApprovalDetail> {
return this.http.get<ApprovalDetail>(`${this.baseUrl}/${id}`);
}
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview> {
return this.http.get<PromotionPreview>(
`/api/release-orchestrator/releases/${releaseId}/promotion-preview`,
{ params: { targetEnvironmentId } }
);
}
getAvailableEnvironments(releaseId: string): Observable<TargetEnvironment[]> {
return this.http.get<TargetEnvironment[]>(
`/api/release-orchestrator/releases/${releaseId}/available-environments`
);
}
submitPromotionRequest(releaseId: string, request: PromotionRequestDto): Observable<ApprovalRequest> {
return this.http.post<ApprovalRequest>(
`/api/release-orchestrator/releases/${releaseId}/promote`,
request
);
}
approve(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/approve`, { comment });
}
reject(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/reject`, { comment });
}
batchApprove(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-approve`, { ids, comment });
}
batchReject(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-reject`, { ids, comment });
}
}
// Mock Client Implementation
@Injectable()
export class MockApprovalClient implements ApprovalApi {
private approvals: ApprovalDetail[] = [
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'alice.johnson',
requestedAt: '2026-01-12T08:00:00Z',
urgency: 'normal',
justification: 'Scheduled release with new rate limiting feature and bug fixes for authentication edge cases.',
status: 'pending',
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-01-14T08:00:00Z',
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'No vulnerabilities found', details: { scannedImages: 3, totalVulnerabilities: 0 }, evaluatedAt: '2026-01-12T08:05:00Z' },
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'All policies satisfied', details: { checkedPolicies: 12, violations: 0 }, evaluatedAt: '2026-01-12T08:06:00Z' },
{ gateId: 'g3', gateName: 'Quality Gates', type: 'quality', status: 'passed', message: 'Code coverage: 85%', details: { coverage: 85, threshold: 80 }, evaluatedAt: '2026-01-12T08:07:00Z' },
],
actions: [
{ id: 'act-1', approvalId: 'apr-001', action: 'approved', actor: 'bob.smith', comment: 'Looks good, tests are passing.', timestamp: '2026-01-12T09:30:00Z' },
],
approvers: [
{ id: 'u1', name: 'Bob Smith', email: 'bob.smith@example.com', hasApproved: true, approvedAt: '2026-01-12T09:30:00Z' },
{ id: 'u2', name: 'Carol Davis', email: 'carol.davis@example.com', hasApproved: false, approvedAt: null },
],
releaseComponents: [
{ name: 'api-gateway', version: '2.1.0', digest: 'sha256:abc123def456...' },
{ name: 'rate-limiter', version: '1.0.5', digest: 'sha256:789xyz012...' },
],
},
{
id: 'apr-002',
releaseId: 'rel-002',
releaseName: 'User Service',
releaseVersion: '3.0.0-rc1',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'david.wilson',
requestedAt: '2026-01-12T10:00:00Z',
urgency: 'high',
justification: 'Critical fix for user authentication timeout issue affecting 5% of users.',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: false,
scheduledTime: null,
expiresAt: '2026-01-13T10:00:00Z',
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'warning', message: '2 low severity vulnerabilities', details: { scannedImages: 2, totalVulnerabilities: 2, severity: 'low' }, evaluatedAt: '2026-01-12T10:05:00Z' },
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'All policies satisfied', details: { checkedPolicies: 12, violations: 0 }, evaluatedAt: '2026-01-12T10:06:00Z' },
{ gateId: 'g3', gateName: 'Quality Gates', type: 'quality', status: 'failed', message: 'Code coverage: 72%', details: { coverage: 72, threshold: 80 }, evaluatedAt: '2026-01-12T10:07:00Z' },
],
actions: [],
approvers: [
{ id: 'u1', name: 'Bob Smith', email: 'bob.smith@example.com', hasApproved: false, approvedAt: null },
{ id: 'u3', name: 'Emily Chen', email: 'emily.chen@example.com', hasApproved: false, approvedAt: null },
],
releaseComponents: [
{ name: 'user-service', version: '3.0.0-rc1', digest: 'sha256:user123...' },
],
},
{
id: 'apr-003',
releaseId: 'rel-003',
releaseName: 'Payment Gateway',
releaseVersion: '1.5.2',
sourceEnvironment: 'dev',
targetEnvironment: 'staging',
requestedBy: 'frank.miller',
requestedAt: '2026-01-11T14:00:00Z',
urgency: 'critical',
justification: 'Emergency fix for payment processing failure in certain regions.',
status: 'approved',
currentApprovals: 2,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: '2026-01-12T06:00:00Z',
expiresAt: '2026-01-12T14:00:00Z',
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'No vulnerabilities found', details: {}, evaluatedAt: '2026-01-11T14:05:00Z' },
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'All policies satisfied', details: {}, evaluatedAt: '2026-01-11T14:06:00Z' },
],
actions: [
{ id: 'act-2', approvalId: 'apr-003', action: 'approved', actor: 'carol.davis', comment: 'Urgent fix approved.', timestamp: '2026-01-11T14:30:00Z' },
{ id: 'act-3', approvalId: 'apr-003', action: 'approved', actor: 'grace.lee', comment: 'Confirmed, proceed with deployment.', timestamp: '2026-01-11T15:00:00Z' },
],
approvers: [
{ id: 'u2', name: 'Carol Davis', email: 'carol.davis@example.com', hasApproved: true, approvedAt: '2026-01-11T14:30:00Z' },
{ id: 'u4', name: 'Grace Lee', email: 'grace.lee@example.com', hasApproved: true, approvedAt: '2026-01-11T15:00:00Z' },
],
releaseComponents: [
{ name: 'payment-gateway', version: '1.5.2', digest: 'sha256:pay456...' },
],
},
{
id: 'apr-004',
releaseId: 'rel-004',
releaseName: 'Notification Service',
releaseVersion: '2.0.0',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'alice.johnson',
requestedAt: '2026-01-10T09:00:00Z',
urgency: 'low',
justification: 'Feature release with new email templates and improved delivery tracking.',
status: 'rejected',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-01-12T09:00:00Z',
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'No vulnerabilities found', details: {}, evaluatedAt: '2026-01-10T09:05:00Z' },
],
actions: [
{ id: 'act-4', approvalId: 'apr-004', action: 'rejected', actor: 'bob.smith', comment: 'Missing integration tests. Please add tests before re-submitting.', timestamp: '2026-01-10T11:00:00Z' },
],
approvers: [
{ id: 'u1', name: 'Bob Smith', email: 'bob.smith@example.com', hasApproved: false, approvedAt: null },
],
releaseComponents: [
{ name: 'notification-service', version: '2.0.0', digest: 'sha256:notify789...' },
],
},
];
private environments: TargetEnvironment[] = [
{ id: 'env-staging', name: 'Staging', tier: 'staging' },
{ id: 'env-production', name: 'Production', tier: 'production' },
{ id: 'env-canary', name: 'Canary', tier: 'production' },
];
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
let result = this.approvals.map(a => ({
id: a.id,
releaseId: a.releaseId,
releaseName: a.releaseName,
releaseVersion: a.releaseVersion,
sourceEnvironment: a.sourceEnvironment,
targetEnvironment: a.targetEnvironment,
requestedBy: a.requestedBy,
requestedAt: a.requestedAt,
urgency: a.urgency,
justification: a.justification,
status: a.status,
currentApprovals: a.currentApprovals,
requiredApprovals: a.requiredApprovals,
gatesPassed: a.gatesPassed,
scheduledTime: a.scheduledTime,
expiresAt: a.expiresAt,
}));
if (filter?.statuses?.length) {
result = result.filter(a => filter.statuses!.includes(a.status));
}
if (filter?.urgencies?.length) {
result = result.filter(a => filter.urgencies!.includes(a.urgency));
}
if (filter?.environment) {
result = result.filter(a => a.targetEnvironment === filter.environment);
}
return of(result).pipe(delay(200));
}
getApproval(id: string): Observable<ApprovalDetail> {
const approval = this.approvals.find(a => a.id === id);
if (!approval) {
throw new Error(`Approval not found: ${id}`);
}
return of({ ...approval }).pipe(delay(100));
}
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview> {
const preview: PromotionPreview = {
releaseId,
releaseName: 'Sample Release',
sourceEnvironment: 'staging',
targetEnvironment: targetEnvironmentId === 'env-production' ? 'production' : 'canary',
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'No vulnerabilities found', details: {}, evaluatedAt: new Date().toISOString() },
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'All policies satisfied', details: {}, evaluatedAt: new Date().toISOString() },
],
allGatesPassed: true,
requiredApprovers: 2,
estimatedDeployTime: 300,
warnings: [],
};
return of(preview).pipe(delay(300));
}
getAvailableEnvironments(releaseId: string): Observable<TargetEnvironment[]> {
return of([...this.environments]).pipe(delay(100));
}
submitPromotionRequest(releaseId: string, request: PromotionRequestDto): Observable<ApprovalRequest> {
const now = new Date().toISOString();
const newApproval: ApprovalRequest = {
id: 'apr-' + Math.random().toString(36).substring(2, 9),
releaseId,
releaseName: 'New Release',
releaseVersion: '1.0.0',
sourceEnvironment: 'staging',
targetEnvironment: request.targetEnvironmentId === 'env-production' ? 'production' : 'staging',
requestedBy: 'current-user',
requestedAt: now,
urgency: request.urgency,
justification: request.justification,
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: request.scheduledTime,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
};
return of(newApproval).pipe(delay(300));
}
approve(id: string, comment: string): Observable<ApprovalDetail> {
const index = this.approvals.findIndex(a => a.id === id);
if (index === -1) throw new Error(`Approval not found: ${id}`);
const approval = this.approvals[index];
approval.currentApprovals++;
approval.actions.push({
id: 'act-' + Math.random().toString(36).substring(2, 9),
approvalId: id,
action: 'approved',
actor: 'current-user',
comment,
timestamp: new Date().toISOString(),
});
if (approval.currentApprovals >= approval.requiredApprovals) {
approval.status = 'approved';
}
return of({ ...approval }).pipe(delay(200));
}
reject(id: string, comment: string): Observable<ApprovalDetail> {
const index = this.approvals.findIndex(a => a.id === id);
if (index === -1) throw new Error(`Approval not found: ${id}`);
const approval = this.approvals[index];
approval.status = 'rejected';
approval.actions.push({
id: 'act-' + Math.random().toString(36).substring(2, 9),
approvalId: id,
action: 'rejected',
actor: 'current-user',
comment,
timestamp: new Date().toISOString(),
});
return of({ ...approval }).pipe(delay(200));
}
batchApprove(ids: string[], comment: string): Observable<void> {
for (const id of ids) {
const approval = this.approvals.find(a => a.id === id);
if (approval && approval.status === 'pending') {
approval.currentApprovals++;
if (approval.currentApprovals >= approval.requiredApprovals) {
approval.status = 'approved';
}
approval.actions.push({
id: 'act-' + Math.random().toString(36).substring(2, 9),
approvalId: id,
action: 'approved',
actor: 'current-user',
comment,
timestamp: new Date().toISOString(),
});
}
}
return of(undefined).pipe(delay(300));
}
batchReject(ids: string[], comment: string): Observable<void> {
for (const id of ids) {
const approval = this.approvals.find(a => a.id === id);
if (approval && approval.status === 'pending') {
approval.status = 'rejected';
approval.actions.push({
id: 'act-' + Math.random().toString(36).substring(2, 9),
approvalId: id,
action: 'rejected',
actor: 'current-user',
comment,
timestamp: new Date().toISOString(),
});
}
}
return of(undefined).pipe(delay(300));
}
}

View File

@@ -0,0 +1,186 @@
/**
* Approval Models for Release Orchestrator
* Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui
*/
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
export type ApprovalUrgency = 'low' | 'normal' | 'high' | 'critical';
export type GateStatus = 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
export type GateType = 'security' | 'policy' | 'quality' | 'custom';
export interface GateResult {
gateId: string;
gateName: string;
type: GateType;
status: GateStatus;
message: string;
details: Record<string, unknown>;
evaluatedAt: string;
}
export interface ApprovalRequest {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
sourceEnvironment: string;
targetEnvironment: string;
requestedBy: string;
requestedAt: string;
urgency: ApprovalUrgency;
justification: string;
status: ApprovalStatus;
currentApprovals: number;
requiredApprovals: number;
gatesPassed: boolean;
scheduledTime: string | null;
expiresAt: string;
}
export interface ApprovalAction {
id: string;
approvalId: string;
action: 'approved' | 'rejected';
actor: string;
comment: string;
timestamp: string;
}
export interface Approver {
id: string;
name: string;
email: string;
hasApproved: boolean;
approvedAt: string | null;
}
export interface ReleaseComponent {
name: string;
version: string;
digest: string;
}
export interface ApprovalDetail extends ApprovalRequest {
gateResults: GateResult[];
actions: ApprovalAction[];
approvers: Approver[];
releaseComponents: ReleaseComponent[];
}
export interface PromotionPreview {
releaseId: string;
releaseName: string;
sourceEnvironment: string;
targetEnvironment: string;
gateResults: GateResult[];
allGatesPassed: boolean;
requiredApprovers: number;
estimatedDeployTime: number;
warnings: string[];
}
export interface TargetEnvironment {
id: string;
name: string;
tier: string;
}
export interface PromotionRequestDto {
targetEnvironmentId: string;
urgency: ApprovalUrgency;
justification: string;
notifyApprovers: boolean;
scheduledTime: string | null;
}
export interface ApprovalFilter {
statuses?: ApprovalStatus[];
urgencies?: ApprovalUrgency[];
environment?: string | null;
}
// Helper functions
export function getUrgencyLabel(urgency: ApprovalUrgency): string {
const labels: Record<ApprovalUrgency, string> = {
low: 'Low',
normal: 'Normal',
high: 'High',
critical: 'Critical',
};
return labels[urgency] || urgency;
}
export function getUrgencyColor(urgency: ApprovalUrgency): string {
const colors: Record<ApprovalUrgency, string> = {
low: '#6b7280',
normal: '#3b82f6',
high: '#f59e0b',
critical: '#ef4444',
};
return colors[urgency] || '#6b7280';
}
export function getStatusLabel(status: ApprovalStatus): string {
const labels: Record<ApprovalStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
};
return labels[status] || status;
}
export function getStatusColor(status: ApprovalStatus): string {
const colors: Record<ApprovalStatus, string> = {
pending: '#f59e0b',
approved: '#10b981',
rejected: '#ef4444',
expired: '#6b7280',
};
return colors[status] || '#6b7280';
}
export function getGateStatusIcon(status: GateStatus): string {
const icons: Record<GateStatus, string> = {
passed: 'check-circle',
failed: 'x-circle',
warning: 'alert-triangle',
pending: 'clock',
skipped: 'minus-circle',
};
return icons[status] || 'circle';
}
export function getGateStatusColor(status: GateStatus): string {
const colors: Record<GateStatus, string> = {
passed: '#10b981',
failed: '#ef4444',
warning: '#f59e0b',
pending: '#6b7280',
skipped: '#9ca3af',
};
return colors[status] || '#6b7280';
}
export function getGateTypeIcon(type: GateType): string {
const icons: Record<GateType, string> = {
security: 'shield',
policy: 'book',
quality: 'chart',
custom: 'cog',
};
return icons[type] || 'circle';
}
export function formatTimeRemaining(expiresAt: string): string {
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'Expired';
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
if (hours > 24) {
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
}

View File

@@ -0,0 +1,333 @@
/**
* Deployment API Client
* Sprint: SPRINT_20260110_111_006_FE_deployment_monitoring_ui
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, BehaviorSubject, interval, map } from 'rxjs';
import type {
DeploymentSummary,
Deployment,
DeploymentTarget,
DeploymentEvent,
LogEntry,
DeploymentMetrics,
} from './deployment.models';
export interface DeploymentFilter {
statuses?: string[];
environments?: string[];
releases?: string[];
}
export interface DeploymentApi {
getDeployments(filter?: DeploymentFilter): Observable<DeploymentSummary[]>;
getDeployment(id: string): Observable<Deployment>;
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]>;
getDeploymentEvents(deploymentId: string): Observable<DeploymentEvent[]>;
getDeploymentMetrics(deploymentId: string): Observable<DeploymentMetrics>;
pause(deploymentId: string): Observable<void>;
resume(deploymentId: string): Observable<void>;
cancel(deploymentId: string): Observable<void>;
rollback(deploymentId: string, targetIds?: string[], reason?: string): Observable<void>;
retryTarget(deploymentId: string, targetId: string): Observable<void>;
subscribeToUpdates(deploymentId: string): Observable<Deployment>;
}
export const DEPLOYMENT_API = new InjectionToken<DeploymentApi>('DEPLOYMENT_API');
@Injectable({ providedIn: 'root' })
export class DeploymentHttpClient implements DeploymentApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/release-orchestrator/deployments';
getDeployments(filter?: DeploymentFilter): Observable<DeploymentSummary[]> {
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.environments?.length) params['environments'] = filter.environments.join(',');
if (filter?.releases?.length) params['releases'] = filter.releases.join(',');
return this.http.get<DeploymentSummary[]>(this.baseUrl, { params });
}
getDeployment(id: string): Observable<Deployment> {
return this.http.get<Deployment>(`${this.baseUrl}/${id}`);
}
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]> {
const url = targetId
? `${this.baseUrl}/${deploymentId}/targets/${targetId}/logs`
: `${this.baseUrl}/${deploymentId}/logs`;
return this.http.get<LogEntry[]>(url);
}
getDeploymentEvents(deploymentId: string): Observable<DeploymentEvent[]> {
return this.http.get<DeploymentEvent[]>(`${this.baseUrl}/${deploymentId}/events`);
}
getDeploymentMetrics(deploymentId: string): Observable<DeploymentMetrics> {
return this.http.get<DeploymentMetrics>(`${this.baseUrl}/${deploymentId}/metrics`);
}
pause(deploymentId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${deploymentId}/pause`, {});
}
resume(deploymentId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${deploymentId}/resume`, {});
}
cancel(deploymentId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${deploymentId}/cancel`, {});
}
rollback(deploymentId: string, targetIds?: string[], reason?: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${deploymentId}/rollback`, { targetIds, reason });
}
retryTarget(deploymentId: string, targetId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${deploymentId}/targets/${targetId}/retry`, {});
}
subscribeToUpdates(deploymentId: string): Observable<Deployment> {
// In production, this would use SignalR
return interval(2000).pipe(map(() => null as unknown as Deployment));
}
}
@Injectable({ providedIn: 'root' })
export class MockDeploymentClient implements DeploymentApi {
private readonly mockDeployments: DeploymentSummary[] = [
{
id: 'dep-001',
releaseId: 'rel-001',
releaseName: 'backend-api',
releaseVersion: '2.5.0',
environmentId: 'env-staging',
environmentName: 'Staging',
status: 'running',
strategy: 'rolling',
progress: 60,
startedAt: new Date(Date.now() - 300000).toISOString(),
completedAt: null,
initiatedBy: 'alice@example.com',
targetCount: 5,
completedTargets: 3,
failedTargets: 0,
},
{
id: 'dep-002',
releaseId: 'rel-002',
releaseName: 'frontend-app',
releaseVersion: '1.12.0',
environmentId: 'env-prod',
environmentName: 'Production',
status: 'completed',
strategy: 'blue_green',
progress: 100,
startedAt: new Date(Date.now() - 3600000).toISOString(),
completedAt: new Date(Date.now() - 3300000).toISOString(),
initiatedBy: 'bob@example.com',
targetCount: 3,
completedTargets: 3,
failedTargets: 0,
},
{
id: 'dep-003',
releaseId: 'rel-003',
releaseName: 'worker-service',
releaseVersion: '3.0.1',
environmentId: 'env-dev',
environmentName: 'Development',
status: 'failed',
strategy: 'all_at_once',
progress: 40,
startedAt: new Date(Date.now() - 7200000).toISOString(),
completedAt: new Date(Date.now() - 7000000).toISOString(),
initiatedBy: 'charlie@example.com',
targetCount: 4,
completedTargets: 2,
failedTargets: 2,
},
{
id: 'dep-004',
releaseId: 'rel-004',
releaseName: 'gateway-service',
releaseVersion: '1.8.0',
environmentId: 'env-staging',
environmentName: 'Staging',
status: 'paused',
strategy: 'canary',
progress: 25,
startedAt: new Date(Date.now() - 600000).toISOString(),
completedAt: null,
initiatedBy: 'alice@example.com',
targetCount: 8,
completedTargets: 2,
failedTargets: 0,
},
];
private readonly mockTargets: Record<string, DeploymentTarget[]> = {
'dep-001': [
{ id: 'tgt-1', name: 'api-server-1', type: 'docker_host', status: 'completed', progress: 100, startedAt: new Date(Date.now() - 280000).toISOString(), completedAt: new Date(Date.now() - 240000).toISOString(), duration: 40000, agentId: 'agent-01', error: null, previousVersion: '2.4.0' },
{ id: 'tgt-2', name: 'api-server-2', type: 'docker_host', status: 'completed', progress: 100, startedAt: new Date(Date.now() - 240000).toISOString(), completedAt: new Date(Date.now() - 200000).toISOString(), duration: 40000, agentId: 'agent-02', error: null, previousVersion: '2.4.0' },
{ id: 'tgt-3', name: 'api-server-3', type: 'docker_host', status: 'completed', progress: 100, startedAt: new Date(Date.now() - 200000).toISOString(), completedAt: new Date(Date.now() - 160000).toISOString(), duration: 40000, agentId: 'agent-03', error: null, previousVersion: '2.4.0' },
{ id: 'tgt-4', name: 'api-server-4', type: 'docker_host', status: 'running', progress: 75, startedAt: new Date(Date.now() - 160000).toISOString(), completedAt: null, duration: null, agentId: 'agent-04', error: null, previousVersion: '2.4.0' },
{ id: 'tgt-5', name: 'api-server-5', type: 'docker_host', status: 'pending', progress: 0, startedAt: null, completedAt: null, duration: null, agentId: 'agent-05', error: null, previousVersion: '2.4.0' },
],
'dep-003': [
{ id: 'tgt-w1', name: 'worker-node-1', type: 'compose_host', status: 'completed', progress: 100, startedAt: new Date(Date.now() - 7180000).toISOString(), completedAt: new Date(Date.now() - 7100000).toISOString(), duration: 80000, agentId: 'agent-w01', error: null, previousVersion: '3.0.0' },
{ id: 'tgt-w2', name: 'worker-node-2', type: 'compose_host', status: 'completed', progress: 100, startedAt: new Date(Date.now() - 7180000).toISOString(), completedAt: new Date(Date.now() - 7090000).toISOString(), duration: 90000, agentId: 'agent-w02', error: null, previousVersion: '3.0.0' },
{ id: 'tgt-w3', name: 'worker-node-3', type: 'compose_host', status: 'failed', progress: 45, startedAt: new Date(Date.now() - 7100000).toISOString(), completedAt: new Date(Date.now() - 7000000).toISOString(), duration: 100000, agentId: 'agent-w03', error: 'Health check failed: service did not respond', previousVersion: '3.0.0' },
{ id: 'tgt-w4', name: 'worker-node-4', type: 'compose_host', status: 'failed', progress: 30, startedAt: new Date(Date.now() - 7100000).toISOString(), completedAt: new Date(Date.now() - 7010000).toISOString(), duration: 90000, agentId: 'agent-w04', error: 'Container exited with code 1', previousVersion: '3.0.0' },
],
};
private progressSubject = new BehaviorSubject<number>(60);
getDeployments(filter?: DeploymentFilter): Observable<DeploymentSummary[]> {
let result = [...this.mockDeployments];
if (filter?.statuses?.length) {
result = result.filter(d => filter.statuses!.includes(d.status));
}
if (filter?.environments?.length) {
result = result.filter(d => filter.environments!.includes(d.environmentId));
}
return of(result);
}
getDeployment(id: string): Observable<Deployment> {
const summary = this.mockDeployments.find(d => d.id === id);
if (!summary) {
return of(null as unknown as Deployment);
}
const targets = this.mockTargets[id] || this.generateMockTargets(summary.targetCount);
return of({
...summary,
targets,
currentStep: summary.status === 'running' ? 'Deploying to target 4' : null,
canPause: summary.status === 'running',
canResume: summary.status === 'paused',
canCancel: ['running', 'paused', 'pending'].includes(summary.status),
canRollback: ['completed', 'failed'].includes(summary.status),
});
}
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]> {
const baseLogs: LogEntry[] = [
{ timestamp: new Date(Date.now() - 280000).toISOString(), level: 'info', source: 'orchestrator', targetId: null, message: 'Deployment started' },
{ timestamp: new Date(Date.now() - 279000).toISOString(), level: 'info', source: 'orchestrator', targetId: null, message: 'Validating deployment configuration...' },
{ timestamp: new Date(Date.now() - 278000).toISOString(), level: 'debug', source: 'orchestrator', targetId: null, message: 'Configuration validated successfully' },
{ timestamp: new Date(Date.now() - 275000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Starting deployment to api-server-1' },
{ timestamp: new Date(Date.now() - 274000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Pulling image: registry.example.com/backend-api:2.5.0' },
{ timestamp: new Date(Date.now() - 260000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Image pulled successfully' },
{ timestamp: new Date(Date.now() - 259000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Stopping existing container...' },
{ timestamp: new Date(Date.now() - 255000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Starting new container...' },
{ timestamp: new Date(Date.now() - 250000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Running health checks...' },
{ timestamp: new Date(Date.now() - 245000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Health check passed' },
{ timestamp: new Date(Date.now() - 240000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Deployment to api-server-1 completed' },
{ timestamp: new Date(Date.now() - 238000).toISOString(), level: 'info', source: 'agent-02', targetId: 'tgt-2', message: 'Starting deployment to api-server-2' },
{ timestamp: new Date(Date.now() - 200000).toISOString(), level: 'info', source: 'agent-02', targetId: 'tgt-2', message: 'Deployment to api-server-2 completed' },
{ timestamp: new Date(Date.now() - 198000).toISOString(), level: 'info', source: 'agent-03', targetId: 'tgt-3', message: 'Starting deployment to api-server-3' },
{ timestamp: new Date(Date.now() - 160000).toISOString(), level: 'info', source: 'agent-03', targetId: 'tgt-3', message: 'Deployment to api-server-3 completed' },
{ timestamp: new Date(Date.now() - 158000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Starting deployment to api-server-4' },
{ timestamp: new Date(Date.now() - 155000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Pulling image: registry.example.com/backend-api:2.5.0' },
{ timestamp: new Date(Date.now() - 140000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Image pulled successfully' },
{ timestamp: new Date(Date.now() - 138000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Stopping existing container...' },
{ timestamp: new Date(Date.now() - 130000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Starting new container...' },
{ timestamp: new Date(Date.now() - 120000).toISOString(), level: 'info', source: 'agent-04', targetId: 'tgt-4', message: 'Running health checks...' },
];
if (targetId) {
return of(baseLogs.filter(l => l.targetId === targetId));
}
return of(baseLogs);
}
getDeploymentEvents(deploymentId: string): Observable<DeploymentEvent[]> {
return of([
{ id: 'evt-1', type: 'started', targetId: null, targetName: null, message: 'Deployment started', timestamp: new Date(Date.now() - 280000).toISOString() },
{ id: 'evt-2', type: 'target_started', targetId: 'tgt-1', targetName: 'api-server-1', message: 'Started deploying to api-server-1', timestamp: new Date(Date.now() - 275000).toISOString() },
{ id: 'evt-3', type: 'target_completed', targetId: 'tgt-1', targetName: 'api-server-1', message: 'Completed deployment to api-server-1', timestamp: new Date(Date.now() - 240000).toISOString() },
{ id: 'evt-4', type: 'target_started', targetId: 'tgt-2', targetName: 'api-server-2', message: 'Started deploying to api-server-2', timestamp: new Date(Date.now() - 238000).toISOString() },
{ id: 'evt-5', type: 'target_completed', targetId: 'tgt-2', targetName: 'api-server-2', message: 'Completed deployment to api-server-2', timestamp: new Date(Date.now() - 200000).toISOString() },
{ id: 'evt-6', type: 'target_started', targetId: 'tgt-3', targetName: 'api-server-3', message: 'Started deploying to api-server-3', timestamp: new Date(Date.now() - 198000).toISOString() },
{ id: 'evt-7', type: 'target_completed', targetId: 'tgt-3', targetName: 'api-server-3', message: 'Completed deployment to api-server-3', timestamp: new Date(Date.now() - 160000).toISOString() },
{ id: 'evt-8', type: 'target_started', targetId: 'tgt-4', targetName: 'api-server-4', message: 'Started deploying to api-server-4', timestamp: new Date(Date.now() - 158000).toISOString() },
]);
}
getDeploymentMetrics(deploymentId: string): Observable<DeploymentMetrics> {
return of({
totalDuration: 280000,
averageTargetDuration: 45000,
successRate: 100,
rollbackCount: 0,
imagesPulled: 4,
containersStarted: 4,
containersRemoved: 3,
healthChecksPerformed: 12,
});
}
pause(deploymentId: string): Observable<void> {
return of(void 0);
}
resume(deploymentId: string): Observable<void> {
return of(void 0);
}
cancel(deploymentId: string): Observable<void> {
return of(void 0);
}
rollback(deploymentId: string, targetIds?: string[], reason?: string): Observable<void> {
return of(void 0);
}
retryTarget(deploymentId: string, targetId: string): Observable<void> {
return of(void 0);
}
subscribeToUpdates(deploymentId: string): Observable<Deployment> {
// Simulate real-time updates
return interval(3000).pipe(
map(() => {
const progress = Math.min(this.progressSubject.value + 5, 100);
this.progressSubject.next(progress);
const summary = this.mockDeployments.find(d => d.id === deploymentId)!;
return {
...summary,
progress,
targets: this.mockTargets[deploymentId] || [],
currentStep: progress < 100 ? 'Deploying...' : null,
canPause: progress < 100,
canResume: false,
canCancel: progress < 100,
canRollback: progress >= 100,
};
})
);
}
private generateMockTargets(count: number): DeploymentTarget[] {
const targets: DeploymentTarget[] = [];
for (let i = 0; i < count; i++) {
targets.push({
id: `tgt-gen-${i}`,
name: `server-${i + 1}`,
type: 'docker_host',
status: i < 2 ? 'completed' : i === 2 ? 'running' : 'pending',
progress: i < 2 ? 100 : i === 2 ? 50 : 0,
startedAt: i <= 2 ? new Date(Date.now() - (count - i) * 60000).toISOString() : null,
completedAt: i < 2 ? new Date(Date.now() - (count - i - 1) * 60000).toISOString() : null,
duration: i < 2 ? 60000 : null,
agentId: `agent-${i + 1}`,
error: null,
previousVersion: '1.0.0',
});
}
return targets;
}
}

View File

@@ -0,0 +1,185 @@
/**
* Deployment Models and Types
* Sprint: SPRINT_20260110_111_006_FE_deployment_monitoring_ui
*/
export type DeploymentStatus =
| 'pending'
| 'running'
| 'paused'
| 'completed'
| 'failed'
| 'cancelled'
| 'rolling_back';
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'all_at_once';
export type TargetStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
export type TargetType = 'docker_host' | 'compose_host' | 'ecs_service' | 'nomad_job';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type DeploymentEventType =
| 'started'
| 'target_started'
| 'target_completed'
| 'target_failed'
| 'paused'
| 'resumed'
| 'completed'
| 'failed'
| 'cancelled'
| 'rollback_started';
export interface DeploymentSummary {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
environmentId: string;
environmentName: string;
status: DeploymentStatus;
strategy: DeploymentStrategy;
progress: number;
startedAt: string;
completedAt: string | null;
initiatedBy: string;
targetCount: number;
completedTargets: number;
failedTargets: number;
}
export interface Deployment extends DeploymentSummary {
targets: DeploymentTarget[];
currentStep: string | null;
canPause: boolean;
canResume: boolean;
canCancel: boolean;
canRollback: boolean;
}
export interface DeploymentTarget {
id: string;
name: string;
type: TargetType;
status: TargetStatus;
progress: number;
startedAt: string | null;
completedAt: string | null;
duration: number | null;
agentId: string;
error: string | null;
previousVersion: string | null;
}
export interface DeploymentEvent {
id: string;
type: DeploymentEventType;
targetId: string | null;
targetName: string | null;
message: string;
timestamp: string;
}
export interface LogEntry {
timestamp: string;
level: LogLevel;
source: string;
targetId: string | null;
message: string;
}
export interface DeploymentMetrics {
totalDuration: number;
averageTargetDuration: number;
successRate: number;
rollbackCount: number;
imagesPulled: number;
containersStarted: number;
containersRemoved: number;
healthChecksPerformed: number;
}
// Helper functions
export function getStatusLabel(status: DeploymentStatus): string {
const labels: Record<DeploymentStatus, string> = {
pending: 'Pending',
running: 'Running',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
rolling_back: 'Rolling Back',
};
return labels[status] || status;
}
export function getStatusColor(status: DeploymentStatus): string {
const colors: Record<DeploymentStatus, string> = {
pending: '#6b7280',
running: '#3b82f6',
paused: '#f59e0b',
completed: '#10b981',
failed: '#ef4444',
cancelled: '#9ca3af',
rolling_back: '#f59e0b',
};
return colors[status] || '#6b7280';
}
export function getTargetStatusColor(status: TargetStatus): string {
const colors: Record<TargetStatus, string> = {
pending: '#6b7280',
running: '#3b82f6',
completed: '#10b981',
failed: '#ef4444',
skipped: '#9ca3af',
};
return colors[status] || '#6b7280';
}
export function getTargetTypeIcon(type: TargetType): string {
const icons: Record<TargetType, string> = {
docker_host: 'D',
compose_host: 'C',
ecs_service: 'E',
nomad_job: 'N',
};
return icons[type] || '?';
}
export function getLogLevelColor(level: LogLevel): string {
const colors: Record<LogLevel, string> = {
debug: '#6b7280',
info: '#3b82f6',
warn: '#f59e0b',
error: '#ef4444',
};
return colors[level] || '#6b7280';
}
export function getStrategyLabel(strategy: DeploymentStrategy): string {
const labels: Record<DeploymentStrategy, string> = {
rolling: 'Rolling Update',
blue_green: 'Blue-Green',
canary: 'Canary',
all_at_once: 'All at Once',
};
return labels[strategy] || strategy;
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
export function formatTimestamp(timestamp: string): string {
return new Date(timestamp).toISOString().split('T')[1].slice(0, 12);
}

View File

@@ -0,0 +1,581 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
PolicyProfile,
PolicyProfileType,
PolicySimulationRequest,
PolicySimulationResult,
PolicySimulationStatus,
BundleSimulationResult,
FeedFreshness,
FeedFreshnessSummary,
FeedStalenessStatus,
AirGapStatus,
CreatePolicyProfileRequest,
UpdatePolicyProfileRequest,
PolicyValidationResult,
GateResult,
ArtifactSimulationResult,
} from './policy-gates.models';
/**
* Injection token for Policy Gates API client.
*/
export const POLICY_GATES_API = new InjectionToken<PolicyGatesApi>('POLICY_GATES_API');
/**
* Policy Gates API interface.
*/
export interface PolicyGatesApi {
// Profile management
listProfiles(includeBuiltin?: boolean): Observable<readonly PolicyProfile[]>;
getProfile(profileId: string): Observable<PolicyProfile>;
getProfileByName(name: string): Observable<PolicyProfile | null>;
createProfile(request: CreatePolicyProfileRequest): Observable<PolicyProfile>;
updateProfile(profileId: string, request: UpdatePolicyProfileRequest): Observable<PolicyProfile>;
deleteProfile(profileId: string): Observable<boolean>;
setDefaultProfile(profileId: string): Observable<void>;
getEffectiveProfile(): Observable<PolicyProfile>;
validatePolicyYaml(yaml: string): Observable<PolicyValidationResult>;
// Simulation
simulate(request: PolicySimulationRequest): Observable<PolicySimulationResult>;
simulateBundle(promotionId: string, profileIdOrName?: string): Observable<BundleSimulationResult>;
// Feed freshness
getFeedFreshnessSummary(): Observable<FeedFreshnessSummary>;
getFeedFreshness(feedName: string): Observable<FeedFreshness | null>;
// Air-gap status
getAirGapStatus(): Observable<AirGapStatus>;
}
// =============================================================================
// Mock Data Fixtures
// =============================================================================
const mockProfiles: PolicyProfile[] = [
{
id: 'profile-001',
name: 'lenient-dev',
displayName: 'Lenient (Development)',
description: 'Permissive profile for development environments. Allows most artifacts through with warnings.',
profileType: 'lenient_dev',
isDefault: false,
isBuiltin: true,
policyYaml: `name: lenient-dev
display_name: "Lenient (Development)"
profile_type: lenient_dev
requires:
- attestation: sbom.cyclonedx
required: true
- attestation: provenance.intoto
required: false
- vex.status:
allow: ["affected", "not_affected", "under_investigation"]
- reachability.runtime.max_age_days: 30
on_fail:
soft: ["missing_runtime_evidence", "missing_vex", "stale_reachability"]
hard: []`,
policyDigest: 'sha256:a1b2c3d4e5f6',
attestationRequirements: [
{ type: 'sbom.cyclonedx', required: true },
{ type: 'provenance.intoto', required: false },
],
vexRequirements: {
allow: ['affected', 'not_affected', 'under_investigation'],
requireRationale: false,
},
reachabilityRequirements: { runtimeMaxAgeDays: 30 },
onFailSoft: ['missing_runtime_evidence', 'missing_vex', 'stale_reachability'],
onFailHard: [],
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
{
id: 'profile-002',
name: 'standard',
displayName: 'Standard',
description: 'Balanced profile suitable for staging and most production workloads.',
profileType: 'standard',
isDefault: true,
isBuiltin: true,
policyYaml: `name: standard
display_name: "Standard"
profile_type: standard
requires:
- attestation: sbom.cyclonedx
required: true
- attestation: provenance.intoto
required: true
- vex.status:
allow: ["not_affected", "under_investigation"]
require_rationale: true
- reachability.runtime.max_age_days: 14
on_fail:
soft: ["missing_runtime_evidence"]
hard: ["missing_sbom", "missing_provenance", "affected_cve"]`,
policyDigest: 'sha256:b2c3d4e5f6a7',
attestationRequirements: [
{ type: 'sbom.cyclonedx', required: true },
{ type: 'provenance.intoto', required: true },
],
vexRequirements: {
allow: ['not_affected', 'under_investigation'],
requireRationale: true,
},
reachabilityRequirements: { runtimeMaxAgeDays: 14 },
onFailSoft: ['missing_runtime_evidence'],
onFailHard: ['missing_sbom', 'missing_provenance', 'affected_cve'],
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
{
id: 'profile-003',
name: 'strict-prod',
displayName: 'Strict (Production)',
description: 'Strict profile for production environments requiring signed attestations and verified provenance.',
profileType: 'strict_prod',
isDefault: false,
isBuiltin: true,
policyYaml: `name: strict-prod
display_name: "Strict (Production)"
profile_type: strict_prod
requires:
- attestation: sbom.cyclonedx
signer: ["stella-authority-prod"]
- attestation: provenance.intoto
slsa_level: "L2+"
- vex.status:
allow: ["not_affected:component_not_present", "not_affected:fix_applied"]
require_rationale: true
- reachability.runtime.max_age_days: 7
- rekor_mirror.sync_lag_minutes: 60
on_fail:
soft: ["missing_runtime_evidence"]
hard: ["unsigned_provenance", "rekor_unverifiable", "affected_cve"]`,
policyDigest: 'sha256:c3d4e5f6a7b8',
attestationRequirements: [
{ type: 'sbom.cyclonedx', required: true, signers: ['stella-authority-prod'] },
{ type: 'provenance.intoto', required: true, slsaLevel: 'L2+' },
],
vexRequirements: {
allow: ['not_affected:component_not_present', 'not_affected:fix_applied'],
requireRationale: true,
},
reachabilityRequirements: { runtimeMaxAgeDays: 7 },
onFailSoft: ['missing_runtime_evidence'],
onFailHard: ['unsigned_provenance', 'rekor_unverifiable', 'affected_cve'],
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
{
id: 'profile-004',
name: 'gov-defense',
displayName: 'Government/Defense (PQ-Ready)',
description: 'Highest security profile for government and defense workloads with post-quantum readiness.',
profileType: 'gov_defense',
isDefault: false,
isBuiltin: true,
policyYaml: `name: gov-defense
display_name: "Government/Defense (PQ-Ready)"
profile_type: gov_defense
requires:
- attestation: sbom.cyclonedx
signer: ["stella-authority-prod"]
algorithm: ["ecdsa-p384", "dilithium2?"]
- attestation: provenance.intoto
slsa_level: "L3+"
steps: ["build", "scan", "vex", "review"]
- vex.status:
allow: ["not_affected:component_not_present", "not_affected:fix_applied"]
require_rationale: true
require_justification: true
- reachability.runtime.max_age_days: 3
- rekor_mirror.sync_lag_minutes: 30
on_fail:
soft: []
hard: ["unsigned_provenance", "missing_review", "affected_cve", "stale_evidence"]`,
policyDigest: 'sha256:d4e5f6a7b8c9',
attestationRequirements: [
{ type: 'sbom.cyclonedx', required: true, signers: ['stella-authority-prod'] },
{ type: 'provenance.intoto', required: true, slsaLevel: 'L3+' },
],
vexRequirements: {
allow: ['not_affected:component_not_present', 'not_affected:fix_applied'],
requireRationale: true,
requireJustification: true,
},
reachabilityRequirements: { runtimeMaxAgeDays: 3 },
onFailSoft: [],
onFailHard: ['unsigned_provenance', 'missing_review', 'affected_cve', 'stale_evidence'],
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
];
const mockFeedFreshness: FeedFreshness[] = [
{
id: 'feed-001',
tenantId: 'tenant-001',
feedName: 'NVD',
feedUrl: 'https://nvd.nist.gov/feeds/json/cve/1.1',
lastSyncAt: '2026-01-12T09:30:00Z',
lastSuccessAt: '2026-01-12T09:30:00Z',
stalenessSeconds: 1800, // 30 minutes
stalenessStatus: 'fresh',
entryCount: 250000,
updatedAt: '2026-01-12T09:30:00Z',
},
{
id: 'feed-002',
tenantId: 'tenant-001',
feedName: 'GHSA',
feedUrl: 'https://github.com/advisories',
lastSyncAt: '2026-01-12T07:00:00Z',
lastSuccessAt: '2026-01-12T07:00:00Z',
stalenessSeconds: 10800, // 3 hours
stalenessStatus: 'fresh',
entryCount: 45000,
updatedAt: '2026-01-12T07:00:00Z',
},
{
id: 'feed-003',
tenantId: 'tenant-001',
feedName: 'OSV',
feedUrl: 'https://osv.dev',
lastSyncAt: '2026-01-12T04:00:00Z',
lastSuccessAt: '2026-01-12T04:00:00Z',
stalenessSeconds: 21600, // 6 hours
stalenessStatus: 'warning',
entryCount: 120000,
updatedAt: '2026-01-12T04:00:00Z',
},
{
id: 'feed-004',
tenantId: 'tenant-001',
feedName: 'EPSS',
feedUrl: 'https://epss.cyentia.com',
lastSyncAt: '2026-01-11T10:00:00Z',
lastSuccessAt: '2026-01-11T10:00:00Z',
stalenessSeconds: 86400, // 24 hours
stalenessStatus: 'stale',
entryCount: 200000,
updatedAt: '2026-01-11T10:00:00Z',
},
{
id: 'feed-005',
tenantId: 'tenant-001',
feedName: 'REKOR',
feedUrl: 'https://rekor.sigstore.dev',
lastSyncAt: '2026-01-12T09:45:00Z',
lastSuccessAt: '2026-01-12T09:45:00Z',
stalenessSeconds: 900, // 15 minutes
stalenessStatus: 'fresh',
entryCount: 15000000,
updatedAt: '2026-01-12T09:45:00Z',
},
];
const mockFeedFreshnessSummary: FeedFreshnessSummary = {
totalFeeds: 5,
freshCount: 3,
staleCount: 1,
warningCount: 1,
unknownCount: 0,
overallStatus: 'warning',
oldestSyncAt: '2026-01-11T10:00:00Z',
feeds: mockFeedFreshness,
};
const mockAirGapStatus: AirGapStatus = {
isSealed: false,
allFeedsSynced: false,
staleFeedNames: ['EPSS'],
lastRekorSync: '2026-01-12T09:45:00Z',
warnings: ['Feed EPSS is stale (>24h since last sync)'],
};
const mockPassingSimulation: PolicySimulationResult = {
simulationId: 'sim-001',
profileId: 'profile-002',
profileName: 'standard',
status: 'pass',
gateResults: [
{
gateName: 'SBOM Attestation',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'CycloneDX SBOM attestation present and verified',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Provenance',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'SLSA Provenance L2 attestation verified',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'VEX Status',
gateType: 'vex_status',
passed: true,
isMissingEvidence: false,
details: 'All vulnerabilities have status not_affected with rationale',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Reachability',
gateType: 'reachability',
passed: true,
isMissingEvidence: false,
details: 'Runtime reachability analysis is 5 days old (max: 14 days)',
evaluatedAt: '2026-01-12T10:00:00Z',
},
],
missingEvidence: [],
softFailures: [],
hardFailures: [],
inputDigest: 'sha256:input123',
executedAt: '2026-01-12T10:00:00Z',
durationMs: 150,
cachedUntil: '2026-01-12T11:00:00Z',
};
const mockFailingSimulation: PolicySimulationResult = {
simulationId: 'sim-002',
profileId: 'profile-003',
profileName: 'strict-prod',
status: 'fail',
gateResults: [
{
gateName: 'SBOM Attestation',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'CycloneDX SBOM attestation present and verified',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Provenance',
gateType: 'attestation',
passed: false,
isMissingEvidence: true,
details: 'Missing provenance attestation with SLSA Level L2+',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'VEX Status',
gateType: 'vex_status',
passed: false,
isMissingEvidence: false,
details: 'CVE-2024-1234 has status affected without fix_applied',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Rekor Verification',
gateType: 'rekor',
passed: false,
isMissingEvidence: false,
details: 'Rekor mirror is 90 minutes behind (max: 60 minutes)',
evaluatedAt: '2026-01-12T10:00:00Z',
},
],
missingEvidence: ['provenance.intoto'],
softFailures: [],
hardFailures: ['unsigned_provenance', 'affected_cve', 'rekor_unverifiable'],
inputDigest: 'sha256:input456',
executedAt: '2026-01-12T10:00:00Z',
durationMs: 180,
};
const mockBundleSimulation: BundleSimulationResult = {
promotionId: 'promo-001',
profileId: 'profile-002',
profileName: 'standard',
overallStatus: 'warn',
artifactResults: [
{
artifactDigest: 'sha256:abc123',
artifactName: 'api-service:v1.2.3',
status: 'pass',
gateResults: mockPassingSimulation.gateResults,
blockingGates: [],
warningGates: [],
},
{
artifactDigest: 'sha256:def456',
artifactName: 'worker-service:v1.2.3',
status: 'warn',
gateResults: [
{
gateName: 'SBOM Attestation',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'CycloneDX SBOM attestation present',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Reachability',
gateType: 'reachability',
passed: false,
isMissingEvidence: true,
details: 'No runtime reachability analysis available',
evaluatedAt: '2026-01-12T10:00:00Z',
},
],
blockingGates: [],
warningGates: ['missing_runtime_evidence'],
},
],
allGatesPassed: false,
blockingGates: [],
warningGates: ['missing_runtime_evidence'],
executedAt: '2026-01-12T10:00:00Z',
durationMs: 320,
};
// =============================================================================
// Mock API Implementation
// =============================================================================
@Injectable({ providedIn: 'root' })
export class MockPolicyGatesApi implements PolicyGatesApi {
listProfiles(includeBuiltin = true): Observable<readonly PolicyProfile[]> {
const profiles = includeBuiltin
? mockProfiles
: mockProfiles.filter((p) => !p.isBuiltin);
return of(profiles).pipe(delay(200));
}
getProfile(profileId: string): Observable<PolicyProfile> {
const profile = mockProfiles.find((p) => p.id === profileId);
if (!profile) {
throw new Error(`Profile not found: ${profileId}`);
}
return of(profile).pipe(delay(150));
}
getProfileByName(name: string): Observable<PolicyProfile | null> {
const profile = mockProfiles.find((p) => p.name === name);
return of(profile ?? null).pipe(delay(150));
}
createProfile(request: CreatePolicyProfileRequest): Observable<PolicyProfile> {
const newProfile: PolicyProfile = {
id: `profile-${Date.now()}`,
name: request.name,
displayName: request.displayName,
description: request.description,
profileType: request.profileType,
isDefault: request.setAsDefault ?? false,
isBuiltin: false,
policyYaml: request.policyYaml,
policyDigest: `sha256:${Date.now().toString(16)}`,
attestationRequirements: request.attestationRequirements ?? [],
vexRequirements: request.vexRequirements,
reachabilityRequirements: request.reachabilityRequirements,
onFailSoft: request.onFailSoft ?? [],
onFailHard: request.onFailHard ?? [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return of(newProfile).pipe(delay(300));
}
updateProfile(profileId: string, request: UpdatePolicyProfileRequest): Observable<PolicyProfile> {
const profile = mockProfiles.find((p) => p.id === profileId);
if (!profile) {
throw new Error(`Profile not found: ${profileId}`);
}
const updated: PolicyProfile = {
...profile,
displayName: request.displayName ?? profile.displayName,
description: request.description ?? profile.description,
policyYaml: request.policyYaml ?? profile.policyYaml,
attestationRequirements: request.attestationRequirements ?? profile.attestationRequirements,
vexRequirements: request.vexRequirements ?? profile.vexRequirements,
reachabilityRequirements: request.reachabilityRequirements ?? profile.reachabilityRequirements,
onFailSoft: request.onFailSoft ?? profile.onFailSoft,
onFailHard: request.onFailHard ?? profile.onFailHard,
updatedAt: new Date().toISOString(),
};
return of(updated).pipe(delay(250));
}
deleteProfile(profileId: string): Observable<boolean> {
const profile = mockProfiles.find((p) => p.id === profileId);
if (!profile) {
return of(false).pipe(delay(150));
}
if (profile.isBuiltin) {
throw new Error('Cannot delete built-in profile');
}
return of(true).pipe(delay(200));
}
setDefaultProfile(profileId: string): Observable<void> {
const profile = mockProfiles.find((p) => p.id === profileId);
if (!profile) {
throw new Error(`Profile not found: ${profileId}`);
}
return of(undefined).pipe(delay(200));
}
getEffectiveProfile(): Observable<PolicyProfile> {
const defaultProfile = mockProfiles.find((p) => p.isDefault);
return of(defaultProfile ?? mockProfiles[0]).pipe(delay(150));
}
validatePolicyYaml(yaml: string): Observable<PolicyValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
if (!yaml.includes('name:')) {
errors.push('Policy must have a name field');
}
if (!yaml.includes('requires:') && !yaml.includes('on_fail:')) {
errors.push('Policy must have requires or on_fail section');
}
if (!yaml.includes('attestation:')) {
warnings.push('Policy has no attestation requirements');
}
return of({
valid: errors.length === 0,
errors,
warnings,
}).pipe(delay(100));
}
simulate(request: PolicySimulationRequest): Observable<PolicySimulationResult> {
// Return different results based on profile
const isStrict = request.profileIdOrName?.includes('strict') ||
request.profileIdOrName === 'profile-003';
return of(isStrict ? mockFailingSimulation : mockPassingSimulation).pipe(delay(300));
}
simulateBundle(promotionId: string, profileIdOrName?: string): Observable<BundleSimulationResult> {
return of({
...mockBundleSimulation,
promotionId,
profileName: profileIdOrName ?? mockBundleSimulation.profileName,
}).pipe(delay(400));
}
getFeedFreshnessSummary(): Observable<FeedFreshnessSummary> {
return of(mockFeedFreshnessSummary).pipe(delay(200));
}
getFeedFreshness(feedName: string): Observable<FeedFreshness | null> {
const feed = mockFeedFreshness.find((f) => f.feedName === feedName);
return of(feed ?? null).pipe(delay(150));
}
getAirGapStatus(): Observable<AirGapStatus> {
return of(mockAirGapStatus).pipe(delay(200));
}
}

View File

@@ -0,0 +1,290 @@
/**
* Policy Gates API Models
*
* Models for policy profile management, gate simulation, and feed freshness tracking.
*/
// =============================================================================
// Enums and Type Definitions
// =============================================================================
export type PolicyProfileType =
| 'lenient_dev'
| 'standard'
| 'strict_prod'
| 'gov_defense'
| 'custom';
export type PolicySimulationStatus = 'pass' | 'fail' | 'warn' | 'error';
export type PolicySimulationType = 'preview' | 'whatif' | 'bundle';
export type FeedStalenessStatus = 'fresh' | 'warning' | 'stale' | 'unknown';
// =============================================================================
// Policy Profile Models
// =============================================================================
export interface AttestationRequirement {
type: string;
required: boolean;
signers?: readonly string[];
slsaLevel?: string;
}
export interface VexRequirements {
allow: readonly string[];
requireRationale: boolean;
requireJustification?: boolean;
}
export interface ReachabilityRequirements {
runtimeMaxAgeDays: number;
}
export interface PolicyProfile {
id: string;
tenantId?: string;
name: string;
displayName: string;
description?: string;
profileType: PolicyProfileType;
isDefault: boolean;
isBuiltin: boolean;
policyYaml: string;
policyDigest: string;
attestationRequirements: readonly AttestationRequirement[];
vexRequirements?: VexRequirements;
reachabilityRequirements?: ReachabilityRequirements;
onFailSoft: readonly string[];
onFailHard: readonly string[];
createdBy?: string;
createdAt: string;
updatedAt: string;
}
export interface CreatePolicyProfileRequest {
name: string;
displayName: string;
description?: string;
profileType: PolicyProfileType;
policyYaml: string;
attestationRequirements?: readonly AttestationRequirement[];
vexRequirements?: VexRequirements;
reachabilityRequirements?: ReachabilityRequirements;
onFailSoft?: readonly string[];
onFailHard?: readonly string[];
setAsDefault?: boolean;
}
export interface UpdatePolicyProfileRequest {
displayName?: string;
description?: string;
policyYaml?: string;
attestationRequirements?: readonly AttestationRequirement[];
vexRequirements?: VexRequirements;
reachabilityRequirements?: ReachabilityRequirements;
onFailSoft?: readonly string[];
onFailHard?: readonly string[];
setAsDefault?: boolean;
}
// =============================================================================
// Gate Simulation Models
// =============================================================================
export interface GateResult {
gateName: string;
gateType: string;
passed: boolean;
isMissingEvidence: boolean;
details: string;
evaluatedAt: string;
}
export interface PolicySimulationRequest {
profileIdOrName?: string;
simulationType: PolicySimulationType;
promotionId?: string;
artifactDigests?: readonly string[];
availableAttestations?: readonly string[];
vexStatus?: string;
vexRationale?: string;
reachabilityAnalysisAgeDays?: number;
forceFresh?: boolean;
}
export interface PolicySimulationResult {
simulationId: string;
profileId: string;
profileName: string;
status: PolicySimulationStatus;
gateResults: readonly GateResult[];
missingEvidence: readonly string[];
softFailures: readonly string[];
hardFailures: readonly string[];
inputDigest: string;
executedAt: string;
durationMs: number;
cachedUntil?: string;
}
export interface ArtifactSimulationResult {
artifactDigest: string;
artifactName?: string;
status: PolicySimulationStatus;
gateResults: readonly GateResult[];
blockingGates: readonly string[];
warningGates: readonly string[];
}
export interface BundleSimulationResult {
promotionId: string;
profileId: string;
profileName: string;
overallStatus: PolicySimulationStatus;
artifactResults: readonly ArtifactSimulationResult[];
allGatesPassed: boolean;
blockingGates: readonly string[];
warningGates: readonly string[];
executedAt: string;
durationMs: number;
}
// =============================================================================
// Feed Freshness Models
// =============================================================================
export interface FeedFreshness {
id: string;
tenantId: string;
feedName: string;
feedUrl?: string;
lastSyncAt?: string;
lastSuccessAt?: string;
stalenessSeconds?: number;
stalenessStatus: FeedStalenessStatus;
entryCount?: number;
errorMessage?: string;
updatedAt: string;
}
export interface FeedFreshnessSummary {
totalFeeds: number;
freshCount: number;
staleCount: number;
warningCount: number;
unknownCount: number;
overallStatus: FeedStalenessStatus;
oldestSyncAt?: string;
feeds: readonly FeedFreshness[];
}
export interface AirGapStatus {
isSealed: boolean;
allFeedsSynced: boolean;
staleFeedNames: readonly string[];
lastRekorSync?: string;
warnings: readonly string[];
sealedAt?: string;
}
export interface UpdateFeedFreshnessRequest {
feedName: string;
feedUrl?: string;
syncSuccessful: boolean;
entryCount?: number;
errorMessage?: string;
}
// =============================================================================
// YAML Validation Models
// =============================================================================
export interface PolicyValidationResult {
valid: boolean;
errors: readonly string[];
warnings: readonly string[];
}
// =============================================================================
// Helper Functions
// =============================================================================
export function getProfileTypeLabel(type: PolicyProfileType): string {
const labels: Record<PolicyProfileType, string> = {
lenient_dev: 'Lenient (Development)',
standard: 'Standard',
strict_prod: 'Strict (Production)',
gov_defense: 'Government/Defense',
custom: 'Custom',
};
return labels[type] || type;
}
export function getProfileTypeColor(type: PolicyProfileType): string {
const colors: Record<PolicyProfileType, string> = {
lenient_dev: '#10b981', // green
standard: '#3b82f6', // blue
strict_prod: '#f59e0b', // amber
gov_defense: '#8b5cf6', // purple
custom: '#6b7280', // gray
};
return colors[type] || '#6b7280';
}
export function getSimulationStatusLabel(status: PolicySimulationStatus): string {
const labels: Record<PolicySimulationStatus, string> = {
pass: 'Passed',
fail: 'Failed',
warn: 'Warning',
error: 'Error',
};
return labels[status] || status;
}
export function getSimulationStatusColor(status: PolicySimulationStatus): string {
const colors: Record<PolicySimulationStatus, string> = {
pass: '#10b981', // green
fail: '#ef4444', // red
warn: '#f59e0b', // amber
error: '#6b7280', // gray
};
return colors[status] || '#6b7280';
}
export function getFeedStatusLabel(status: FeedStalenessStatus): string {
const labels: Record<FeedStalenessStatus, string> = {
fresh: 'Fresh',
warning: 'Warning',
stale: 'Stale',
unknown: 'Unknown',
};
return labels[status] || status;
}
export function getFeedStatusColor(status: FeedStalenessStatus): string {
const colors: Record<FeedStalenessStatus, string> = {
fresh: '#10b981', // green
warning: '#f59e0b', // amber
stale: '#ef4444', // red
unknown: '#6b7280', // gray
};
return colors[status] || '#6b7280';
}
export function formatStalenessTime(seconds: number): string {
if (seconds < 60) {
return `${seconds}s ago`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}m ago`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `${hours}h ago`;
}
const days = Math.floor(seconds / 86400);
return `${days}d ago`;
}

View File

@@ -0,0 +1,143 @@
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { AppConfigService } from '../config/app-config.service';
import type { DashboardData, PipelineData, PendingApproval, ActiveDeployment, RecentRelease } from './release-dashboard.models';
export const RELEASE_DASHBOARD_API_BASE_URL = new InjectionToken<string>('RELEASE_DASHBOARD_API_BASE_URL');
export interface ReleaseDashboardApi {
getDashboardData(): Observable<DashboardData>;
approvePromotion(id: string): Observable<void>;
rejectPromotion(id: string, reason?: string): Observable<void>;
}
export const RELEASE_DASHBOARD_API = new InjectionToken<ReleaseDashboardApi>('RELEASE_DASHBOARD_API');
@Injectable({ providedIn: 'root' })
export class ReleaseDashboardHttpClient implements ReleaseDashboardApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(RELEASE_DASHBOARD_API_BASE_URL);
getDashboardData(): Observable<DashboardData> {
return this.http.get<DashboardData>(`${this.baseUrl}/dashboard`);
}
approvePromotion(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/promotions/${id}/approve`, {});
}
rejectPromotion(id: string, reason?: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/promotions/${id}/reject`, { reason });
}
}
@Injectable({ providedIn: 'root' })
export class MockReleaseDashboardClient implements ReleaseDashboardApi {
getDashboardData(): Observable<DashboardData> {
const mockData: DashboardData = {
pipelineData: {
environments: [
{ id: 'dev', name: 'dev', displayName: 'Development', order: 1, releaseCount: 5, pendingCount: 0, healthStatus: 'healthy' },
{ id: 'staging', name: 'staging', displayName: 'Staging', order: 2, releaseCount: 3, pendingCount: 1, healthStatus: 'healthy' },
{ id: 'uat', name: 'uat', displayName: 'UAT', order: 3, releaseCount: 2, pendingCount: 0, healthStatus: 'degraded' },
{ id: 'prod', name: 'prod', displayName: 'Production', order: 4, releaseCount: 1, pendingCount: 2, healthStatus: 'healthy' },
],
connections: [
{ from: 'dev', to: 'staging' },
{ from: 'staging', to: 'uat' },
{ from: 'uat', to: 'prod' },
],
},
pendingApprovals: [
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'api-gateway',
releaseVersion: 'v2.3.1',
sourceEnvironment: 'Staging',
targetEnvironment: 'UAT',
requestedBy: 'john.doe@example.com',
requestedAt: new Date(Date.now() - 3600000).toISOString(),
urgency: 'normal',
},
{
id: 'apr-002',
releaseId: 'rel-002',
releaseName: 'user-service',
releaseVersion: 'v1.5.0',
sourceEnvironment: 'UAT',
targetEnvironment: 'Production',
requestedBy: 'jane.smith@example.com',
requestedAt: new Date(Date.now() - 7200000).toISOString(),
urgency: 'high',
},
],
activeDeployments: [
{
id: 'dep-001',
releaseId: 'rel-003',
releaseName: 'payment-service',
releaseVersion: 'v3.0.0',
environment: 'Staging',
progress: 75,
status: 'running',
startedAt: new Date(Date.now() - 180000).toISOString(),
completedTargets: 3,
totalTargets: 4,
},
],
recentReleases: [
{
id: 'rel-001',
name: 'api-gateway',
version: 'v2.3.1',
status: 'promoting',
currentEnvironment: 'Staging',
createdAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: 'john.doe@example.com',
componentCount: 3,
},
{
id: 'rel-002',
name: 'user-service',
version: 'v1.5.0',
status: 'ready',
currentEnvironment: 'UAT',
createdAt: new Date(Date.now() - 172800000).toISOString(),
createdBy: 'jane.smith@example.com',
componentCount: 2,
},
{
id: 'rel-003',
name: 'payment-service',
version: 'v3.0.0',
status: 'deployed',
currentEnvironment: 'Development',
createdAt: new Date(Date.now() - 259200000).toISOString(),
createdBy: 'bob.wilson@example.com',
componentCount: 4,
},
{
id: 'rel-004',
name: 'notification-service',
version: 'v1.2.3',
status: 'deployed',
currentEnvironment: 'Production',
createdAt: new Date(Date.now() - 345600000).toISOString(),
createdBy: 'alice.johnson@example.com',
componentCount: 1,
},
],
};
return of(mockData).pipe(delay(500));
}
approvePromotion(id: string): Observable<void> {
return of(undefined).pipe(delay(300));
}
rejectPromotion(id: string, reason?: string): Observable<void> {
return of(undefined).pipe(delay(300));
}
}

View File

@@ -0,0 +1,69 @@
/**
* Release Orchestrator Dashboard Models
* TypeScript interfaces for dashboard data
*/
export interface PipelineEnvironment {
id: string;
name: string;
displayName: string;
order: number;
releaseCount: number;
pendingCount: number;
healthStatus: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
}
export interface PipelineData {
environments: PipelineEnvironment[];
connections: Array<{ from: string; to: string }>;
}
export interface PendingApproval {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
sourceEnvironment: string;
targetEnvironment: string;
requestedBy: string;
requestedAt: string;
urgency: 'low' | 'normal' | 'high' | 'critical';
}
export interface ActiveDeployment {
id: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
environment: string;
progress: number;
status: 'running' | 'paused' | 'waiting';
startedAt: string;
completedTargets: number;
totalTargets: number;
}
export interface RecentRelease {
id: string;
name: string;
version: string;
status: 'draft' | 'ready' | 'promoting' | 'deployed' | 'failed' | 'deprecated' | 'rolled_back';
currentEnvironment: string | null;
createdAt: string;
createdBy: string;
componentCount: number;
}
export interface DashboardData {
pipelineData: PipelineData;
pendingApprovals: PendingApproval[];
activeDeployments: ActiveDeployment[];
recentReleases: RecentRelease[];
}
export interface DashboardState {
data: DashboardData | null;
loading: boolean;
error: string | null;
lastUpdated: Date | null;
}

View File

@@ -0,0 +1,644 @@
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import type {
Environment,
EnvironmentListResponse,
CreateEnvironmentRequest,
UpdateEnvironmentRequest,
UpdateEnvironmentSettingsRequest,
DeploymentTarget,
CreateTargetRequest,
UpdateTargetRequest,
TargetHealthCheckResponse,
FreezeWindow,
CreateFreezeWindowRequest,
UpdateFreezeWindowRequest,
} from './release-environment.models';
export const RELEASE_ENVIRONMENT_API_BASE_URL = new InjectionToken<string>('RELEASE_ENVIRONMENT_API_BASE_URL');
export interface ReleaseEnvironmentApi {
// Environment CRUD
listEnvironments(): Observable<EnvironmentListResponse>;
getEnvironment(id: string): Observable<Environment>;
createEnvironment(request: CreateEnvironmentRequest): Observable<Environment>;
updateEnvironment(id: string, request: UpdateEnvironmentRequest): Observable<Environment>;
deleteEnvironment(id: string): Observable<void>;
updateEnvironmentSettings(id: string, settings: UpdateEnvironmentSettingsRequest): Observable<Environment>;
// Target management
listTargets(environmentId: string): Observable<DeploymentTarget[]>;
addTarget(environmentId: string, request: CreateTargetRequest): Observable<DeploymentTarget>;
updateTarget(environmentId: string, targetId: string, request: UpdateTargetRequest): Observable<DeploymentTarget>;
removeTarget(environmentId: string, targetId: string): Observable<void>;
checkTargetHealth(environmentId: string, targetId: string): Observable<TargetHealthCheckResponse>;
// Freeze window management
listFreezeWindows(environmentId: string): Observable<FreezeWindow[]>;
createFreezeWindow(environmentId: string, request: CreateFreezeWindowRequest): Observable<FreezeWindow>;
updateFreezeWindow(environmentId: string, windowId: string, request: UpdateFreezeWindowRequest): Observable<FreezeWindow>;
deleteFreezeWindow(environmentId: string, windowId: string): Observable<void>;
}
export const RELEASE_ENVIRONMENT_API = new InjectionToken<ReleaseEnvironmentApi>('RELEASE_ENVIRONMENT_API');
@Injectable({ providedIn: 'root' })
export class ReleaseEnvironmentHttpClient implements ReleaseEnvironmentApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(RELEASE_ENVIRONMENT_API_BASE_URL);
listEnvironments(): Observable<EnvironmentListResponse> {
return this.http.get<EnvironmentListResponse>(`${this.baseUrl}/environments`);
}
getEnvironment(id: string): Observable<Environment> {
return this.http.get<Environment>(`${this.baseUrl}/environments/${id}`);
}
createEnvironment(request: CreateEnvironmentRequest): Observable<Environment> {
return this.http.post<Environment>(`${this.baseUrl}/environments`, request);
}
updateEnvironment(id: string, request: UpdateEnvironmentRequest): Observable<Environment> {
return this.http.put<Environment>(`${this.baseUrl}/environments/${id}`, request);
}
deleteEnvironment(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/environments/${id}`);
}
updateEnvironmentSettings(id: string, settings: UpdateEnvironmentSettingsRequest): Observable<Environment> {
return this.http.put<Environment>(`${this.baseUrl}/environments/${id}/settings`, settings);
}
listTargets(environmentId: string): Observable<DeploymentTarget[]> {
return this.http.get<DeploymentTarget[]>(`${this.baseUrl}/environments/${environmentId}/targets`);
}
addTarget(environmentId: string, request: CreateTargetRequest): Observable<DeploymentTarget> {
return this.http.post<DeploymentTarget>(`${this.baseUrl}/environments/${environmentId}/targets`, request);
}
updateTarget(environmentId: string, targetId: string, request: UpdateTargetRequest): Observable<DeploymentTarget> {
return this.http.put<DeploymentTarget>(`${this.baseUrl}/environments/${environmentId}/targets/${targetId}`, request);
}
removeTarget(environmentId: string, targetId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/environments/${environmentId}/targets/${targetId}`);
}
checkTargetHealth(environmentId: string, targetId: string): Observable<TargetHealthCheckResponse> {
return this.http.post<TargetHealthCheckResponse>(
`${this.baseUrl}/environments/${environmentId}/targets/${targetId}/health-check`,
{}
);
}
listFreezeWindows(environmentId: string): Observable<FreezeWindow[]> {
return this.http.get<FreezeWindow[]>(`${this.baseUrl}/environments/${environmentId}/freeze-windows`);
}
createFreezeWindow(environmentId: string, request: CreateFreezeWindowRequest): Observable<FreezeWindow> {
return this.http.post<FreezeWindow>(`${this.baseUrl}/environments/${environmentId}/freeze-windows`, request);
}
updateFreezeWindow(environmentId: string, windowId: string, request: UpdateFreezeWindowRequest): Observable<FreezeWindow> {
return this.http.put<FreezeWindow>(
`${this.baseUrl}/environments/${environmentId}/freeze-windows/${windowId}`,
request
);
}
deleteFreezeWindow(environmentId: string, windowId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/environments/${environmentId}/freeze-windows/${windowId}`);
}
}
@Injectable({ providedIn: 'root' })
export class MockReleaseEnvironmentClient implements ReleaseEnvironmentApi {
private environments: Environment[] = [
{
id: 'dev',
name: 'dev',
displayName: 'Development',
description: 'Development environment for testing new features',
order: 1,
isProduction: false,
targetCount: 2,
healthyTargetCount: 2,
requiresApproval: false,
requiredApprovers: 0,
freezeWindowCount: 0,
activeFreezeWindow: false,
autoPromoteOnSuccess: true,
separationOfDuties: false,
notifyOnPromotion: false,
notifyOnDeployment: true,
notifyOnFailure: true,
maxConcurrentDeployments: 5,
deploymentTimeout: 1800,
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'staging',
name: 'staging',
displayName: 'Staging',
description: 'Pre-production testing environment',
order: 2,
isProduction: false,
targetCount: 3,
healthyTargetCount: 3,
requiresApproval: true,
requiredApprovers: 1,
freezeWindowCount: 1,
activeFreezeWindow: false,
autoPromoteOnSuccess: false,
separationOfDuties: false,
notifyOnPromotion: true,
notifyOnDeployment: true,
notifyOnFailure: true,
maxConcurrentDeployments: 3,
deploymentTimeout: 2400,
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'uat',
name: 'uat',
displayName: 'UAT',
description: 'User acceptance testing environment',
order: 3,
isProduction: false,
targetCount: 2,
healthyTargetCount: 1,
requiresApproval: true,
requiredApprovers: 1,
freezeWindowCount: 0,
activeFreezeWindow: false,
autoPromoteOnSuccess: false,
separationOfDuties: true,
notifyOnPromotion: true,
notifyOnDeployment: true,
notifyOnFailure: true,
maxConcurrentDeployments: 2,
deploymentTimeout: 3600,
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'prod',
name: 'prod',
displayName: 'Production',
description: 'Live production environment',
order: 4,
isProduction: true,
targetCount: 4,
healthyTargetCount: 4,
requiresApproval: true,
requiredApprovers: 2,
freezeWindowCount: 2,
activeFreezeWindow: false,
autoPromoteOnSuccess: false,
separationOfDuties: true,
notifyOnPromotion: true,
notifyOnDeployment: true,
notifyOnFailure: true,
webhookUrl: 'https://hooks.example.com/deployments',
maxConcurrentDeployments: 1,
deploymentTimeout: 7200,
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
];
private targets: Record<string, DeploymentTarget[]> = {
dev: [
{
id: 'dev-web-01',
environmentId: 'dev',
name: 'dev-web-01',
type: 'docker_host',
agentId: 'agent-dev-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 300000).toISOString(),
metadata: { region: 'us-east-1' },
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'dev-api-01',
environmentId: 'dev',
name: 'dev-api-01',
type: 'docker_host',
agentId: 'agent-dev-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 300000).toISOString(),
metadata: { region: 'us-east-1' },
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
],
staging: [
{
id: 'stg-web-01',
environmentId: 'staging',
name: 'stg-web-01',
type: 'compose_host',
agentId: 'agent-stg-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 600000).toISOString(),
metadata: { region: 'us-east-1' },
createdAt: new Date(Date.now() - 25 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'stg-api-01',
environmentId: 'staging',
name: 'stg-api-01',
type: 'compose_host',
agentId: 'agent-stg-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 600000).toISOString(),
metadata: { region: 'us-east-1' },
createdAt: new Date(Date.now() - 25 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'stg-worker-01',
environmentId: 'staging',
name: 'stg-worker-01',
type: 'docker_host',
agentId: 'agent-stg-02',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 600000).toISOString(),
metadata: { region: 'us-east-1' },
createdAt: new Date(Date.now() - 25 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
],
uat: [
{
id: 'uat-web-01',
environmentId: 'uat',
name: 'uat-web-01',
type: 'ecs_service',
agentId: undefined,
agentStatus: 'unknown',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 900000).toISOString(),
metadata: { cluster: 'uat-cluster', service: 'web' },
createdAt: new Date(Date.now() - 20 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'uat-api-01',
environmentId: 'uat',
name: 'uat-api-01',
type: 'ecs_service',
agentId: undefined,
agentStatus: 'unknown',
healthStatus: 'unhealthy',
lastHealthCheck: new Date(Date.now() - 900000).toISOString(),
metadata: { cluster: 'uat-cluster', service: 'api' },
createdAt: new Date(Date.now() - 20 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
],
prod: [
{
id: 'prod-web-01',
environmentId: 'prod',
name: 'prod-web-01',
type: 'docker_host',
agentId: 'agent-prod-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 120000).toISOString(),
metadata: { region: 'us-east-1', az: 'us-east-1a' },
createdAt: new Date(Date.now() - 60 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'prod-web-02',
environmentId: 'prod',
name: 'prod-web-02',
type: 'docker_host',
agentId: 'agent-prod-02',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 120000).toISOString(),
metadata: { region: 'us-east-1', az: 'us-east-1b' },
createdAt: new Date(Date.now() - 60 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'prod-api-01',
environmentId: 'prod',
name: 'prod-api-01',
type: 'docker_host',
agentId: 'agent-prod-01',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 120000).toISOString(),
metadata: { region: 'us-east-1', az: 'us-east-1a' },
createdAt: new Date(Date.now() - 60 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
{
id: 'prod-api-02',
environmentId: 'prod',
name: 'prod-api-02',
type: 'docker_host',
agentId: 'agent-prod-02',
agentStatus: 'connected',
healthStatus: 'healthy',
lastHealthCheck: new Date(Date.now() - 120000).toISOString(),
metadata: { region: 'us-east-1', az: 'us-east-1b' },
createdAt: new Date(Date.now() - 60 * 86400000).toISOString(),
createdBy: 'admin@example.com',
},
],
};
private freezeWindows: Record<string, FreezeWindow[]> = {
staging: [
{
id: 'fw-stg-01',
environmentId: 'staging',
name: 'Weekend Freeze',
reason: 'No deployments during weekends',
startTime: '2026-01-17T18:00:00Z',
endTime: '2026-01-19T06:00:00Z',
recurrence: 'weekly',
isActive: false,
createdBy: 'admin@example.com',
createdAt: new Date(Date.now() - 14 * 86400000).toISOString(),
},
],
prod: [
{
id: 'fw-prod-01',
environmentId: 'prod',
name: 'Holiday Freeze',
reason: 'No deployments during holidays',
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-02T23:59:59Z',
recurrence: 'none',
isActive: false,
createdBy: 'admin@example.com',
createdAt: new Date(Date.now() - 30 * 86400000).toISOString(),
},
{
id: 'fw-prod-02',
environmentId: 'prod',
name: 'Friday Lockdown',
reason: 'No deployments after Friday 4PM',
startTime: '2026-01-17T16:00:00Z',
endTime: '2026-01-20T08:00:00Z',
recurrence: 'weekly',
isActive: false,
createdBy: 'ops@example.com',
createdAt: new Date(Date.now() - 20 * 86400000).toISOString(),
},
],
};
listEnvironments(): Observable<EnvironmentListResponse> {
return of({
items: [...this.environments].sort((a, b) => a.order - b.order),
totalCount: this.environments.length,
}).pipe(delay(300));
}
getEnvironment(id: string): Observable<Environment> {
const env = this.environments.find((e) => e.id === id);
if (!env) {
throw new Error(`Environment ${id} not found`);
}
return of(env).pipe(delay(200));
}
createEnvironment(request: CreateEnvironmentRequest): Observable<Environment> {
const newEnv: Environment = {
id: request.name.toLowerCase().replace(/\s+/g, '-'),
...request,
targetCount: 0,
healthyTargetCount: 0,
freezeWindowCount: 0,
activeFreezeWindow: false,
autoPromoteOnSuccess: false,
separationOfDuties: false,
notifyOnPromotion: true,
notifyOnDeployment: true,
notifyOnFailure: true,
maxConcurrentDeployments: 1,
deploymentTimeout: 3600,
createdAt: new Date().toISOString(),
createdBy: 'current-user@example.com',
};
this.environments.push(newEnv);
this.targets[newEnv.id] = [];
this.freezeWindows[newEnv.id] = [];
return of(newEnv).pipe(delay(300));
}
updateEnvironment(id: string, request: UpdateEnvironmentRequest): Observable<Environment> {
const idx = this.environments.findIndex((e) => e.id === id);
if (idx === -1) {
throw new Error(`Environment ${id} not found`);
}
this.environments[idx] = {
...this.environments[idx],
...request,
updatedAt: new Date().toISOString(),
updatedBy: 'current-user@example.com',
};
return of(this.environments[idx]).pipe(delay(300));
}
deleteEnvironment(id: string): Observable<void> {
const idx = this.environments.findIndex((e) => e.id === id);
if (idx === -1) {
throw new Error(`Environment ${id} not found`);
}
this.environments.splice(idx, 1);
delete this.targets[id];
delete this.freezeWindows[id];
return of(undefined).pipe(delay(300));
}
updateEnvironmentSettings(id: string, settings: UpdateEnvironmentSettingsRequest): Observable<Environment> {
const idx = this.environments.findIndex((e) => e.id === id);
if (idx === -1) {
throw new Error(`Environment ${id} not found`);
}
this.environments[idx] = {
...this.environments[idx],
...settings,
updatedAt: new Date().toISOString(),
updatedBy: 'current-user@example.com',
};
return of(this.environments[idx]).pipe(delay(300));
}
listTargets(environmentId: string): Observable<DeploymentTarget[]> {
return of(this.targets[environmentId] ?? []).pipe(delay(200));
}
addTarget(environmentId: string, request: CreateTargetRequest): Observable<DeploymentTarget> {
const newTarget: DeploymentTarget = {
id: `${environmentId}-${request.name}`,
environmentId,
name: request.name,
type: request.type,
agentId: request.agentId,
agentStatus: request.agentId ? 'connected' : 'unknown',
healthStatus: 'unknown',
metadata: request.metadata ?? {},
createdAt: new Date().toISOString(),
createdBy: 'current-user@example.com',
};
if (!this.targets[environmentId]) {
this.targets[environmentId] = [];
}
this.targets[environmentId].push(newTarget);
// Update environment target count
const env = this.environments.find((e) => e.id === environmentId);
if (env) {
env.targetCount++;
}
return of(newTarget).pipe(delay(300));
}
updateTarget(environmentId: string, targetId: string, request: UpdateTargetRequest): Observable<DeploymentTarget> {
const targets = this.targets[environmentId];
if (!targets) {
throw new Error(`Environment ${environmentId} not found`);
}
const idx = targets.findIndex((t) => t.id === targetId);
if (idx === -1) {
throw new Error(`Target ${targetId} not found`);
}
targets[idx] = {
...targets[idx],
...request,
updatedAt: new Date().toISOString(),
};
return of(targets[idx]).pipe(delay(300));
}
removeTarget(environmentId: string, targetId: string): Observable<void> {
const targets = this.targets[environmentId];
if (!targets) {
throw new Error(`Environment ${environmentId} not found`);
}
const idx = targets.findIndex((t) => t.id === targetId);
if (idx === -1) {
throw new Error(`Target ${targetId} not found`);
}
targets.splice(idx, 1);
// Update environment target count
const env = this.environments.find((e) => e.id === environmentId);
if (env) {
env.targetCount--;
}
return of(undefined).pipe(delay(300));
}
checkTargetHealth(environmentId: string, targetId: string): Observable<TargetHealthCheckResponse> {
const targets = this.targets[environmentId];
if (!targets) {
throw new Error(`Environment ${environmentId} not found`);
}
const target = targets.find((t) => t.id === targetId);
if (!target) {
throw new Error(`Target ${targetId} not found`);
}
// Update target health
target.healthStatus = 'healthy';
target.lastHealthCheck = new Date().toISOString();
return of({
targetId,
healthStatus: 'healthy' as const,
checkedAt: new Date().toISOString(),
details: { latencyMs: Math.floor(Math.random() * 50) + 10 },
}).pipe(delay(500));
}
listFreezeWindows(environmentId: string): Observable<FreezeWindow[]> {
return of(this.freezeWindows[environmentId] ?? []).pipe(delay(200));
}
createFreezeWindow(environmentId: string, request: CreateFreezeWindowRequest): Observable<FreezeWindow> {
const newWindow: FreezeWindow = {
id: `fw-${environmentId}-${Date.now()}`,
environmentId,
...request,
isActive: false,
createdBy: 'current-user@example.com',
createdAt: new Date().toISOString(),
};
if (!this.freezeWindows[environmentId]) {
this.freezeWindows[environmentId] = [];
}
this.freezeWindows[environmentId].push(newWindow);
// Update environment freeze window count
const env = this.environments.find((e) => e.id === environmentId);
if (env) {
env.freezeWindowCount++;
}
return of(newWindow).pipe(delay(300));
}
updateFreezeWindow(environmentId: string, windowId: string, request: UpdateFreezeWindowRequest): Observable<FreezeWindow> {
const windows = this.freezeWindows[environmentId];
if (!windows) {
throw new Error(`Environment ${environmentId} not found`);
}
const idx = windows.findIndex((w) => w.id === windowId);
if (idx === -1) {
throw new Error(`Freeze window ${windowId} not found`);
}
windows[idx] = {
...windows[idx],
...request,
updatedAt: new Date().toISOString(),
};
return of(windows[idx]).pipe(delay(300));
}
deleteFreezeWindow(environmentId: string, windowId: string): Observable<void> {
const windows = this.freezeWindows[environmentId];
if (!windows) {
throw new Error(`Environment ${environmentId} not found`);
}
const idx = windows.findIndex((w) => w.id === windowId);
if (idx === -1) {
throw new Error(`Freeze window ${windowId} not found`);
}
windows.splice(idx, 1);
// Update environment freeze window count
const env = this.environments.find((e) => e.id === environmentId);
if (env) {
env.freezeWindowCount--;
}
return of(undefined).pipe(delay(300));
}
}

View File

@@ -0,0 +1,227 @@
/**
* Release Environment Models
* Sprint: SPRINT_20260110_111_002_FE_environment_management_ui
*/
// Environment entity
export interface Environment {
id: string;
name: string;
displayName: string;
description: string;
order: number;
isProduction: boolean;
targetCount: number;
healthyTargetCount: number;
requiresApproval: boolean;
requiredApprovers: number;
freezeWindowCount: number;
activeFreezeWindow: boolean;
autoPromoteOnSuccess: boolean;
separationOfDuties: boolean;
notifyOnPromotion: boolean;
notifyOnDeployment: boolean;
notifyOnFailure: boolean;
webhookUrl?: string;
maxConcurrentDeployments: number;
deploymentTimeout: number;
createdAt: string;
createdBy: string;
updatedAt?: string;
updatedBy?: string;
}
// Deployment target types
export type TargetType = 'docker_host' | 'compose_host' | 'ecs_service' | 'nomad_job';
export type AgentStatus = 'connected' | 'disconnected' | 'unknown';
export type HealthStatus = 'healthy' | 'unhealthy' | 'unknown';
// Deployment target
export interface DeploymentTarget {
id: string;
environmentId: string;
name: string;
type: TargetType;
agentId?: string;
agentStatus: AgentStatus;
healthStatus: HealthStatus;
lastHealthCheck?: string;
metadata: Record<string, string>;
createdAt: string;
createdBy: string;
updatedAt?: string;
}
// Freeze window recurrence
export type FreezeRecurrence = 'none' | 'daily' | 'weekly' | 'monthly';
// Freeze window
export interface FreezeWindow {
id: string;
environmentId: string;
name: string;
reason: string;
startTime: string;
endTime: string;
recurrence: FreezeRecurrence;
isActive: boolean;
createdBy: string;
createdAt: string;
updatedAt?: string;
}
// API request/response types
export interface EnvironmentListResponse {
items: Environment[];
totalCount: number;
}
export interface CreateEnvironmentRequest {
name: string;
displayName: string;
description: string;
order: number;
isProduction: boolean;
requiresApproval: boolean;
requiredApprovers: number;
}
export interface UpdateEnvironmentRequest {
displayName?: string;
description?: string;
order?: number;
isProduction?: boolean;
}
export interface UpdateEnvironmentSettingsRequest {
requiresApproval: boolean;
requiredApprovers: number;
autoPromoteOnSuccess: boolean;
separationOfDuties: boolean;
notifyOnPromotion: boolean;
notifyOnDeployment: boolean;
notifyOnFailure: boolean;
webhookUrl?: string;
maxConcurrentDeployments: number;
deploymentTimeout: number;
}
export interface CreateTargetRequest {
name: string;
type: TargetType;
agentId?: string;
metadata?: Record<string, string>;
}
export interface UpdateTargetRequest {
name?: string;
agentId?: string;
metadata?: Record<string, string>;
}
export interface TargetHealthCheckResponse {
targetId: string;
healthStatus: HealthStatus;
checkedAt: string;
details?: Record<string, unknown>;
}
export interface CreateFreezeWindowRequest {
name: string;
reason: string;
startTime: string;
endTime: string;
recurrence: FreezeRecurrence;
}
export interface UpdateFreezeWindowRequest {
name?: string;
reason?: string;
startTime?: string;
endTime?: string;
recurrence?: FreezeRecurrence;
}
// Display helpers
export function getTargetTypeLabel(type: TargetType): string {
switch (type) {
case 'docker_host':
return 'Docker Host';
case 'compose_host':
return 'Compose Host';
case 'ecs_service':
return 'ECS Service';
case 'nomad_job':
return 'Nomad Job';
default:
return 'Unknown';
}
}
export function getTargetTypeIcon(type: TargetType): string {
switch (type) {
case 'docker_host':
return 'pi-box';
case 'compose_host':
return 'pi-th-large';
case 'ecs_service':
return 'pi-cloud';
case 'nomad_job':
return 'pi-sitemap';
default:
return 'pi-server';
}
}
export function getHealthStatusColor(status: HealthStatus): string {
switch (status) {
case 'healthy':
return 'success';
case 'unhealthy':
return 'danger';
case 'unknown':
return 'secondary';
default:
return 'secondary';
}
}
export function getAgentStatusColor(status: AgentStatus): string {
switch (status) {
case 'connected':
return 'success';
case 'disconnected':
return 'danger';
case 'unknown':
return 'secondary';
default:
return 'secondary';
}
}
export function getRecurrenceLabel(recurrence: FreezeRecurrence): string {
switch (recurrence) {
case 'none':
return 'One-time';
case 'daily':
return 'Daily';
case 'weekly':
return 'Weekly';
case 'monthly':
return 'Monthly';
default:
return 'Unknown';
}
}
export function getHealthPercentage(env: Environment): number {
if (env.targetCount === 0) return 100;
return Math.round((env.healthyTargetCount / env.targetCount) * 100);
}
export function getHealthClass(env: Environment): string {
const pct = getHealthPercentage(env);
if (pct >= 90) return 'health--good';
if (pct >= 70) return 'health--warning';
return 'health--critical';
}

View File

@@ -0,0 +1,349 @@
/**
* Release Evidence API Client
* Sprint: SPRINT_20260110_111_007_FE_evidence_viewer
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import type {
EvidencePacketSummary,
EvidencePacketDetail,
EvidenceFilter,
EvidenceListResponse,
VerificationResult,
ExportFormat,
EvidenceTimelineEvent,
} from './release-evidence.models';
export interface ReleaseEvidenceApi {
getEvidencePackets(filter?: EvidenceFilter): Observable<EvidenceListResponse>;
getEvidencePacket(id: string): Observable<EvidencePacketDetail>;
verifyEvidence(id: string): Observable<VerificationResult>;
exportEvidence(id: string, format: ExportFormat, includeSignature: boolean): Observable<Blob>;
downloadRaw(id: string): Observable<Blob>;
getTimeline(id: string): Observable<EvidenceTimelineEvent[]>;
}
export const RELEASE_EVIDENCE_API = new InjectionToken<ReleaseEvidenceApi>('RELEASE_EVIDENCE_API');
@Injectable({ providedIn: 'root' })
export class ReleaseEvidenceHttpClient implements ReleaseEvidenceApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/release-orchestrator/evidence';
getEvidencePackets(filter?: EvidenceFilter): Observable<EvidenceListResponse> {
const params: Record<string, string> = {};
if (filter?.search) params['search'] = filter.search;
if (filter?.signatureStatuses?.length) params['signatureStatuses'] = filter.signatureStatuses.join(',');
if (filter?.environmentId) params['environmentId'] = filter.environmentId;
if (filter?.dateFrom) params['dateFrom'] = filter.dateFrom;
if (filter?.dateTo) params['dateTo'] = filter.dateTo;
return this.http.get<EvidenceListResponse>(this.baseUrl, { params });
}
getEvidencePacket(id: string): Observable<EvidencePacketDetail> {
return this.http.get<EvidencePacketDetail>(`${this.baseUrl}/${id}`);
}
verifyEvidence(id: string): Observable<VerificationResult> {
return this.http.post<VerificationResult>(`${this.baseUrl}/${id}/verify`, {});
}
exportEvidence(id: string, format: ExportFormat, includeSignature: boolean): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${id}/export`, {
params: { format, includeSignature: includeSignature.toString() },
responseType: 'blob',
});
}
downloadRaw(id: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${id}/raw`, { responseType: 'blob' });
}
getTimeline(id: string): Observable<EvidenceTimelineEvent[]> {
return this.http.get<EvidenceTimelineEvent[]>(`${this.baseUrl}/${id}/timeline`);
}
}
@Injectable({ providedIn: 'root' })
export class MockReleaseEvidenceClient implements ReleaseEvidenceApi {
private readonly mockPackets: EvidencePacketSummary[] = [
{
id: 'evp-001',
deploymentId: 'dep-001',
releaseId: 'rel-001',
releaseName: 'backend-api',
releaseVersion: '2.5.0',
environmentId: 'env-staging',
environmentName: 'Staging',
status: 'complete',
signatureStatus: 'valid',
contentHash: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
signedAt: new Date(Date.now() - 300000).toISOString(),
signedBy: 'release-signer@example.com',
createdAt: new Date(Date.now() - 350000).toISOString(),
size: 45678,
contentTypes: ['SBOM', 'Attestation', 'Log'],
},
{
id: 'evp-002',
deploymentId: 'dep-002',
releaseId: 'rel-002',
releaseName: 'frontend-app',
releaseVersion: '1.12.0',
environmentId: 'env-prod',
environmentName: 'Production',
status: 'complete',
signatureStatus: 'valid',
contentHash: 'sha256:b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567',
signedAt: new Date(Date.now() - 3600000).toISOString(),
signedBy: 'release-signer@example.com',
createdAt: new Date(Date.now() - 3650000).toISOString(),
size: 67890,
contentTypes: ['SBOM', 'Attestation', 'Log', 'VEX'],
},
{
id: 'evp-003',
deploymentId: 'dep-003',
releaseId: 'rel-003',
releaseName: 'worker-service',
releaseVersion: '3.0.1',
environmentId: 'env-dev',
environmentName: 'Development',
status: 'failed',
signatureStatus: 'unsigned',
contentHash: 'sha256:c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
signedAt: null,
signedBy: null,
createdAt: new Date(Date.now() - 7200000).toISOString(),
size: 23456,
contentTypes: ['Log'],
},
{
id: 'evp-004',
deploymentId: 'dep-004',
releaseId: 'rel-004',
releaseName: 'gateway-service',
releaseVersion: '1.8.0',
environmentId: 'env-staging',
environmentName: 'Staging',
status: 'pending',
signatureStatus: 'unsigned',
contentHash: 'sha256:d4e5f6789012345678901234567890abcdef1234567890abcdef123456789',
signedAt: null,
signedBy: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
size: 12345,
contentTypes: ['SBOM'],
},
{
id: 'evp-005',
deploymentId: 'dep-005',
releaseId: 'rel-005',
releaseName: 'auth-service',
releaseVersion: '2.1.0',
environmentId: 'env-prod',
environmentName: 'Production',
status: 'complete',
signatureStatus: 'expired',
contentHash: 'sha256:e5f6789012345678901234567890abcdef1234567890abcdef1234567890',
signedAt: new Date(Date.now() - 86400000 * 30).toISOString(),
signedBy: 'release-signer@example.com',
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
size: 89012,
contentTypes: ['SBOM', 'Attestation', 'Log', 'VEX', 'SLSA'],
},
];
private readonly mockDetail: EvidencePacketDetail = {
...this.mockPackets[0],
content: {
metadata: {
deploymentId: 'dep-001',
releaseId: 'rel-001',
environmentId: 'env-staging',
startedAt: new Date(Date.now() - 350000).toISOString(),
completedAt: new Date(Date.now() - 300000).toISOString(),
initiatedBy: 'alice@example.com',
outcome: 'success',
},
release: {
name: 'backend-api',
version: '2.5.0',
components: [
{ name: 'api-server', digest: 'sha256:abc123def456...', version: '2.5.0' },
{ name: 'redis-cache', digest: 'sha256:def456ghi789...', version: '7.2.0' },
{ name: 'postgres-db', digest: 'sha256:ghi789jkl012...', version: '16.1' },
],
},
workflow: {
id: 'wf-staging-deploy',
name: 'Staging Deployment',
version: 3,
stepsExecuted: 8,
stepsFailed: 0,
},
targets: [
{ id: 'tgt-1', name: 'api-server-1', type: 'docker_host', outcome: 'success', duration: 45000 },
{ id: 'tgt-2', name: 'api-server-2', type: 'docker_host', outcome: 'success', duration: 42000 },
{ id: 'tgt-3', name: 'api-server-3', type: 'docker_host', outcome: 'success', duration: 48000 },
],
approvals: [
{
approver: 'bob@example.com',
action: 'approved',
timestamp: new Date(Date.now() - 400000).toISOString(),
comment: 'LGTM - all security checks passed',
},
{
approver: 'charlie@example.com',
action: 'approved',
timestamp: new Date(Date.now() - 380000).toISOString(),
comment: 'QA sign-off complete',
},
],
gateResults: [
{ gateId: 'gate-vuln', gateName: 'Vulnerability Scan', status: 'passed', evaluatedAt: new Date(Date.now() - 420000).toISOString() },
{ gateId: 'gate-sbom', gateName: 'SBOM Validation', status: 'passed', evaluatedAt: new Date(Date.now() - 415000).toISOString() },
{ gateId: 'gate-policy', gateName: 'Policy Compliance', status: 'passed', evaluatedAt: new Date(Date.now() - 410000).toISOString() },
{ gateId: 'gate-test', gateName: 'Integration Tests', status: 'passed', evaluatedAt: new Date(Date.now() - 405000).toISOString() },
],
artifacts: [
{ name: 'sbom.json', type: 'SBOM', digest: 'sha256:sbom123...', size: 15678 },
{ name: 'attestation.json', type: 'Attestation', digest: 'sha256:att456...', size: 8901 },
{ name: 'deployment.log', type: 'Log', digest: 'sha256:log789...', size: 21099 },
],
},
signature: {
algorithm: 'ECDSA-P256',
keyId: 'release-signing-key-2026',
signature: 'MEUCIQDf4Z8Q9K3L2N1M0P5R6S7T8U9V0W1X2Y3Z4A5B6C7D8E9FghIjKlMnOpQrStUvWxYz0123456789ABCDEFabcdef==',
signedAt: new Date(Date.now() - 300000).toISOString(),
signedBy: 'release-signer@example.com',
certificate: '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKzA...\n-----END CERTIFICATE-----',
},
verificationResult: null,
};
private readonly mockTimeline: EvidenceTimelineEvent[] = [
{ id: 'evt-1', type: 'created', timestamp: new Date(Date.now() - 350000).toISOString(), actor: 'system', details: 'Evidence packet created' },
{ id: 'evt-2', type: 'signed', timestamp: new Date(Date.now() - 300000).toISOString(), actor: 'release-signer@example.com', details: 'Packet signed with ECDSA-P256' },
{ id: 'evt-3', type: 'verified', timestamp: new Date(Date.now() - 250000).toISOString(), actor: 'bob@example.com', details: 'Signature verified successfully' },
{ id: 'evt-4', type: 'viewed', timestamp: new Date(Date.now() - 120000).toISOString(), actor: 'charlie@example.com', details: 'Evidence packet viewed' },
{ id: 'evt-5', type: 'exported', timestamp: new Date(Date.now() - 60000).toISOString(), actor: 'alice@example.com', details: 'Exported to PDF format' },
];
getEvidencePackets(filter?: EvidenceFilter): Observable<EvidenceListResponse> {
let items = [...this.mockPackets];
if (filter?.search) {
const search = filter.search.toLowerCase();
items = items.filter(
(p) =>
p.releaseName.toLowerCase().includes(search) ||
p.environmentName.toLowerCase().includes(search) ||
p.releaseVersion.includes(search)
);
}
if (filter?.signatureStatuses?.length) {
items = items.filter((p) => filter.signatureStatuses!.includes(p.signatureStatus));
}
if (filter?.environmentId) {
items = items.filter((p) => p.environmentId === filter.environmentId);
}
return of({
items,
total: items.length,
page: 1,
pageSize: 20,
}).pipe(delay(200));
}
getEvidencePacket(id: string): Observable<EvidencePacketDetail> {
const summary = this.mockPackets.find((p) => p.id === id);
if (!summary) {
return of({ ...this.mockDetail, id }).pipe(delay(200));
}
// Return mock detail with summary data
return of({
...this.mockDetail,
...summary,
content: {
...this.mockDetail.content,
metadata: {
...this.mockDetail.content.metadata,
deploymentId: summary.deploymentId,
releaseId: summary.releaseId,
environmentId: summary.environmentId,
outcome: summary.status === 'complete' ? 'success' : summary.status === 'failed' ? 'failure' : 'pending',
},
release: {
...this.mockDetail.content.release,
name: summary.releaseName,
version: summary.releaseVersion,
},
},
signature: summary.signatureStatus === 'unsigned' ? null : this.mockDetail.signature,
}).pipe(delay(300));
}
verifyEvidence(id: string): Observable<VerificationResult> {
const packet = this.mockPackets.find((p) => p.id === id);
const isValid = packet?.signatureStatus === 'valid';
const isExpired = packet?.signatureStatus === 'expired';
const isUnsigned = packet?.signatureStatus === 'unsigned';
return of({
valid: isValid,
message: isUnsigned
? 'Evidence packet is not signed'
: isExpired
? 'Signature has expired'
: isValid
? 'All verification checks passed'
: 'Signature verification failed',
details: {
signatureValid: isValid,
contentHashValid: !isUnsigned,
certificateValid: isValid,
timestampValid: !isExpired && !isUnsigned,
},
verifiedAt: new Date().toISOString(),
}).pipe(delay(500));
}
exportEvidence(id: string, format: ExportFormat, includeSignature: boolean): Observable<Blob> {
const content = JSON.stringify(
{
exportedAt: new Date().toISOString(),
format,
includeSignature,
packId: id,
content: this.mockDetail.content,
},
null,
2
);
const mimeTypes: Record<ExportFormat, string> = {
json: 'application/json',
pdf: 'application/pdf',
csv: 'text/csv',
slsa: 'application/json',
};
return of(new Blob([content], { type: mimeTypes[format] })).pipe(delay(300));
}
downloadRaw(id: string): Observable<Blob> {
return of(new Blob([JSON.stringify(this.mockDetail, null, 2)], { type: 'application/json' })).pipe(delay(200));
}
getTimeline(id: string): Observable<EvidenceTimelineEvent[]> {
return of(this.mockTimeline).pipe(delay(200));
}
}

View File

@@ -0,0 +1,230 @@
/**
* Release Evidence Packet Models
* Sprint: SPRINT_20260110_111_007_FE_evidence_viewer
*
* Models for deployment evidence packets generated during release deployments.
* Distinct from evidence.models.ts (advisory/VEX evidence) and evidence-pack.models.ts (claims/attestations).
*/
// ---------------------------------------------------------------------------
// Evidence Packet Types
// ---------------------------------------------------------------------------
export type EvidencePacketStatus = 'pending' | 'complete' | 'failed';
export type SignatureStatus = 'unsigned' | 'valid' | 'invalid' | 'expired';
export type ExportFormat = 'json' | 'pdf' | 'csv' | 'slsa';
export interface EvidencePacketSummary {
id: string;
deploymentId: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
environmentId: string;
environmentName: string;
status: EvidencePacketStatus;
signatureStatus: SignatureStatus;
contentHash: string;
signedAt: string | null;
signedBy: string | null;
createdAt: string;
size: number;
contentTypes: string[];
}
export interface EvidencePacketDetail extends EvidencePacketSummary {
content: EvidenceContent;
signature: EvidenceSignature | null;
verificationResult: VerificationResult | null;
}
// ---------------------------------------------------------------------------
// Evidence Content Types
// ---------------------------------------------------------------------------
export interface EvidenceContent {
metadata: EvidenceMetadata;
release: EvidenceReleaseInfo;
workflow: EvidenceWorkflowInfo;
targets: EvidenceTargetInfo[];
approvals: EvidenceApprovalInfo[];
gateResults: EvidenceGateResult[];
artifacts: EvidenceArtifactInfo[];
}
export interface EvidenceMetadata {
deploymentId: string;
releaseId: string;
environmentId: string;
startedAt: string;
completedAt: string;
initiatedBy: string;
outcome: string;
}
export interface EvidenceReleaseInfo {
name: string;
version: string;
components: EvidenceComponentInfo[];
}
export interface EvidenceComponentInfo {
name: string;
digest: string;
version: string;
}
export interface EvidenceWorkflowInfo {
id: string;
name: string;
version: number;
stepsExecuted: number;
stepsFailed: number;
}
export interface EvidenceTargetInfo {
id: string;
name: string;
type: string;
outcome: string;
duration: number;
}
export interface EvidenceApprovalInfo {
approver: string;
action: string;
timestamp: string;
comment: string;
}
export interface EvidenceGateResult {
gateId: string;
gateName: string;
status: string;
evaluatedAt: string;
}
export interface EvidenceArtifactInfo {
name: string;
type: string;
digest: string;
size: number;
}
// ---------------------------------------------------------------------------
// Signature & Verification Types
// ---------------------------------------------------------------------------
export interface EvidenceSignature {
algorithm: string;
keyId: string;
signature: string;
signedAt: string;
signedBy: string;
certificate: string | null;
}
export interface VerificationResult {
valid: boolean;
message: string;
details: VerificationDetails;
verifiedAt: string;
}
export interface VerificationDetails {
signatureValid: boolean;
contentHashValid: boolean;
certificateValid: boolean;
timestampValid: boolean;
}
// ---------------------------------------------------------------------------
// Timeline Types
// ---------------------------------------------------------------------------
export type TimelineEventType = 'created' | 'signed' | 'verified' | 'exported' | 'viewed';
export interface EvidenceTimelineEvent {
id: string;
type: TimelineEventType;
timestamp: string;
actor: string;
details: string;
}
// ---------------------------------------------------------------------------
// Filter & Request Types
// ---------------------------------------------------------------------------
export interface EvidenceFilter {
search?: string;
signatureStatuses?: SignatureStatus[];
environmentId?: string;
dateFrom?: string;
dateTo?: string;
}
export interface ExportOptions {
format: ExportFormat;
includeSignature: boolean;
}
// ---------------------------------------------------------------------------
// API Response Types
// ---------------------------------------------------------------------------
export interface EvidenceListResponse {
items: EvidencePacketSummary[];
total: number;
page: number;
pageSize: number;
}
// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------
export function getSignatureStatusColor(status: SignatureStatus): string {
const colors: Record<SignatureStatus, string> = {
valid: '#22c55e',
invalid: '#ef4444',
unsigned: '#6b7280',
expired: '#f59e0b',
};
return colors[status];
}
export function getSignatureStatusLabel(status: SignatureStatus): string {
const labels: Record<SignatureStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unsigned: 'Unsigned',
expired: 'Expired',
};
return labels[status];
}
export function formatFileSize(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`;
}
export function getOutcomeClass(outcome: string): string {
const classes: Record<string, string> = {
success: 'outcome--success',
failure: 'outcome--failure',
partial: 'outcome--warning',
cancelled: 'outcome--secondary',
};
return classes[outcome] || '';
}
export function getGateStatusClass(status: string): string {
const classes: Record<string, string> = {
passed: 'gate--passed',
failed: 'gate--failed',
skipped: 'gate--skipped',
};
return classes[status] || '';
}

View File

@@ -0,0 +1,543 @@
/**
* Release Management API Client
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay, map } from 'rxjs';
import type {
ManagedRelease,
ReleaseComponent,
ReleaseEvent,
RegistryImage,
CreateManagedReleaseRequest,
UpdateManagedReleaseRequest,
AddComponentRequest,
ReleaseFilter,
ReleaseListResponse,
} from './release-management.models';
export const RELEASE_MANAGEMENT_API = new InjectionToken<ReleaseManagementApi>('RELEASE_MANAGEMENT_API');
export interface ReleaseManagementApi {
// Releases
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse>;
getRelease(id: string): Observable<ManagedRelease>;
createRelease(request: CreateManagedReleaseRequest): Observable<ManagedRelease>;
updateRelease(id: string, request: UpdateManagedReleaseRequest): Observable<ManagedRelease>;
deleteRelease(id: string): Observable<void>;
// Release lifecycle
markReady(id: string): Observable<ManagedRelease>;
requestPromotion(id: string, targetEnvironment: string): Observable<ManagedRelease>;
deploy(id: string): Observable<ManagedRelease>;
rollback(id: string): Observable<ManagedRelease>;
cloneRelease(id: string, newName: string, newVersion: string): Observable<ManagedRelease>;
// Components
getComponents(releaseId: string): Observable<ReleaseComponent[]>;
addComponent(releaseId: string, request: AddComponentRequest): Observable<ReleaseComponent>;
updateComponent(releaseId: string, componentId: string, configOverrides: Record<string, string>): Observable<ReleaseComponent>;
removeComponent(releaseId: string, componentId: string): Observable<void>;
// Events
getEvents(releaseId: string): Observable<ReleaseEvent[]>;
// Registry search
searchImages(query: string): Observable<RegistryImage[]>;
getImageDigests(repository: string): Observable<RegistryImage>;
}
// HTTP Client Implementation
@Injectable()
export class ReleaseManagementHttpClient implements ReleaseManagementApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/release-orchestrator/releases';
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> {
const params: Record<string, string> = {};
if (filter?.search) params['search'] = filter.search;
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.environment) params['environment'] = filter.environment;
if (filter?.sortField) params['sortField'] = filter.sortField;
if (filter?.sortOrder) params['sortOrder'] = filter.sortOrder;
if (filter?.page) params['page'] = String(filter.page);
if (filter?.pageSize) params['pageSize'] = String(filter.pageSize);
return this.http.get<ReleaseListResponse>(this.baseUrl, { params });
}
getRelease(id: string): Observable<ManagedRelease> {
return this.http.get<ManagedRelease>(`${this.baseUrl}/${id}`);
}
createRelease(request: CreateManagedReleaseRequest): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(this.baseUrl, request);
}
updateRelease(id: string, request: UpdateManagedReleaseRequest): Observable<ManagedRelease> {
return this.http.patch<ManagedRelease>(`${this.baseUrl}/${id}`, request);
}
deleteRelease(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
markReady(id: string): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(`${this.baseUrl}/${id}/ready`, {});
}
requestPromotion(id: string, targetEnvironment: string): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(`${this.baseUrl}/${id}/promote`, { targetEnvironment });
}
deploy(id: string): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(`${this.baseUrl}/${id}/deploy`, {});
}
rollback(id: string): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(`${this.baseUrl}/${id}/rollback`, {});
}
cloneRelease(id: string, newName: string, newVersion: string): Observable<ManagedRelease> {
return this.http.post<ManagedRelease>(`${this.baseUrl}/${id}/clone`, { name: newName, version: newVersion });
}
getComponents(releaseId: string): Observable<ReleaseComponent[]> {
return this.http.get<ReleaseComponent[]>(`${this.baseUrl}/${releaseId}/components`);
}
addComponent(releaseId: string, request: AddComponentRequest): Observable<ReleaseComponent> {
return this.http.post<ReleaseComponent>(`${this.baseUrl}/${releaseId}/components`, request);
}
updateComponent(releaseId: string, componentId: string, configOverrides: Record<string, string>): Observable<ReleaseComponent> {
return this.http.patch<ReleaseComponent>(`${this.baseUrl}/${releaseId}/components/${componentId}`, { configOverrides });
}
removeComponent(releaseId: string, componentId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${releaseId}/components/${componentId}`);
}
getEvents(releaseId: string): Observable<ReleaseEvent[]> {
return this.http.get<ReleaseEvent[]>(`${this.baseUrl}/${releaseId}/events`);
}
searchImages(query: string): Observable<RegistryImage[]> {
return this.http.get<RegistryImage[]>('/api/registry/images/search', { params: { q: query } });
}
getImageDigests(repository: string): Observable<RegistryImage> {
return this.http.get<RegistryImage>('/api/registry/images/digests', { params: { repository } });
}
}
// Mock Client Implementation
@Injectable()
export class MockReleaseManagementClient implements ReleaseManagementApi {
private releases: ManagedRelease[] = [
{
id: 'rel-001',
name: 'Platform Release',
version: '1.2.3',
description: 'Feature release with API improvements and bug fixes',
status: 'deployed',
currentEnvironment: 'production',
targetEnvironment: null,
componentCount: 3,
createdAt: '2026-01-10T08:00:00Z',
createdBy: 'deploy-bot',
updatedAt: '2026-01-11T14:30:00Z',
deployedAt: '2026-01-11T14:30:00Z',
deploymentStrategy: 'rolling',
},
{
id: 'rel-002',
name: 'Platform Release',
version: '1.3.0-rc1',
description: 'Release candidate for next major version',
status: 'ready',
currentEnvironment: 'staging',
targetEnvironment: 'production',
componentCount: 4,
createdAt: '2026-01-11T10:00:00Z',
createdBy: 'ci-pipeline',
updatedAt: '2026-01-12T09:00:00Z',
deployedAt: null,
deploymentStrategy: 'blue_green',
},
{
id: 'rel-003',
name: 'Hotfix',
version: '1.2.4',
description: 'Critical security patch',
status: 'deploying',
currentEnvironment: 'staging',
targetEnvironment: 'production',
componentCount: 1,
createdAt: '2026-01-12T06:00:00Z',
createdBy: 'security-team',
updatedAt: '2026-01-12T10:00:00Z',
deployedAt: null,
deploymentStrategy: 'rolling',
},
{
id: 'rel-004',
name: 'Feature Branch',
version: '2.0.0-alpha',
description: 'New architecture preview',
status: 'draft',
currentEnvironment: null,
targetEnvironment: 'dev',
componentCount: 5,
createdAt: '2026-01-08T15:00:00Z',
createdBy: 'dev-team',
updatedAt: '2026-01-10T11:00:00Z',
deployedAt: null,
deploymentStrategy: 'recreate',
},
{
id: 'rel-005',
name: 'Platform Release',
version: '1.2.2',
description: 'Previous stable release',
status: 'rolled_back',
currentEnvironment: null,
targetEnvironment: null,
componentCount: 3,
createdAt: '2026-01-05T12:00:00Z',
createdBy: 'deploy-bot',
updatedAt: '2026-01-10T08:00:00Z',
deployedAt: '2026-01-06T10:00:00Z',
deploymentStrategy: 'rolling',
},
];
private components: Map<string, ReleaseComponent[]> = new Map([
['rel-001', [
{ id: 'comp-001', releaseId: 'rel-001', name: 'api-service', imageRef: 'registry.example.com/api-service', digest: 'sha256:abc123def456', tag: 'v1.2.3', version: '1.2.3', type: 'container', configOverrides: {} },
{ id: 'comp-002', releaseId: 'rel-001', name: 'worker-service', imageRef: 'registry.example.com/worker-service', digest: 'sha256:def456abc789', tag: 'v1.2.3', version: '1.2.3', type: 'container', configOverrides: {} },
{ id: 'comp-003', releaseId: 'rel-001', name: 'web-app', imageRef: 'registry.example.com/web-app', digest: 'sha256:789abc123def', tag: 'v1.2.3', version: '1.2.3', type: 'container', configOverrides: {} },
]],
['rel-002', [
{ id: 'comp-004', releaseId: 'rel-002', name: 'api-service', imageRef: 'registry.example.com/api-service', digest: 'sha256:new123new456', tag: 'v1.3.0-rc1', version: '1.3.0-rc1', type: 'container', configOverrides: {} },
{ id: 'comp-005', releaseId: 'rel-002', name: 'worker-service', imageRef: 'registry.example.com/worker-service', digest: 'sha256:new456new789', tag: 'v1.3.0-rc1', version: '1.3.0-rc1', type: 'container', configOverrides: {} },
{ id: 'comp-006', releaseId: 'rel-002', name: 'web-app', imageRef: 'registry.example.com/web-app', digest: 'sha256:new789newabc', tag: 'v1.3.0-rc1', version: '1.3.0-rc1', type: 'container', configOverrides: {} },
{ id: 'comp-007', releaseId: 'rel-002', name: 'migration', imageRef: 'registry.example.com/migration', digest: 'sha256:mig123mig456', tag: 'v1.3.0-rc1', version: '1.3.0-rc1', type: 'script', configOverrides: {} },
]],
]);
private events: Map<string, ReleaseEvent[]> = new Map([
['rel-001', [
{ id: 'evt-001', releaseId: 'rel-001', type: 'created', environment: null, actor: 'deploy-bot', message: 'Release created', timestamp: '2026-01-10T08:00:00Z', metadata: {} },
{ id: 'evt-002', releaseId: 'rel-001', type: 'promoted', environment: 'dev', actor: 'deploy-bot', message: 'Promoted to dev', timestamp: '2026-01-10T09:00:00Z', metadata: {} },
{ id: 'evt-003', releaseId: 'rel-001', type: 'deployed', environment: 'dev', actor: 'deploy-bot', message: 'Successfully deployed to dev', timestamp: '2026-01-10T09:30:00Z', metadata: {} },
{ id: 'evt-004', releaseId: 'rel-001', type: 'approved', environment: 'staging', actor: 'qa-team', message: 'Approved for staging', timestamp: '2026-01-10T14:00:00Z', metadata: {} },
{ id: 'evt-005', releaseId: 'rel-001', type: 'deployed', environment: 'staging', actor: 'deploy-bot', message: 'Successfully deployed to staging', timestamp: '2026-01-10T14:30:00Z', metadata: {} },
{ id: 'evt-006', releaseId: 'rel-001', type: 'approved', environment: 'production', actor: 'release-manager', message: 'Approved for production', timestamp: '2026-01-11T10:00:00Z', metadata: {} },
{ id: 'evt-007', releaseId: 'rel-001', type: 'deployed', environment: 'production', actor: 'deploy-bot', message: 'Successfully deployed to production', timestamp: '2026-01-11T14:30:00Z', metadata: {} },
]],
['rel-002', [
{ id: 'evt-008', releaseId: 'rel-002', type: 'created', environment: null, actor: 'ci-pipeline', message: 'Release created from CI', timestamp: '2026-01-11T10:00:00Z', metadata: {} },
{ id: 'evt-009', releaseId: 'rel-002', type: 'deployed', environment: 'staging', actor: 'deploy-bot', message: 'Deployed to staging for testing', timestamp: '2026-01-11T12:00:00Z', metadata: {} },
]],
]);
private generateId(): string {
return 'rel-' + Math.random().toString(36).substring(2, 9);
}
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> {
let filtered = [...this.releases];
if (filter?.search) {
const search = filter.search.toLowerCase();
filtered = filtered.filter(r =>
r.name.toLowerCase().includes(search) ||
r.version.toLowerCase().includes(search) ||
r.description.toLowerCase().includes(search)
);
}
if (filter?.statuses?.length) {
filtered = filtered.filter(r => filter.statuses!.includes(r.status));
}
if (filter?.environment) {
filtered = filtered.filter(r =>
r.currentEnvironment === filter.environment ||
r.targetEnvironment === filter.environment
);
}
// Sort
const sortField = filter?.sortField || 'createdAt';
const sortOrder = filter?.sortOrder || 'desc';
filtered.sort((a, b) => {
const aVal = (a as any)[sortField];
const bVal = (b as any)[sortField];
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortOrder === 'asc' ? cmp : -cmp;
});
const page = filter?.page || 1;
const pageSize = filter?.pageSize || 20;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return of({
items,
total: filtered.length,
page,
pageSize,
}).pipe(delay(200));
}
getRelease(id: string): Observable<ManagedRelease> {
const release = this.releases.find(r => r.id === id);
if (!release) {
throw new Error(`Release not found: ${id}`);
}
return of(release).pipe(delay(100));
}
createRelease(request: CreateManagedReleaseRequest): Observable<ManagedRelease> {
const now = new Date().toISOString();
const newRelease: ManagedRelease = {
id: this.generateId(),
name: request.name,
version: request.version,
description: request.description,
status: 'draft',
currentEnvironment: null,
targetEnvironment: request.targetEnvironment || null,
componentCount: 0,
createdAt: now,
createdBy: 'current-user',
updatedAt: now,
deployedAt: null,
deploymentStrategy: request.deploymentStrategy || 'rolling',
};
this.releases.unshift(newRelease);
return of(newRelease).pipe(delay(300));
}
updateRelease(id: string, request: UpdateManagedReleaseRequest): Observable<ManagedRelease> {
const index = this.releases.findIndex(r => r.id === id);
if (index === -1) throw new Error(`Release not found: ${id}`);
this.releases[index] = {
...this.releases[index],
...request,
updatedAt: new Date().toISOString(),
};
return of(this.releases[index]).pipe(delay(200));
}
deleteRelease(id: string): Observable<void> {
const index = this.releases.findIndex(r => r.id === id);
if (index !== -1) {
this.releases.splice(index, 1);
}
return of(undefined).pipe(delay(200));
}
markReady(id: string): Observable<ManagedRelease> {
const index = this.releases.findIndex(r => r.id === id);
if (index === -1) throw new Error(`Release not found: ${id}`);
this.releases[index] = {
...this.releases[index],
status: 'ready',
updatedAt: new Date().toISOString(),
};
return of(this.releases[index]).pipe(delay(300));
}
requestPromotion(id: string, targetEnvironment: string): Observable<ManagedRelease> {
const index = this.releases.findIndex(r => r.id === id);
if (index === -1) throw new Error(`Release not found: ${id}`);
this.releases[index] = {
...this.releases[index],
targetEnvironment,
updatedAt: new Date().toISOString(),
};
return of(this.releases[index]).pipe(delay(300));
}
deploy(id: string): Observable<ManagedRelease> {
const index = this.releases.findIndex(r => r.id === id);
if (index === -1) throw new Error(`Release not found: ${id}`);
const now = new Date().toISOString();
this.releases[index] = {
...this.releases[index],
status: 'deployed',
currentEnvironment: this.releases[index].targetEnvironment,
targetEnvironment: null,
deployedAt: now,
updatedAt: now,
};
return of(this.releases[index]).pipe(delay(500));
}
rollback(id: string): Observable<ManagedRelease> {
const index = this.releases.findIndex(r => r.id === id);
if (index === -1) throw new Error(`Release not found: ${id}`);
this.releases[index] = {
...this.releases[index],
status: 'rolled_back',
currentEnvironment: null,
updatedAt: new Date().toISOString(),
};
return of(this.releases[index]).pipe(delay(500));
}
cloneRelease(id: string, newName: string, newVersion: string): Observable<ManagedRelease> {
const original = this.releases.find(r => r.id === id);
if (!original) throw new Error(`Release not found: ${id}`);
const now = new Date().toISOString();
const cloned: ManagedRelease = {
id: this.generateId(),
name: newName,
version: newVersion,
description: original.description,
status: 'draft',
currentEnvironment: null,
targetEnvironment: null,
componentCount: original.componentCount,
createdAt: now,
createdBy: 'current-user',
updatedAt: now,
deployedAt: null,
deploymentStrategy: original.deploymentStrategy,
};
// Clone components
const originalComponents = this.components.get(id) || [];
const clonedComponents = originalComponents.map(c => ({
...c,
id: 'comp-' + Math.random().toString(36).substring(2, 9),
releaseId: cloned.id,
}));
this.components.set(cloned.id, clonedComponents);
this.releases.unshift(cloned);
return of(cloned).pipe(delay(300));
}
getComponents(releaseId: string): Observable<ReleaseComponent[]> {
return of(this.components.get(releaseId) || []).pipe(delay(100));
}
addComponent(releaseId: string, request: AddComponentRequest): Observable<ReleaseComponent> {
const component: ReleaseComponent = {
id: 'comp-' + Math.random().toString(36).substring(2, 9),
releaseId,
name: request.name,
imageRef: request.imageRef,
digest: request.digest,
tag: request.tag || null,
version: request.version,
type: request.type,
configOverrides: request.configOverrides || {},
};
const components = this.components.get(releaseId) || [];
components.push(component);
this.components.set(releaseId, components);
// Update release component count
const releaseIndex = this.releases.findIndex(r => r.id === releaseId);
if (releaseIndex !== -1) {
this.releases[releaseIndex] = {
...this.releases[releaseIndex],
componentCount: components.length,
updatedAt: new Date().toISOString(),
};
}
return of(component).pipe(delay(200));
}
updateComponent(releaseId: string, componentId: string, configOverrides: Record<string, string>): Observable<ReleaseComponent> {
const components = this.components.get(releaseId) || [];
const index = components.findIndex(c => c.id === componentId);
if (index === -1) throw new Error(`Component not found: ${componentId}`);
components[index] = { ...components[index], configOverrides };
this.components.set(releaseId, components);
return of(components[index]).pipe(delay(200));
}
removeComponent(releaseId: string, componentId: string): Observable<void> {
const components = this.components.get(releaseId) || [];
const filtered = components.filter(c => c.id !== componentId);
this.components.set(releaseId, filtered);
// Update release component count
const releaseIndex = this.releases.findIndex(r => r.id === releaseId);
if (releaseIndex !== -1) {
this.releases[releaseIndex] = {
...this.releases[releaseIndex],
componentCount: filtered.length,
updatedAt: new Date().toISOString(),
};
}
return of(undefined).pipe(delay(200));
}
getEvents(releaseId: string): Observable<ReleaseEvent[]> {
return of(this.events.get(releaseId) || []).pipe(delay(100));
}
searchImages(query: string): Observable<RegistryImage[]> {
if (query.length < 2) return of([]);
// Mock search results
const results: RegistryImage[] = [
{
name: query + '-service',
repository: `registry.example.com/${query}-service`,
tags: ['latest', 'v1.0.0', 'v1.1.0', 'v1.2.0'],
digests: [
{ tag: 'latest', digest: 'sha256:latest123', pushedAt: '2026-01-12T10:00:00Z' },
{ tag: 'v1.2.0', digest: 'sha256:v120hash', pushedAt: '2026-01-11T10:00:00Z' },
{ tag: 'v1.1.0', digest: 'sha256:v110hash', pushedAt: '2026-01-05T10:00:00Z' },
{ tag: 'v1.0.0', digest: 'sha256:v100hash', pushedAt: '2026-01-01T10:00:00Z' },
],
lastPushed: '2026-01-12T10:00:00Z',
},
{
name: query + '-worker',
repository: `registry.example.com/${query}-worker`,
tags: ['latest', 'v1.0.0'],
digests: [
{ tag: 'latest', digest: 'sha256:workerlatest', pushedAt: '2026-01-10T10:00:00Z' },
{ tag: 'v1.0.0', digest: 'sha256:workerv100', pushedAt: '2026-01-01T10:00:00Z' },
],
lastPushed: '2026-01-10T10:00:00Z',
},
];
return of(results).pipe(delay(300));
}
getImageDigests(repository: string): Observable<RegistryImage> {
const name = repository.split('/').pop() || repository;
return of({
name,
repository,
tags: ['latest', 'v1.0.0', 'v1.1.0'],
digests: [
{ tag: 'latest', digest: 'sha256:abc123', pushedAt: '2026-01-12T10:00:00Z' },
{ tag: 'v1.1.0', digest: 'sha256:def456', pushedAt: '2026-01-10T10:00:00Z' },
{ tag: 'v1.0.0', digest: 'sha256:ghi789', pushedAt: '2026-01-05T10:00:00Z' },
],
lastPushed: '2026-01-12T10:00:00Z',
}).pipe(delay(200));
}
}

View File

@@ -0,0 +1,152 @@
/**
* Release Management Models for Release Orchestrator
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
export type ReleaseWorkflowStatus = 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
export type ComponentType = 'container' | 'helm' | 'script';
export type ReleaseEventType = 'created' | 'promoted' | 'approved' | 'rejected' | 'deployed' | 'failed' | 'rolled_back';
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate';
export interface ManagedRelease {
id: string;
name: string;
version: string;
description: string;
status: ReleaseWorkflowStatus;
currentEnvironment: string | null;
targetEnvironment: string | null;
componentCount: number;
createdAt: string;
createdBy: string;
updatedAt: string;
deployedAt: string | null;
deploymentStrategy: DeploymentStrategy;
}
export interface ReleaseComponent {
id: string;
releaseId: string;
name: string;
imageRef: string;
digest: string;
tag: string | null;
version: string;
type: ComponentType;
configOverrides: Record<string, string>;
}
export interface ReleaseEvent {
id: string;
releaseId: string;
type: ReleaseEventType;
environment: string | null;
actor: string;
message: string;
timestamp: string;
metadata: Record<string, unknown>;
}
export interface RegistryImage {
name: string;
repository: string;
tags: string[];
digests: Array<{ tag: string; digest: string; pushedAt: string }>;
lastPushed: string;
}
export interface CreateManagedReleaseRequest {
name: string;
version: string;
description: string;
targetEnvironment?: string;
deploymentStrategy?: DeploymentStrategy;
}
export interface UpdateManagedReleaseRequest {
name?: string;
version?: string;
description?: string;
targetEnvironment?: string;
deploymentStrategy?: DeploymentStrategy;
}
export interface AddComponentRequest {
name: string;
imageRef: string;
digest: string;
tag?: string;
version: string;
type: ComponentType;
configOverrides?: Record<string, string>;
}
export interface ReleaseFilter {
search?: string;
statuses?: ReleaseWorkflowStatus[];
environment?: string;
sortField?: string;
sortOrder?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export interface ReleaseListResponse {
items: ManagedRelease[];
total: number;
page: number;
pageSize: number;
}
// Display helpers
export function getStatusLabel(status: ReleaseWorkflowStatus): string {
const labels: Record<ReleaseWorkflowStatus, string> = {
draft: 'Draft',
ready: 'Ready',
deploying: 'Deploying',
deployed: 'Deployed',
failed: 'Failed',
rolled_back: 'Rolled Back',
};
return labels[status] || status;
}
export function getStatusColor(status: ReleaseWorkflowStatus): string {
const colors: Record<ReleaseWorkflowStatus, string> = {
draft: '#6c757d',
ready: '#17a2b8',
deploying: '#ffc107',
deployed: '#28a745',
failed: '#dc3545',
rolled_back: '#fd7e14',
};
return colors[status] || '#6c757d';
}
export function getEventIcon(type: ReleaseEventType): string {
const icons: Record<ReleaseEventType, string> = {
created: '+',
promoted: '>',
approved: 'Y',
rejected: 'X',
deployed: '^',
failed: '!',
rolled_back: '<',
};
return icons[type] || 'o';
}
export function getStrategyLabel(strategy: DeploymentStrategy): string {
const labels: Record<DeploymentStrategy, string> = {
rolling: 'Rolling Update',
blue_green: 'Blue/Green',
canary: 'Canary',
recreate: 'Recreate',
};
return labels[strategy] || strategy;
}
export function formatDigest(digest: string): string {
if (!digest) return '';
return digest.length > 19 ? digest.substring(0, 19) + '...' : digest;
}

View File

@@ -1,9 +1,43 @@
// Sprint: SPRINT_20251229_037_FE - Signals Runtime Dashboard
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { Signal, SignalStats, SignalTrigger, SignalListResponse, SignalType, SignalStatus } from './signals.models';
// Call graph path types for reachability evidence
export interface CallGraphHop {
service: string;
endpoint: string;
timestamp: string;
}
export interface CallGraphEvidence {
score: number;
traceId?: string;
}
export interface CallGraphPath {
id: string;
source: string;
target: string;
lastObserved: string;
hops: CallGraphHop[];
evidence: CallGraphEvidence;
}
export interface CallGraphsResponse {
paths: CallGraphPath[];
}
export interface ReachabilityFact {
observedAt: string;
evidenceTraceIds: string[];
}
export interface FactsResponse {
facts: ReachabilityFact[];
}
@Injectable({ providedIn: 'root' })
export class SignalsClient {
private readonly http = inject(HttpClient);
@@ -50,3 +84,18 @@ export class SignalsClient {
return this.http.patch<SignalTrigger>(`${this.baseUrl}/triggers/${id}`, { enabled });
}
}
/**
* Mock signals client for reachability evidence drawer.
* Returns empty data for quickstart/demo mode.
*/
@Injectable({ providedIn: 'root' })
export class MockSignalsClient {
getFacts(_params: { assetId?: string; component: string }): Observable<FactsResponse> {
return of({ facts: [] });
}
getCallGraphs(_params: { assetId?: string }): Observable<CallGraphsResponse> {
return of({ paths: [] });
}
}

View File

@@ -0,0 +1,319 @@
/**
* Workflow API Client
* Sprint: SPRINT_20260110_111_004_FE_workflow_editor
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import type {
Workflow,
WorkflowStep,
CreateWorkflowRequest,
UpdateWorkflowRequest,
UpdateStepsRequest,
WorkflowStatus,
} from './workflow.models';
export const WORKFLOW_API = new InjectionToken<WorkflowApi>('WORKFLOW_API');
export interface WorkflowApi {
listWorkflows(): Observable<Workflow[]>;
getWorkflow(id: string): Observable<Workflow>;
createWorkflow(request: CreateWorkflowRequest): Observable<Workflow>;
updateWorkflow(id: string, request: UpdateWorkflowRequest): Observable<Workflow>;
updateSteps(id: string, request: UpdateStepsRequest): Observable<Workflow>;
deleteWorkflow(id: string): Observable<void>;
activateWorkflow(id: string): Observable<Workflow>;
deactivateWorkflow(id: string): Observable<Workflow>;
cloneWorkflow(id: string, newName: string): Observable<Workflow>;
validateWorkflow(id: string): Observable<{ valid: boolean; errors: string[] }>;
}
// HTTP Client Implementation
@Injectable()
export class WorkflowHttpClient implements WorkflowApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/release-orchestrator/workflows';
listWorkflows(): Observable<Workflow[]> {
return this.http.get<Workflow[]>(this.baseUrl);
}
getWorkflow(id: string): Observable<Workflow> {
return this.http.get<Workflow>(`${this.baseUrl}/${id}`);
}
createWorkflow(request: CreateWorkflowRequest): Observable<Workflow> {
return this.http.post<Workflow>(this.baseUrl, request);
}
updateWorkflow(id: string, request: UpdateWorkflowRequest): Observable<Workflow> {
return this.http.patch<Workflow>(`${this.baseUrl}/${id}`, request);
}
updateSteps(id: string, request: UpdateStepsRequest): Observable<Workflow> {
return this.http.put<Workflow>(`${this.baseUrl}/${id}/steps`, request);
}
deleteWorkflow(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
activateWorkflow(id: string): Observable<Workflow> {
return this.http.post<Workflow>(`${this.baseUrl}/${id}/activate`, {});
}
deactivateWorkflow(id: string): Observable<Workflow> {
return this.http.post<Workflow>(`${this.baseUrl}/${id}/deactivate`, {});
}
cloneWorkflow(id: string, newName: string): Observable<Workflow> {
return this.http.post<Workflow>(`${this.baseUrl}/${id}/clone`, { name: newName });
}
validateWorkflow(id: string): Observable<{ valid: boolean; errors: string[] }> {
return this.http.post<{ valid: boolean; errors: string[] }>(`${this.baseUrl}/${id}/validate`, {});
}
}
// Mock Client Implementation
@Injectable()
export class MockWorkflowClient implements WorkflowApi {
private workflows: Workflow[] = [
{
id: 'wf-001',
name: 'Standard Deployment',
description: 'Standard deployment workflow with approval gates',
version: 3,
status: 'active',
steps: [
{ id: 'step-1', type: 'gate', name: 'Policy Check', config: { policies: ['security', 'compliance'] }, position: { x: 100, y: 100 }, dependencies: [] },
{ id: 'step-2', type: 'approval', name: 'QA Approval', config: { requiredApprovers: 1 }, position: { x: 300, y: 100 }, dependencies: ['step-1'] },
{ id: 'step-3', type: 'deploy', name: 'Deploy to Staging', config: { targetEnvironment: 'staging' }, position: { x: 500, y: 100 }, dependencies: ['step-2'] },
{ id: 'step-4', type: 'wait', name: 'Soak Period', config: { duration: 3600 }, position: { x: 700, y: 100 }, dependencies: ['step-3'] },
{ id: 'step-5', type: 'approval', name: 'Release Approval', config: { requiredApprovers: 2 }, position: { x: 900, y: 100 }, dependencies: ['step-4'] },
{ id: 'step-6', type: 'deploy', name: 'Deploy to Production', config: { targetEnvironment: 'production' }, position: { x: 1100, y: 100 }, dependencies: ['step-5'] },
{ id: 'step-7', type: 'notify', name: 'Notify Team', config: { channels: ['slack'] }, position: { x: 1300, y: 100 }, dependencies: ['step-6'] },
],
triggerEnvironments: ['dev'],
createdAt: '2026-01-05T10:00:00Z',
createdBy: 'admin',
updatedAt: '2026-01-10T15:00:00Z',
lastRunAt: '2026-01-11T09:00:00Z',
},
{
id: 'wf-002',
name: 'Hotfix Deployment',
description: 'Fast-track workflow for critical hotfixes',
version: 2,
status: 'active',
steps: [
{ id: 'step-1', type: 'gate', name: 'Security Scan', config: { policies: ['security'] }, position: { x: 100, y: 100 }, dependencies: [] },
{ id: 'step-2', type: 'approval', name: 'Emergency Approval', config: { requiredApprovers: 1 }, position: { x: 300, y: 100 }, dependencies: ['step-1'] },
{ id: 'step-3', type: 'deploy', name: 'Deploy to Production', config: { targetEnvironment: 'production', strategy: 'rolling' }, position: { x: 500, y: 100 }, dependencies: ['step-2'] },
{ id: 'step-4', type: 'notify', name: 'Alert Team', config: { channels: ['slack', 'email'] }, position: { x: 700, y: 100 }, dependencies: ['step-3'] },
],
triggerEnvironments: [],
createdAt: '2026-01-08T14:00:00Z',
createdBy: 'security-team',
updatedAt: '2026-01-09T11:00:00Z',
lastRunAt: null,
},
{
id: 'wf-003',
name: 'Canary Deployment',
description: 'Canary deployment with gradual rollout',
version: 1,
status: 'draft',
steps: [
{ id: 'step-1', type: 'gate', name: 'Policy Check', config: {}, position: { x: 100, y: 100 }, dependencies: [] },
{ id: 'step-2', type: 'deploy', name: 'Deploy Canary (5%)', config: { targetEnvironment: 'production', percentage: 5 }, position: { x: 300, y: 100 }, dependencies: ['step-1'] },
{ id: 'step-3', type: 'wait', name: 'Monitor (15m)', config: { duration: 900 }, position: { x: 500, y: 100 }, dependencies: ['step-2'] },
{ id: 'step-4', type: 'deploy', name: 'Expand to 25%', config: { percentage: 25 }, position: { x: 700, y: 100 }, dependencies: ['step-3'] },
{ id: 'step-5', type: 'wait', name: 'Monitor (30m)', config: { duration: 1800 }, position: { x: 900, y: 100 }, dependencies: ['step-4'] },
{ id: 'step-6', type: 'deploy', name: 'Full Rollout', config: { percentage: 100 }, position: { x: 1100, y: 100 }, dependencies: ['step-5'] },
],
triggerEnvironments: [],
createdAt: '2026-01-11T09:00:00Z',
createdBy: 'dev-team',
updatedAt: '2026-01-11T09:00:00Z',
lastRunAt: null,
},
];
private generateId(): string {
return 'wf-' + Math.random().toString(36).substring(2, 9);
}
listWorkflows(): Observable<Workflow[]> {
return of([...this.workflows]).pipe(delay(200));
}
getWorkflow(id: string): Observable<Workflow> {
const workflow = this.workflows.find(w => w.id === id);
if (!workflow) {
throw new Error(`Workflow not found: ${id}`);
}
return of({ ...workflow }).pipe(delay(100));
}
createWorkflow(request: CreateWorkflowRequest): Observable<Workflow> {
const now = new Date().toISOString();
const workflow: Workflow = {
id: this.generateId(),
name: request.name,
description: request.description || '',
version: 1,
status: 'draft',
steps: [],
triggerEnvironments: request.triggerEnvironments || [],
createdAt: now,
createdBy: 'current-user',
updatedAt: now,
lastRunAt: null,
};
this.workflows.unshift(workflow);
return of(workflow).pipe(delay(300));
}
updateWorkflow(id: string, request: UpdateWorkflowRequest): Observable<Workflow> {
const index = this.workflows.findIndex(w => w.id === id);
if (index === -1) throw new Error(`Workflow not found: ${id}`);
this.workflows[index] = {
...this.workflows[index],
...request,
updatedAt: new Date().toISOString(),
};
return of(this.workflows[index]).pipe(delay(200));
}
updateSteps(id: string, request: UpdateStepsRequest): Observable<Workflow> {
const index = this.workflows.findIndex(w => w.id === id);
if (index === -1) throw new Error(`Workflow not found: ${id}`);
this.workflows[index] = {
...this.workflows[index],
steps: request.steps,
version: this.workflows[index].version + 1,
updatedAt: new Date().toISOString(),
};
return of(this.workflows[index]).pipe(delay(300));
}
deleteWorkflow(id: string): Observable<void> {
const index = this.workflows.findIndex(w => w.id === id);
if (index !== -1) {
this.workflows.splice(index, 1);
}
return of(undefined).pipe(delay(200));
}
activateWorkflow(id: string): Observable<Workflow> {
const index = this.workflows.findIndex(w => w.id === id);
if (index === -1) throw new Error(`Workflow not found: ${id}`);
this.workflows[index] = {
...this.workflows[index],
status: 'active',
updatedAt: new Date().toISOString(),
};
return of(this.workflows[index]).pipe(delay(200));
}
deactivateWorkflow(id: string): Observable<Workflow> {
const index = this.workflows.findIndex(w => w.id === id);
if (index === -1) throw new Error(`Workflow not found: ${id}`);
this.workflows[index] = {
...this.workflows[index],
status: 'disabled',
updatedAt: new Date().toISOString(),
};
return of(this.workflows[index]).pipe(delay(200));
}
cloneWorkflow(id: string, newName: string): Observable<Workflow> {
const original = this.workflows.find(w => w.id === id);
if (!original) throw new Error(`Workflow not found: ${id}`);
const now = new Date().toISOString();
const cloned: Workflow = {
...original,
id: this.generateId(),
name: newName,
version: 1,
status: 'draft',
createdAt: now,
createdBy: 'current-user',
updatedAt: now,
lastRunAt: null,
steps: original.steps.map(s => ({
...s,
id: 'step-' + Math.random().toString(36).substring(2, 9),
})),
};
this.workflows.unshift(cloned);
return of(cloned).pipe(delay(300));
}
validateWorkflow(id: string): Observable<{ valid: boolean; errors: string[] }> {
const workflow = this.workflows.find(w => w.id === id);
if (!workflow) {
return of({ valid: false, errors: ['Workflow not found'] }).pipe(delay(100));
}
const errors: string[] = [];
// Validation rules
if (workflow.steps.length === 0) {
errors.push('Workflow must have at least one step');
}
// Check for orphaned steps (no incoming dependencies and not first)
const stepsWithIncoming = new Set(workflow.steps.flatMap(s => s.dependencies));
for (const step of workflow.steps) {
if (step.dependencies.length === 0) {
const hasIncoming = stepsWithIncoming.has(step.id);
if (!hasIncoming) {
// This is okay if it's a starting step
}
}
}
// Check for circular dependencies
const visited = new Set<string>();
const recursionStack = new Set<string>();
const hasCycle = (stepId: string): boolean => {
visited.add(stepId);
recursionStack.add(stepId);
const step = workflow.steps.find(s => s.id === stepId);
if (step) {
for (const depId of step.dependencies) {
if (!visited.has(depId) && hasCycle(depId)) {
return true;
} else if (recursionStack.has(depId)) {
return true;
}
}
}
recursionStack.delete(stepId);
return false;
};
for (const step of workflow.steps) {
if (!visited.has(step.id) && hasCycle(step.id)) {
errors.push('Workflow contains circular dependencies');
break;
}
}
return of({
valid: errors.length === 0,
errors,
}).pipe(delay(200));
}
}

View File

@@ -0,0 +1,182 @@
/**
* Workflow Models for Release Orchestrator
* Sprint: SPRINT_20260110_111_004_FE_workflow_editor
*/
export type WorkflowStepType = 'script' | 'approval' | 'deploy' | 'notify' | 'gate' | 'wait' | 'parallel' | 'manual';
export type WorkflowStatus = 'draft' | 'active' | 'disabled' | 'archived';
export interface WorkflowStepPosition {
x: number;
y: number;
}
export interface WorkflowStep {
id: string;
type: WorkflowStepType;
name: string;
description?: string;
config: Record<string, unknown>;
position: WorkflowStepPosition;
dependencies: string[];
}
export interface Workflow {
id: string;
name: string;
description: string;
version: number;
status: WorkflowStatus;
steps: WorkflowStep[];
triggerEnvironments: string[];
createdAt: string;
createdBy: string;
updatedAt: string;
lastRunAt: string | null;
}
export interface StepTypeDefinition {
type: WorkflowStepType;
label: string;
description: string;
icon: string;
color: string;
defaultConfig: Record<string, unknown>;
}
export interface CreateWorkflowRequest {
name: string;
description?: string;
triggerEnvironments?: string[];
}
export interface UpdateWorkflowRequest {
name?: string;
description?: string;
status?: WorkflowStatus;
triggerEnvironments?: string[];
}
export interface UpdateStepsRequest {
steps: WorkflowStep[];
}
// Step type definitions
export const STEP_TYPES: StepTypeDefinition[] = [
{
type: 'script',
label: 'Script',
description: 'Execute a custom script or command',
icon: 'code',
color: '#6366f1',
defaultConfig: { command: '', timeout: 300 },
},
{
type: 'approval',
label: 'Approval',
description: 'Wait for manual approval',
icon: 'check-circle',
color: '#10b981',
defaultConfig: { requiredApprovers: 1, approverRoles: [] },
},
{
type: 'deploy',
label: 'Deploy',
description: 'Deploy to target environment',
icon: 'rocket',
color: '#3b82f6',
defaultConfig: { targetEnvironment: '', strategy: 'rolling' },
},
{
type: 'notify',
label: 'Notify',
description: 'Send notification',
icon: 'bell',
color: '#f59e0b',
defaultConfig: { channels: [], message: '' },
},
{
type: 'gate',
label: 'Gate',
description: 'Policy gate check',
icon: 'shield',
color: '#ef4444',
defaultConfig: { policies: [], failOnViolation: true },
},
{
type: 'wait',
label: 'Wait',
description: 'Wait for a duration',
icon: 'clock',
color: '#8b5cf6',
defaultConfig: { duration: 60, unit: 'seconds' },
},
{
type: 'parallel',
label: 'Parallel',
description: 'Execute steps in parallel',
icon: 'git-branch',
color: '#14b8a6',
defaultConfig: { branches: [] },
},
{
type: 'manual',
label: 'Manual',
description: 'Manual intervention step',
icon: 'hand',
color: '#f97316',
defaultConfig: { instructions: '' },
},
];
export function getStepTypeDefinition(type: WorkflowStepType): StepTypeDefinition {
return STEP_TYPES.find(s => s.type === type) || STEP_TYPES[0];
}
export function getStatusLabel(status: WorkflowStatus): string {
const labels: Record<WorkflowStatus, string> = {
draft: 'Draft',
active: 'Active',
disabled: 'Disabled',
archived: 'Archived',
};
return labels[status] || status;
}
export function getStatusColor(status: WorkflowStatus): string {
const colors: Record<WorkflowStatus, string> = {
draft: '#6c757d',
active: '#28a745',
disabled: '#ffc107',
archived: '#6c757d',
};
return colors[status] || '#6c757d';
}
// YAML helpers
export function workflowToYaml(workflow: Workflow): string {
const lines: string[] = [];
lines.push(`name: ${workflow.name}`);
lines.push(`description: ${workflow.description}`);
lines.push(`version: ${workflow.version}`);
lines.push('');
lines.push('steps:');
for (const step of workflow.steps) {
lines.push(` - id: ${step.id}`);
lines.push(` name: ${step.name}`);
lines.push(` type: ${step.type}`);
if (step.dependencies.length > 0) {
lines.push(` depends_on: [${step.dependencies.join(', ')}]`);
}
if (Object.keys(step.config).length > 0) {
lines.push(' config:');
for (const [key, value] of Object.entries(step.config)) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
}
}
lines.push('');
}
return lines.join('\n');
}

View File

@@ -146,7 +146,7 @@ import {
<p class="section-desc">Define the escalation path. Each level is triggered after the previous level's delay.</p>
<div formArrayName="levels">
@for (level of levelsArray.controls; track $index; let i = $index) {
@for (level of levelsArray.controls; let i = $index; track i) {
<div class="level-form" [formGroupName]="i">
<div class="level-form-header">
<span class="level-badge">Level {{ i + 1 }}</span>
@@ -208,7 +208,7 @@ import {
<section class="form-section">
<h4>Escalation Timeline Preview</h4>
<div class="preview-timeline">
@for (level of levelsArray.controls; track $index; let i = $index) {
@for (level of levelsArray.controls; let i = $index; track i) {
<div class="preview-step">
<div class="preview-time">
{{ calculateCumulativeDelay(i) }} min

View File

@@ -145,7 +145,7 @@ import {
<p class="section-description">Configure how notifications are delivered.</p>
<div formArrayName="actions">
@for (action of actionsArray.controls; track $index; let i = $index) {
@for (action of actionsArray.controls; let i = $index; track i) {
<div class="action-card" [formGroupName]="i">
<div class="action-header">
<span class="action-number">Action {{ i + 1 }}</span>

View File

@@ -123,7 +123,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
<h4>Time Windows</h4>
<div formArrayName="windows">
@for (window of windowsArray.controls; track $index; let i = $index) {
@for (window of windowsArray.controls; let i = $index; track i) {
<div class="window-form" [formGroupName]="i">
<div class="form-row">
<div class="form-group days-group">
@@ -181,7 +181,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
<p class="section-desc">Events that should still notify during quiet hours.</p>
<div formArrayName="exemptions">
@for (exemption of exemptionsArray.controls; track $index; let i = $index) {
@for (exemption of exemptionsArray.controls; let i = $index; track i) {
<div class="exemption-form" [formGroupName]="i">
<div class="form-row">
<div class="form-group">

View File

@@ -210,7 +210,7 @@ import {
</div>
</div>
@if (previewResult()!.variables && Object.keys(previewResult()!.variables).length > 0) {
@if (previewResult()!.variables && getVariableKeys().length > 0) {
<div class="preview-section">
<label>Variables Used</label>
<div class="variables-list">

View File

@@ -94,7 +94,7 @@ import {
<section class="form-section">
<h3>Subject (optional)</h3>
<div class="form-group">
<input type="text" formControlName="subject" placeholder="e.g., CRITICAL: {{cveId}} detected in {{image}}" />
<input type="text" formControlName="subject" [placeholder]="'e.g., CRITICAL: ' + '{{cveId}}' + ' detected in ' + '{{image}}'" />
<span class="help-text">Use {{ '{{variableName}}' }} for variable substitution</span>
</div>
</section>
@@ -130,7 +130,7 @@ import {
<p class="section-desc">Define expected variables for this template.</p>
<div formArrayName="variables">
@for (variable of variablesArray.controls; track $index; let i = $index) {
@for (variable of variablesArray.controls; let i = $index; track i) {
<div class="variable-row" [formGroupName]="i">
<input type="text" formControlName="name" placeholder="Variable name" class="var-name-input" />
<select formControlName="type" class="var-type-select">

View File

@@ -859,10 +859,20 @@ export class AiRunViewerComponent implements OnInit, OnChanges {
const r = this.run();
if (!r) return;
// Find pending approval request from timeline
const pendingEvent = r.timeline?.find(e => e.type === 'approval_requested');
const actionId = pendingEvent && 'actionId' in pendingEvent.content
? (pendingEvent.content as { actionId: string }).actionId
: pendingEvent?.eventId ?? '';
if (!actionId) {
console.error('No pending approval found');
return;
}
this.processing.set(true);
this.api.submitApproval(r.runId, { decision: 'approved' }).subscribe({
next: (updated) => {
this.run.set(updated);
this.api.submitApproval(r.runId, actionId, { decision: 'approved' }).subscribe({
next: () => {
this.loadRun(); // Reload to get updated state
this.processing.set(false);
this.approved.emit(r.runId);
},
@@ -877,10 +887,20 @@ export class AiRunViewerComponent implements OnInit, OnChanges {
const r = this.run();
if (!r) return;
// Find pending approval request from timeline
const pendingEvent = r.timeline?.find(e => e.type === 'approval_requested');
const actionId = pendingEvent && 'actionId' in pendingEvent.content
? (pendingEvent.content as { actionId: string }).actionId
: pendingEvent?.eventId ?? '';
if (!actionId) {
console.error('No pending approval found');
return;
}
this.processing.set(true);
this.api.submitApproval(r.runId, { decision: 'denied', reason: 'Rejected by user' }).subscribe({
next: (updated) => {
this.run.set(updated);
this.api.submitApproval(r.runId, actionId, { decision: 'denied', reason: 'Rejected by user' }).subscribe({
next: () => {
this.loadRun(); // Reload to get updated state
this.processing.set(false);
this.rejected.emit(r.runId);
},

View File

@@ -20,29 +20,29 @@ import { AuditCorrelationCluster } from '../../core/api/audit-log.models';
<p class="description">Events clustered by causality and correlation IDs</p>
</header>
@if (selectedCluster()) {
@if (selectedCluster(); as cluster) {
<div class="cluster-detail">
<header class="detail-header">
<h2>Correlation: {{ selectedCluster()?.correlationId.slice(0, 12) }}...</h2>
<h2>Correlation: {{ cluster.correlationId.slice(0, 12) }}...</h2>
<button class="btn-secondary" (click)="selectedCluster.set(null)">Back to List</button>
</header>
<div class="cluster-meta">
<span class="badge" [class]="selectedCluster()?.outcome">{{ selectedCluster()?.outcome }}</span>
<span>Duration: {{ selectedCluster()?.duration }}ms</span>
<span>{{ selectedCluster()?.relatedEvents.length }} events</span>
<span class="badge" [class]="cluster.outcome">{{ cluster.outcome }}</span>
<span>Duration: {{ cluster.duration }}ms</span>
<span>{{ cluster.relatedEvents.length }} events</span>
</div>
<div class="root-event">
<h3>Root Event</h3>
<div class="event-card" [routerLink]="['/admin/audit/events', selectedCluster()?.rootEvent.id]">
<span class="badge module" [class]="selectedCluster()?.rootEvent.module">{{ selectedCluster()?.rootEvent.module }}</span>
<span class="badge action" [class]="selectedCluster()?.rootEvent.action">{{ selectedCluster()?.rootEvent.action }}</span>
<span class="desc">{{ selectedCluster()?.rootEvent.description }}</span>
<span class="time">{{ formatTime(selectedCluster()?.rootEvent.timestamp!) }}</span>
<div class="event-card" [routerLink]="['/admin/audit/events', cluster.rootEvent.id]">
<span class="badge module" [class]="cluster.rootEvent.module">{{ cluster.rootEvent.module }}</span>
<span class="badge action" [class]="cluster.rootEvent.action">{{ cluster.rootEvent.action }}</span>
<span class="desc">{{ cluster.rootEvent.description }}</span>
<span class="time">{{ formatTime(cluster.rootEvent.timestamp) }}</span>
</div>
</div>
<div class="related-events">
<h3>Related Events</h3>
@for (event of selectedCluster()?.relatedEvents; track event.id) {
@for (event of cluster.relatedEvents; track event.id) {
<div class="event-card" [routerLink]="['/admin/audit/events', event.id]">
<span class="badge module" [class]="event.module">{{ event.module }}</span>
<span class="badge action" [class]="event.action">{{ event.action }}</span>

View File

@@ -58,11 +58,11 @@ import { AuditEvent } from '../../core/api/audit-log.models';
<span class="more">+{{ getDetail(event, 'changedFields').length - 3 }}</span>
}
} @else if (event.diff?.fields?.length) {
@for (field of event.diff.fields.slice(0, 3); track field) {
@for (field of event.diff!.fields!.slice(0, 3); track field) {
<span class="field-badge">{{ field }}</span>
}
@if (event.diff.fields.length > 3) {
<span class="more">+{{ event.diff.fields.length - 3 }}</span>
@if (event.diff!.fields!.length > 3) {
<span class="more">+{{ event.diff!.fields!.length - 3 }}</span>
}
} @else {
-
@@ -81,7 +81,7 @@ import { AuditEvent } from '../../core/api/audit-log.models';
</header>
<div class="diff-meta">
<span>{{ getDetail(selectedEvent()!, 'integrationName') }}</span>
<span>Changed by {{ selectedEvent()?.actor.name }} at {{ formatTime(selectedEvent()?.timestamp!) }}</span>
<span>Changed by {{ selectedEvent()?.actor?.name }} at {{ formatTime(selectedEvent()?.timestamp ?? '') }}</span>
</div>
<div class="diff-container">
<div class="diff-pane before">

View File

@@ -119,7 +119,7 @@ export class AuditPolicyComponent implements OnInit {
}
loadEvents(): void {
const filters = category !== 'all' ? { actions: [this.category as any] } : undefined;
const filters = this.category !== 'all' ? { actions: [this.category as any] } : undefined;
this.auditClient.getPolicyAudit(filters).subscribe((res) => {
this.events.set(res.items);
this.cursor.set(res.cursor);

View File

@@ -83,7 +83,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches';
(click)="loadCoverage()"
title="Refresh data"
>
<span class="btn-icon">@</span>
<span class="btn-icon">&#64;</span>
Refresh
</button>
</div>

View File

@@ -0,0 +1,325 @@
// -----------------------------------------------------------------------------
// change-trace-viewer.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Main container component for change-trace viewing using signals.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
inject,
signal,
computed,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SummaryHeaderComponent } from './components/summary-header/summary-header.component';
import { DeltaListComponent } from './components/delta-list/delta-list.component';
import { ProofPanelComponent } from './components/proof-panel/proof-panel.component';
import { ByteDiffViewerComponent } from './components/byte-diff-viewer/byte-diff-viewer.component';
import { ChangeTrace, PackageDelta } from './models/change-trace.models';
import { ChangeTraceService } from './services/change-trace.service';
@Component({
selector: 'stella-change-trace-viewer',
standalone: true,
imports: [
CommonModule,
SummaryHeaderComponent,
DeltaListComponent,
ProofPanelComponent,
ByteDiffViewerComponent,
],
template: `
<div class="change-trace-viewer">
<div class="viewer-header">
<h1>Change Trace</h1>
<div class="header-actions">
<input
type="file"
#fileInput
(change)="onFileSelected($event)"
accept=".json,.cdxchange.json"
style="display: none"
/>
<button class="btn btn-secondary" (click)="fileInput.click()">
Load File
</button>
<button
class="btn btn-primary"
(click)="exportTrace()"
[disabled]="!trace()"
>
Export
</button>
</div>
</div>
@if (loading()) {
<div class="loading-state">
<p>Loading change trace...</p>
</div>
}
@if (error()) {
<div class="error-state">
<p class="error-message">{{ error() }}</p>
<button class="btn btn-secondary" (click)="clearError()">Dismiss</button>
</div>
}
@if (trace(); as traceData) {
<stella-summary-header [trace]="traceData"></stella-summary-header>
<div class="content-grid">
<div class="main-panel">
<stella-delta-list
[deltas]="traceData.deltas"
(deltaSelected)="onDeltaSelected($event)"
></stella-delta-list>
</div>
<div class="side-panel">
<stella-proof-panel
[delta]="selectedDelta()"
></stella-proof-panel>
@if (selectedDelta()?.bytes?.length) {
<stella-byte-diff-viewer
[byteDeltas]="selectedDelta()!.bytes"
></stella-byte-diff-viewer>
}
</div>
</div>
}
@if (!trace() && !loading() && !error()) {
<div class="empty-state">
<div class="empty-content">
<h2>No Change Trace Loaded</h2>
<p>Load a change trace file or navigate to a specific trace ID.</p>
<button class="btn btn-primary" (click)="fileInput.click()">
Load Change Trace File
</button>
</div>
</div>
}
</div>
`,
styles: [`
.change-trace-viewer {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
}
.header-actions {
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
&:hover:not(:disabled) {
background: var(--color-primary-hover, #1d4ed8);
}
}
.btn-secondary {
background: var(--color-surface, #ffffff);
color: var(--color-text-primary, #111827);
border: 1px solid var(--color-border, #d1d5db);
&:hover {
background: var(--color-surface-hover, #f3f4f6);
}
}
.content-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
}
.main-panel {
min-width: 0;
}
.side-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.loading-state,
.error-state,
.empty-state {
padding: 48px;
text-align: center;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
}
.error-message {
color: var(--color-danger, #dc2626);
margin-bottom: 16px;
}
.empty-content {
max-width: 400px;
margin: 0 auto;
h2 {
margin: 0 0 8px;
font-size: 18px;
color: var(--color-text-primary, #111827);
}
p {
margin: 0 0 24px;
color: var(--color-text-muted, #9ca3af);
}
}
@media (max-width: 1200px) {
.content-grid {
grid-template-columns: 1fr;
}
.side-panel {
order: -1;
}
}
@media (max-width: 768px) {
.change-trace-viewer {
padding: 16px;
}
.viewer-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChangeTraceViewerComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly changeTraceService = inject(ChangeTraceService);
// State signals
readonly trace = signal<ChangeTrace | null>(null);
readonly selectedDelta = signal<PackageDelta | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Computed values
readonly hasTrace = computed(() => this.trace() !== null);
readonly deltas = computed(() => this.trace()?.deltas ?? []);
constructor() {
// Subscribe to route params with automatic cleanup
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
const traceId = params['traceId'];
if (traceId) {
this.loadTrace(traceId);
}
});
}
ngOnInit(): void {
// Initial setup if needed
}
loadTrace(traceId: string): void {
this.loading.set(true);
this.error.set(null);
this.changeTraceService.getTrace(traceId).subscribe({
next: (trace) => {
this.trace.set(trace);
this.selectedDelta.set(null);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load trace');
this.loading.set(false);
},
});
}
onDeltaSelected(delta: PackageDelta): void {
this.selectedDelta.set(delta);
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
this.loading.set(true);
this.error.set(null);
this.changeTraceService.loadFromFile(file).subscribe({
next: (trace) => {
this.trace.set(trace);
this.selectedDelta.set(null);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load trace from file');
this.loading.set(false);
},
});
input.value = ''; // Reset for re-selection
}
}
exportTrace(): void {
const traceData = this.trace();
if (traceData) {
this.changeTraceService.exportToFile(traceData);
}
}
clearError(): void {
this.error.set(null);
}
}

View File

@@ -0,0 +1,24 @@
// -----------------------------------------------------------------------------
// change-trace.routes.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Routes for change-trace feature.
// -----------------------------------------------------------------------------
import { Routes } from '@angular/router';
export const changeTraceRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./change-trace-viewer.component').then(
(m) => m.ChangeTraceViewerComponent
),
},
{
path: ':traceId',
loadComponent: () =>
import('./change-trace-viewer.component').then(
(m) => m.ChangeTraceViewerComponent
),
},
];

View File

@@ -0,0 +1,84 @@
<!-- byte-diff-viewer.component.html -->
<div class="byte-diff-viewer" *ngIf="byteDeltas.length > 0">
<div class="viewer-header">
<h4>
<span class="icon">[B]</span>
Byte-Level Changes ({{ byteDeltas.length }})
</h4>
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode === 'list'"
(click)="setViewMode('list')"
aria-label="List view"
>
List
</button>
<button
class="toggle-btn"
[class.active]="viewMode === 'hex'"
(click)="setViewMode('hex')"
aria-label="Hex view"
>
Hex
</button>
</div>
</div>
<!-- List View -->
<div class="list-view" *ngIf="viewMode === 'list'">
<table class="byte-table">
<thead>
<tr>
<th>Offset</th>
<th>Size</th>
<th>Section</th>
<th>From Hash</th>
<th>To Hash</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let delta of byteDeltas">
<td class="offset">{{ formatOffset(delta.offset) }}</td>
<td class="size">{{ formatSize(delta.size) }}</td>
<td class="section">{{ delta.section || '-' }}</td>
<td class="hash from" [title]="delta.fromHash">
{{ truncateHash(delta.fromHash) }}
</td>
<td class="hash to" [title]="delta.toHash">
{{ truncateHash(delta.toHash) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Hex View (simplified representation) -->
<div class="hex-view" *ngIf="viewMode === 'hex'">
<div class="hex-grid" *ngFor="let delta of byteDeltas">
<div class="hex-header">
<span class="offset">{{ formatOffset(delta.offset) }}</span>
<span class="section" *ngIf="delta.section">[{{ delta.section }}]</span>
<span class="size">{{ formatSize(delta.size) }}</span>
</div>
<div class="hex-content">
<div class="hex-row from">
<span class="label">Before:</span>
<code class="hash">{{ delta.fromHash }}</code>
</div>
<div class="hex-row to">
<span class="label">After:</span>
<code class="hash">{{ delta.toHash }}</code>
</div>
<div class="hex-context" *ngIf="delta.context">
<span class="label">Context:</span>
<span class="value">{{ delta.context }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="empty-state" *ngIf="byteDeltas.length === 0">
<p>No byte-level changes detected.</p>
</div>

View File

@@ -0,0 +1,263 @@
// byte-diff-viewer.component.scss
.byte-diff-viewer {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary, #111827);
display: flex;
align-items: center;
gap: 8px;
.icon {
font-family: var(--font-mono, monospace);
color: var(--color-primary, #2563eb);
}
}
}
.view-toggle {
display: flex;
gap: 4px;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
padding: 2px;
}
.toggle-btn {
padding: 4px 12px;
border: none;
background: transparent;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-surface-hover, #f3f4f6);
color: var(--color-text-primary, #111827);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
}
}
// List view styles
.list-view {
overflow-x: auto;
}
.byte-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 10px 16px;
text-align: left;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
th {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-surface-alt, #f9fafb);
}
td {
font-size: 12px;
}
.offset {
font-family: var(--font-mono, monospace);
color: var(--color-primary, #2563eb);
}
.size {
color: var(--color-text-secondary, #6b7280);
}
.section {
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary, #6b7280);
}
.hash {
font-family: var(--font-mono, monospace);
font-size: 11px;
&.from {
color: var(--color-text-muted, #9ca3af);
}
&.to {
color: var(--color-text-primary, #111827);
}
}
tbody tr:hover {
background: var(--color-surface-hover, #f9fafb);
}
}
// Hex view styles
.hex-view {
padding: 8px;
}
.hex-grid {
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
}
.hex-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-surface, #ffffff);
border-bottom: 1px solid var(--color-border, #e5e7eb);
.offset {
font-family: var(--font-mono, monospace);
font-size: 13px;
font-weight: 600;
color: var(--color-primary, #2563eb);
}
.section {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
padding: 2px 6px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 4px;
}
.size {
margin-left: auto;
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
}
}
.hex-content {
padding: 12px;
}
.hex-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
flex-shrink: 0;
width: 60px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.hash {
font-family: var(--font-mono, monospace);
font-size: 11px;
word-break: break-all;
line-height: 1.5;
}
&.from .hash {
color: var(--color-text-muted, #9ca3af);
text-decoration: line-through;
text-decoration-color: var(--color-danger, #dc2626);
}
&.to .hash {
color: var(--color-text-primary, #111827);
background: var(--color-success-bg, #dcfce7);
padding: 2px 4px;
border-radius: 2px;
}
}
.hex-context {
display: flex;
align-items: flex-start;
gap: 12px;
padding-top: 8px;
border-top: 1px dashed var(--color-border, #e5e7eb);
margin-top: 8px;
.label {
flex-shrink: 0;
width: 60px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.value {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-style: italic;
}
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
}
// Responsive
@media (max-width: 768px) {
.byte-table {
th, td {
padding: 8px 12px;
}
}
.hex-row {
flex-direction: column;
gap: 4px;
.label {
width: auto;
}
}
}

View File

@@ -0,0 +1,51 @@
// -----------------------------------------------------------------------------
// byte-diff-viewer.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Viewer for byte-level binary changes with list and hex modes.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ByteDelta } from '../../models/change-trace.models';
type ViewMode = 'list' | 'hex';
@Component({
selector: 'stella-byte-diff-viewer',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './byte-diff-viewer.component.html',
styleUrls: ['./byte-diff-viewer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ByteDiffViewerComponent {
@Input() byteDeltas: ByteDelta[] = [];
viewMode: ViewMode = 'list';
formatOffset(offset: number): string {
return '0x' + offset.toString(16).toUpperCase().padStart(8, '0');
}
formatSize(size: number): string {
if (size >= 1024 * 1024) {
return (size / (1024 * 1024)).toFixed(2) + ' MB';
}
if (size >= 1024) {
return (size / 1024).toFixed(2) + ' KB';
}
return size + ' bytes';
}
truncateHash(hash: string, maxLength: number = 16): string {
if (!hash) return '-';
return hash.length > maxLength
? hash.substring(0, maxLength) + '...'
: hash;
}
setViewMode(mode: ViewMode): void {
this.viewMode = mode;
}
}

View File

@@ -0,0 +1,140 @@
<!-- delta-list.component.html -->
<div class="delta-list">
<div class="list-controls">
<div class="filter-group">
<label for="changeTypeFilter">Change Type:</label>
<select
id="changeTypeFilter"
[(ngModel)]="changeTypeFilter"
class="filter-select"
>
<option value="all">All Changes</option>
<option [value]="ChangeType.Added">Added</option>
<option [value]="ChangeType.Removed">Removed</option>
<option [value]="ChangeType.Upgraded">Upgraded</option>
<option [value]="ChangeType.Downgraded">Downgraded</option>
<option [value]="ChangeType.Patched">Patched</option>
<option [value]="ChangeType.Rebuilt">Rebuilt</option>
</select>
</div>
<div class="count-label">
Showing {{ filteredDeltas.length }} of {{ deltas.length }} packages
</div>
</div>
<table class="delta-table">
<thead>
<tr>
<th class="col-expand"></th>
<th
class="col-component sortable"
(click)="toggleSort('purl')"
[attr.aria-sort]="sortColumn === 'purl' ? sortDirection : null"
>
Component{{ getSortIndicator('purl') }}
</th>
<th class="col-version">Version</th>
<th
class="col-change-type sortable"
(click)="toggleSort('changeType')"
[attr.aria-sort]="sortColumn === 'changeType' ? sortDirection : null"
>
Change Type{{ getSortIndicator('changeType') }}
</th>
<th
class="col-trust-delta sortable"
(click)="toggleSort('trustDelta')"
[attr.aria-sort]="sortColumn === 'trustDelta' ? sortDirection : null"
>
Trust Delta{{ getSortIndicator('trustDelta') }}
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let delta of filteredDeltas">
<tr
class="delta-row"
[class.selected]="isSelected(delta)"
[class.expanded]="isRowExpanded(delta.purl)"
(click)="selectDelta(delta)"
>
<td class="col-expand">
<button
class="expand-btn"
(click)="toggleRowExpand(delta.purl, $event)"
[attr.aria-expanded]="isRowExpanded(delta.purl)"
*ngIf="delta.symbols.length > 0"
>
{{ isRowExpanded(delta.purl) ? '-' : '+' }}
</button>
</td>
<td class="col-component">
<div class="component-cell">
<span class="change-icon" [ngClass]="getChangeTypeClass(delta.changeType)">
{{ getChangeTypeIcon(delta.changeType) }}
</span>
<span class="purl" [title]="delta.purl">
{{ truncatePurl(delta.purl) }}
</span>
</div>
</td>
<td class="col-version">
<span class="version from">{{ delta.fromVersion || '-' }}</span>
<span class="arrow">-></span>
<span class="version to">{{ delta.toVersion || '-' }}</span>
</td>
<td class="col-change-type">
<span class="change-chip" [ngClass]="getChangeTypeClass(delta.changeType)">
{{ formatChangeType(delta.changeType) }}
</span>
</td>
<td class="col-trust-delta">
<stella-trust-badge [score]="delta.trustDelta.score"></stella-trust-badge>
</td>
</tr>
<!-- Expanded Symbol Details -->
<tr class="symbol-detail-row" *ngIf="isRowExpanded(delta.purl)">
<td colspan="5">
<div class="symbol-details">
<h4>Symbols Changed ({{ delta.symbols.length }})</h4>
<table class="symbol-table">
<thead>
<tr>
<th>Symbol</th>
<th>Change</th>
<th>Size Delta</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let symbol of delta.symbols">
<td class="symbol-name" [title]="symbol.symbolName">
{{ symbol.symbolName }}
</td>
<td>
<span class="symbol-change-chip" [ngClass]="'symbol-' + symbol.changeType">
{{ symbol.changeType }}
</span>
</td>
<td
[class.positive]="symbol.sizeDelta < 0"
[class.negative]="symbol.sizeDelta > 0"
>
{{ symbol.sizeDelta > 0 ? '+' : '' }}{{ symbol.sizeDelta }} bytes
</td>
<td>{{ (symbol.confidence * 100).toFixed(0) }}%</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredDeltas.length === 0">
<p>No package changes found matching the current filter.</p>
</div>
</div>

View File

@@ -0,0 +1,363 @@
// delta-list.component.scss
.delta-list {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.list-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-alt, #f9fafb);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 13px;
color: var(--color-text-secondary, #6b7280);
}
}
.filter-select {
padding: 6px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
font-size: 13px;
background: var(--color-surface, #ffffff);
color: var(--color-text-primary, #111827);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 2px var(--color-primary-bg, #eff6ff);
}
}
.count-label {
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
}
.delta-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface-alt, #f9fafb);
text-transform: uppercase;
letter-spacing: 0.05em;
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
color: var(--color-text-primary, #111827);
background: var(--color-surface-hover, #f3f4f6);
}
}
}
}
.col-expand {
width: 40px;
text-align: center;
}
.col-component {
min-width: 300px;
}
.col-version {
min-width: 200px;
}
.col-change-type {
min-width: 120px;
}
.col-trust-delta {
min-width: 100px;
}
.delta-row {
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-surface-hover, #f9fafb);
}
&.selected {
background-color: var(--color-primary-bg, #eff6ff);
}
&.expanded {
background-color: var(--color-surface-alt, #f9fafb);
}
}
.expand-btn {
width: 24px;
height: 24px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 4px;
background: var(--color-surface, #ffffff);
color: var(--color-text-secondary, #6b7280);
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-hover, #f3f4f6);
color: var(--color-text-primary, #111827);
}
}
.component-cell {
display: flex;
align-items: center;
gap: 8px;
}
.change-icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
&.change-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.change-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.change-upgraded {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
&.change-downgraded {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
&.change-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
}
&.change-rebuilt {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
&.change-unchanged {
background: var(--color-surface-alt, #f9fafb);
color: var(--color-text-muted, #9ca3af);
}
}
.purl {
font-family: var(--font-mono, monospace);
font-size: 13px;
color: var(--color-text-primary, #111827);
}
.version {
font-family: var(--font-mono, monospace);
font-size: 12px;
&.from {
color: var(--color-text-muted, #9ca3af);
}
&.to {
color: var(--color-text-primary, #111827);
}
}
.arrow {
margin: 0 8px;
color: var(--color-text-muted, #9ca3af);
}
.change-chip {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
&.change-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.change-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.change-upgraded {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
&.change-downgraded {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
&.change-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
}
&.change-rebuilt {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
&.change-unchanged {
background: var(--color-surface-alt, #f9fafb);
color: var(--color-text-muted, #9ca3af);
}
}
// Symbol details expandable row
.symbol-detail-row {
background: var(--color-surface-alt, #f9fafb);
td {
padding: 0;
}
}
.symbol-details {
padding: 16px;
border-top: 1px solid var(--color-border, #e5e7eb);
h4 {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
}
.symbol-table {
width: 100%;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
background: var(--color-surface, #ffffff);
th, td {
padding: 8px 12px;
font-size: 12px;
}
th {
background: var(--color-surface-alt, #f9fafb);
}
.symbol-name {
font-family: var(--font-mono, monospace);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.positive {
color: var(--color-success, #16a34a);
}
.negative {
color: var(--color-danger, #dc2626);
}
}
.symbol-change-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
&.symbol-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.symbol-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.symbol-modified {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
&.symbol-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
}
&.symbol-unchanged {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
}
// Responsive
@media (max-width: 1024px) {
.delta-table {
display: block;
overflow-x: auto;
}
.col-component {
min-width: 200px;
}
}

View File

@@ -0,0 +1,166 @@
// -----------------------------------------------------------------------------
// delta-list.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Sortable, filterable list of package deltas.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
ChangeType,
PackageDelta,
} from '../../models/change-trace.models';
import { TrustBadgeComponent } from '../trust-badge/trust-badge.component';
type SortColumn = 'purl' | 'changeType' | 'trustDelta';
type SortDirection = 'asc' | 'desc';
@Component({
selector: 'stella-delta-list',
standalone: true,
imports: [CommonModule, FormsModule, TrustBadgeComponent],
templateUrl: './delta-list.component.html',
styleUrls: ['./delta-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeltaListComponent {
@Input() deltas: PackageDelta[] = [];
@Output() deltaSelected = new EventEmitter<PackageDelta>();
selectedDelta: PackageDelta | null = null;
expandedRows = new Set<string>();
// Sorting state
sortColumn: SortColumn = 'trustDelta';
sortDirection: SortDirection = 'desc';
// Filter state
changeTypeFilter: ChangeType | 'all' = 'all';
// Expose enum for template
ChangeType = ChangeType;
get filteredDeltas(): PackageDelta[] {
let result = this.deltas;
if (this.changeTypeFilter !== 'all') {
result = result.filter((d) => d.changeType === this.changeTypeFilter);
}
return this.sortDeltas(result);
}
private sortDeltas(deltas: PackageDelta[]): PackageDelta[] {
return [...deltas].sort((a, b) => {
let comparison = 0;
switch (this.sortColumn) {
case 'purl':
comparison = a.purl.localeCompare(b.purl);
break;
case 'changeType':
comparison = a.changeType.localeCompare(b.changeType);
break;
case 'trustDelta':
comparison = (a.trustDelta.score - b.trustDelta.score);
break;
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
}
toggleSort(column: SortColumn): void {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = column;
this.sortDirection = 'desc';
}
}
getSortIndicator(column: SortColumn): string {
if (this.sortColumn !== column) return '';
return this.sortDirection === 'asc' ? ' ^' : ' v';
}
toggleRowExpand(purl: string, event: Event): void {
event.stopPropagation();
if (this.expandedRows.has(purl)) {
this.expandedRows.delete(purl);
} else {
this.expandedRows.add(purl);
}
}
isRowExpanded(purl: string): boolean {
return this.expandedRows.has(purl);
}
selectDelta(delta: PackageDelta): void {
this.selectedDelta = delta;
this.deltaSelected.emit(delta);
}
isSelected(delta: PackageDelta): boolean {
return this.selectedDelta === delta;
}
getTrustDeltaClass(score: number): string {
if (score < -0.1) return 'trust-positive';
if (score > 0.1) return 'trust-negative';
return 'trust-neutral';
}
getChangeTypeIcon(type: ChangeType): string {
switch (type) {
case ChangeType.Added:
return '+';
case ChangeType.Removed:
return '-';
case ChangeType.Upgraded:
return '^';
case ChangeType.Downgraded:
return 'v';
case ChangeType.Patched:
return '*';
case ChangeType.Rebuilt:
return 'o';
default:
return '=';
}
}
getChangeTypeClass(type: ChangeType): string {
switch (type) {
case ChangeType.Added:
return 'change-added';
case ChangeType.Removed:
return 'change-removed';
case ChangeType.Upgraded:
return 'change-upgraded';
case ChangeType.Downgraded:
return 'change-downgraded';
case ChangeType.Patched:
return 'change-patched';
case ChangeType.Rebuilt:
return 'change-rebuilt';
default:
return 'change-unchanged';
}
}
formatChangeType(type: ChangeType): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
truncatePurl(purl: string, maxLength: number = 50): string {
if (!purl || purl.length <= maxLength) return purl || '-';
return purl.substring(0, maxLength - 3) + '...';
}
}

View File

@@ -0,0 +1,77 @@
<!-- proof-panel.component.html -->
<div class="proof-panel" *ngIf="delta">
<div class="panel-header" (click)="toggle()">
<div class="header-title">
<span class="icon">[P]</span>
<span class="title">Proof Steps</span>
</div>
<div class="header-meta">
<span class="step-count">{{ delta.trustDelta.proofSteps.length }} steps</span>
<span class="expand-icon">{{ isExpanded ? '-' : '+' }}</span>
</div>
</div>
<div class="panel-content" *ngIf="isExpanded">
<div class="trust-scores">
<div class="score-item before">
<span class="label">Before:</span>
<span class="value">{{ delta.trustDelta.beforeScore | number:'1.2-2' }}</span>
</div>
<span class="arrow">-></span>
<div class="score-item after">
<span class="label">After:</span>
<span class="value">{{ delta.trustDelta.afterScore | number:'1.2-2' }}</span>
</div>
<div
class="score-delta"
[ngClass]="getTrustDeltaClass(delta.trustDelta.score)"
>
{{ delta.trustDelta.score > 0 ? '+' : '' }}{{ delta.trustDelta.score | number:'1.2-2' }}
</div>
</div>
<div class="impact-badges">
<div
class="impact-chip"
[ngClass]="getImpactClass(delta.trustDelta.reachabilityImpact)"
>
<span class="impact-icon">[R]</span>
<span class="impact-label">
Reachability: {{ formatImpact(delta.trustDelta.reachabilityImpact) }}
</span>
</div>
<div
class="impact-chip"
[ngClass]="getImpactClass(delta.trustDelta.exploitabilityImpact)"
>
<span class="impact-icon">[E]</span>
<span class="impact-label">
Exploitability: {{ formatImpact(delta.trustDelta.exploitabilityImpact) }}
</span>
</div>
</div>
<ol class="proof-steps">
<li
*ngFor="let step of delta.trustDelta.proofSteps; let i = index"
[ngClass]="getStepClass(step)"
>
<span class="step-icon">{{ getStepIcon(step) }}</span>
<span class="step-text">{{ step }}</span>
<div class="evidence-chips" *ngIf="parseEvidence(step).length > 0">
<span
*ngFor="let ev of parseEvidence(step)"
class="evidence-chip"
[ngClass]="'evidence-' + ev.type"
>
{{ ev.value }}
</span>
</div>
</li>
</ol>
</div>
</div>
<div class="empty-state" *ngIf="!delta">
<p>Select a package to view proof steps</p>
</div>

View File

@@ -0,0 +1,273 @@
// proof-panel.component.scss
.proof-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
cursor: pointer;
user-select: none;
&:hover {
background: var(--color-surface-hover, #f3f4f6);
}
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
.icon {
font-family: var(--font-mono, monospace);
font-size: 14px;
color: var(--color-primary, #2563eb);
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
}
.header-meta {
display: flex;
align-items: center;
gap: 12px;
.step-count {
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
}
.expand-icon {
font-size: 16px;
font-weight: bold;
color: var(--color-text-secondary, #6b7280);
}
}
.panel-content {
padding: 16px;
}
.trust-scores {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 6px;
margin-bottom: 16px;
}
.score-item {
display: flex;
align-items: baseline;
gap: 8px;
.label {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
}
.value {
font-size: 18px;
font-weight: 600;
font-family: var(--font-mono, monospace);
}
&.before .value {
color: var(--color-text-muted, #9ca3af);
}
&.after .value {
color: var(--color-text-primary, #111827);
}
}
.arrow {
color: var(--color-text-muted, #9ca3af);
font-size: 14px;
}
.score-delta {
margin-left: auto;
padding: 6px 12px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
font-family: var(--font-mono, monospace);
&.trust-positive {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.trust-negative {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.trust-neutral {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
}
.impact-badges {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.impact-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
.impact-icon {
font-family: var(--font-mono, monospace);
}
&.impact-positive {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.impact-negative {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.impact-neutral {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
}
.proof-steps {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-left: 3px solid var(--color-border, #e5e7eb);
margin-bottom: 4px;
background: var(--color-surface, #ffffff);
border-radius: 0 6px 6px 0;
&:hover {
background: var(--color-surface-alt, #f9fafb);
}
&.step-positive {
border-left-color: var(--color-success, #16a34a);
background: var(--color-success-bg, #dcfce7);
&:hover {
background: #bbf7d0;
}
}
&.step-negative {
border-left-color: var(--color-danger, #dc2626);
background: var(--color-danger-bg, #fee2e2);
&:hover {
background: #fecaca;
}
}
}
}
.step-icon {
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
flex-shrink: 0;
}
.step-text {
flex: 1;
font-size: 13px;
line-height: 1.4;
color: var(--color-text-primary, #111827);
}
.evidence-chips {
width: 100%;
display: flex;
gap: 6px;
margin-top: 4px;
padding-left: 24px;
}
.evidence-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
font-family: var(--font-mono, monospace);
&.evidence-cve {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.evidence-confidence {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
&.evidence-version {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
}
// Responsive
@media (max-width: 640px) {
.trust-scores {
flex-wrap: wrap;
}
.score-delta {
margin-left: 0;
width: 100%;
text-align: center;
}
.impact-badges {
flex-direction: column;
}
}

View File

@@ -0,0 +1,111 @@
// -----------------------------------------------------------------------------
// proof-panel.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Panel showing proof steps for trust delta calculation.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import {
ExploitabilityImpact,
PackageDelta,
ReachabilityImpact,
} from '../../models/change-trace.models';
interface Evidence {
type: 'cve' | 'confidence' | 'version';
value: string;
}
@Component({
selector: 'stella-proof-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './proof-panel.component.html',
styleUrls: ['./proof-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProofPanelComponent {
@Input() delta: PackageDelta | null = null;
isExpanded = true;
toggle(): void {
this.isExpanded = !this.isExpanded;
}
getStepIcon(step: string): string {
if (step.includes('CVE-')) return '[!]';
if (step.toLowerCase().includes('version')) return '[v]';
if (step.toLowerCase().includes('patch')) return '[*]';
if (step.toLowerCase().includes('symbol')) return '[s]';
if (step.toLowerCase().includes('reachab')) return '[r]';
if (step.toLowerCase().includes('dsse')) return '[+]';
if (step.toLowerCase().includes('verdict')) return '[=]';
return '[-]';
}
getStepClass(step: string): string {
const lower = step.toLowerCase();
if (lower.includes('risk_down') || lower.includes('reduced') || lower.includes('eliminated')) {
return 'step-positive';
}
if (lower.includes('risk_up') || lower.includes('increased') || lower.includes('introduced')) {
return 'step-negative';
}
return '';
}
parseEvidence(step: string): Evidence[] {
const evidence: Evidence[] = [];
// Extract CVE IDs
const cveMatches = step.match(/CVE-\d{4}-\d+/g);
if (cveMatches) {
cveMatches.forEach((cve) =>
evidence.push({ type: 'cve', value: cve })
);
}
// Extract confidence percentages
const confMatch = step.match(/(\d+(?:\.\d+)?%)/);
if (confMatch) {
evidence.push({ type: 'confidence', value: confMatch[1] });
}
// Extract version numbers
const versionMatch = step.match(/\d+\.\d+\.\d+(?:-\S+)?/);
if (versionMatch && !cveMatches) {
evidence.push({ type: 'version', value: versionMatch[0] });
}
return evidence;
}
getTrustDeltaClass(score: number): string {
if (score < -0.1) return 'trust-positive';
if (score > 0.1) return 'trust-negative';
return 'trust-neutral';
}
getImpactClass(impact: ReachabilityImpact | ExploitabilityImpact): string {
switch (impact) {
case ReachabilityImpact.Eliminated:
case ExploitabilityImpact.Eliminated:
case ReachabilityImpact.Reduced:
case ExploitabilityImpact.Down:
return 'impact-positive';
case ReachabilityImpact.Introduced:
case ExploitabilityImpact.Introduced:
case ReachabilityImpact.Increased:
case ExploitabilityImpact.Up:
return 'impact-negative';
default:
return 'impact-neutral';
}
}
formatImpact(impact: ReachabilityImpact | ExploitabilityImpact): string {
return impact.charAt(0).toUpperCase() + impact.slice(1);
}
}

View File

@@ -0,0 +1,55 @@
<!-- summary-header.component.html -->
<div class="summary-header">
<div class="header-row">
<div class="image-info">
<span class="image-ref">{{ trace.subject.imageRef }}</span>
<div class="digest-comparison">
<span class="digest from" [title]="trace.subject.fromDigest">
{{ formatDigest(trace.subject.fromDigest) }}
</span>
<span class="arrow">-></span>
<span class="digest to" [title]="trace.subject.toDigest">
{{ formatDigest(trace.subject.toDigest) }}
</span>
</div>
</div>
<div class="verdict-badge" [ngClass]="verdictClass">
<span class="verdict-icon">{{ verdictIcon }}</span>
<span class="verdict-label">{{ verdictLabel }}</span>
<span class="trust-delta">
{{ trace.summary.riskDelta > 0 ? '+' : '' }}{{ trace.summary.riskDelta | number:'1.2-2' }}
</span>
</div>
</div>
<div class="stats-row">
<div class="stat">
<span class="stat-value">{{ trace.summary.changedPackages }}</span>
<span class="stat-label">Packages Changed</span>
</div>
<div class="stat">
<span class="stat-value">{{ trace.summary.changedSymbols }}</span>
<span class="stat-label">Symbols Changed</span>
</div>
<div class="stat">
<span class="stat-value">{{ formatNumber(trace.summary.changedBytes) }}</span>
<span class="stat-label">Bytes Changed</span>
</div>
</div>
<div class="method-badges">
<span class="method-chip" *ngIf="trace.summary.changedPackages > 0">Package Level</span>
<span class="method-chip" *ngIf="trace.summary.changedSymbols > 0">Symbol Level</span>
<span class="method-chip" *ngIf="trace.summary.changedBytes > 0">Byte Level</span>
</div>
<div class="metadata-row">
<span class="timestamp">
Analyzed: {{ formatDate(trace.basis.analyzedAt) }}
</span>
<span class="engine-version" *ngIf="trace.basis.engineVersion">
Engine: {{ trace.basis.engineVersion }}
</span>
</div>
</div>

View File

@@ -0,0 +1,167 @@
// summary-header.component.scss
.summary-header {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.image-info {
.image-ref {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary, #111827);
display: block;
margin-bottom: 4px;
}
.digest-comparison {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary, #6b7280);
}
.digest {
padding: 2px 6px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 4px;
&.from {
color: var(--color-text-muted, #9ca3af);
}
&.to {
color: var(--color-text-primary, #111827);
}
}
.arrow {
color: var(--color-text-muted, #9ca3af);
}
}
.verdict-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
&.verdict-positive {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.verdict-negative {
background-color: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
&.verdict-neutral {
background-color: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
&.verdict-warning {
background-color: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
.verdict-icon {
font-size: 16px;
}
.verdict-label {
font-size: 14px;
}
.trust-delta {
font-family: var(--font-mono, monospace);
font-size: 14px;
}
}
.stats-row {
display: flex;
gap: 24px;
margin-bottom: 16px;
}
.stat {
display: flex;
flex-direction: column;
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary, #111827);
}
.stat-label {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
}
}
.method-badges {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.method-chip {
padding: 4px 12px;
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.metadata-row {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
.timestamp {
flex: 1;
}
.engine-version {
font-family: var(--font-mono, monospace);
}
}
// Responsive adjustments
@media (max-width: 768px) {
.header-row {
flex-direction: column;
}
.verdict-badge {
align-self: flex-start;
}
.stats-row {
flex-wrap: wrap;
}
.stat {
min-width: 100px;
}
}

View File

@@ -0,0 +1,79 @@
// -----------------------------------------------------------------------------
// summary-header.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Summary header showing change trace overview and verdict.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.models';
@Component({
selector: 'stella-summary-header',
standalone: true,
imports: [CommonModule],
templateUrl: './summary-header.component.html',
styleUrls: ['./summary-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummaryHeaderComponent {
@Input({ required: true }) trace!: ChangeTrace;
get verdictClass(): string {
switch (this.trace.summary.verdict) {
case ChangeTraceVerdict.RiskDown:
return 'verdict-positive';
case ChangeTraceVerdict.RiskUp:
return 'verdict-negative';
case ChangeTraceVerdict.Inconclusive:
return 'verdict-warning';
default:
return 'verdict-neutral';
}
}
get verdictIcon(): string {
switch (this.trace.summary.verdict) {
case ChangeTraceVerdict.RiskDown:
return 'v'; // down indicator
case ChangeTraceVerdict.RiskUp:
return '^'; // up indicator
case ChangeTraceVerdict.Inconclusive:
return '?'; // unknown indicator
default:
return '-'; // neutral indicator
}
}
get verdictLabel(): string {
switch (this.trace.summary.verdict) {
case ChangeTraceVerdict.RiskDown:
return 'RISK DOWN';
case ChangeTraceVerdict.RiskUp:
return 'RISK UP';
case ChangeTraceVerdict.Inconclusive:
return 'INCONCLUSIVE';
default:
return 'NEUTRAL';
}
}
formatDigest(digest: string): string {
if (!digest) return '-';
// Show first 12 chars of digest
return digest.length > 12 ? digest.substring(0, 12) + '...' : digest;
}
formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
formatDate(isoDate: string): string {
if (!isoDate) return '-';
try {
return new Date(isoDate).toLocaleString();
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,76 @@
// -----------------------------------------------------------------------------
// trust-badge.component.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Compact badge showing trust delta score with color coding.
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'stella-trust-badge',
standalone: true,
imports: [CommonModule],
template: `
<div class="trust-badge" [ngClass]="badgeClass">
<span class="icon">{{ icon }}</span>
<span class="score">{{ formattedScore }}</span>
</div>
`,
styles: [`
.trust-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.trust-badge.positive {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.trust-badge.negative {
background-color: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
}
.trust-badge.neutral {
background-color: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
}
.icon {
font-size: 14px;
}
.score {
font-family: var(--font-mono, monospace);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TrustBadgeComponent {
@Input() score: number = 0;
get badgeClass(): string {
if (this.score < -0.1) return 'positive';
if (this.score > 0.1) return 'negative';
return 'neutral';
}
get icon(): string {
// Using ASCII arrows for compatibility
if (this.score < -0.1) return 'v'; // down arrow indicator
if (this.score > 0.1) return '^'; // up arrow indicator
return '-'; // neutral indicator
}
get formattedScore(): string {
const prefix = this.score > 0 ? '+' : '';
return prefix + this.score.toFixed(2);
}
}

View File

@@ -0,0 +1,24 @@
// -----------------------------------------------------------------------------
// index.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Barrel export for change-trace feature.
// -----------------------------------------------------------------------------
// Models
export * from './models/change-trace.models';
// Components
export * from './components/summary-header/summary-header.component';
export * from './components/delta-list/delta-list.component';
export * from './components/proof-panel/proof-panel.component';
export * from './components/byte-diff-viewer/byte-diff-viewer.component';
export * from './components/trust-badge/trust-badge.component';
// Services
export * from './services/change-trace.service';
// Routes
export * from './change-trace.routes';
// Main component
export * from './change-trace-viewer.component';

View File

@@ -0,0 +1,276 @@
// -----------------------------------------------------------------------------
// change-trace.models.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: TypeScript models for change-trace feature matching backend DTOs.
// -----------------------------------------------------------------------------
/**
* Root change-trace model representing a comparison between two artifact versions.
*/
export interface ChangeTrace {
/** Schema version identifier */
schema: string;
/** Subject of the change trace (artifact being compared) */
subject: ChangeTraceSubject;
/** Basis information for the analysis */
basis: ChangeTraceBasis;
/** List of package deltas */
deltas: PackageDelta[];
/** Summary statistics and verdict */
summary: ChangeTraceSummary;
/** Cryptographic commitment for determinism verification */
commitment?: ChangeTraceCommitment;
}
/**
* Subject of the change trace - the artifact being compared.
*/
export interface ChangeTraceSubject {
/** Type of subject (e.g., 'oci.image') */
type: string;
/** Image reference */
imageRef: string;
/** Digest identifier */
digest: string;
/** Digest of the 'from' version */
fromDigest: string;
/** Digest of the 'to' version */
toDigest: string;
/** Scan ID of the 'from' version */
fromScanId?: string;
/** Scan ID of the 'to' version */
toScanId?: string;
}
/**
* Basis information for the analysis.
*/
export interface ChangeTraceBasis {
/** Timestamp when analysis was performed (ISO-8601) */
analyzedAt: string;
/** Policies applied during analysis */
policies: string[];
/** Diffing methods used */
diffMethods: DiffMethod[];
/** Engine version used for analysis */
engineVersion: string;
/** Engine source digest */
engineDigest?: string;
}
/**
* Diffing method used in analysis.
*/
export type DiffMethod = 'pkg' | 'symbol' | 'byte';
/**
* Package-level delta showing changes in a component.
*/
export interface PackageDelta {
/** Package URL (PURL) identifier */
purl: string;
/** Version before the change */
fromVersion?: string;
/** Version after the change */
toVersion?: string;
/** Type of change */
changeType: ChangeType;
/** Symbol-level deltas */
symbols: SymbolDelta[];
/** Byte-level deltas */
bytes: ByteDelta[];
/** Trust delta score and proof */
trustDelta: TrustDelta;
}
/**
* Symbol-level delta showing changes in functions/symbols.
*/
export interface SymbolDelta {
/** Symbol name */
symbolName: string;
/** Type of change */
changeType: SymbolChangeType;
/** Size difference in bytes */
sizeDelta: number;
/** Hash of the symbol before the change */
fromHash?: string;
/** Hash of the symbol after the change */
toHash?: string;
/** CFG block count difference */
cfgBlockDelta?: number;
/** Confidence score (0-1) */
confidence: number;
/** Method used for matching */
matchMethod?: string;
/** Human-readable explanation */
explanation?: string;
}
/**
* Byte-level delta showing raw binary changes.
*/
export interface ByteDelta {
/** Offset in the binary */
offset: number;
/** Size of the changed region */
size: number;
/** Hash of the region before the change */
fromHash: string;
/** Hash of the region after the change */
toHash: string;
/** Section name (e.g., .text, .data) */
section?: string;
/** Contextual information */
context?: string;
}
/**
* Trust delta showing impact on trust score.
*/
export interface TrustDelta {
/** Net trust score change */
score: number;
/** Trust score before the change */
beforeScore: number;
/** Trust score after the change */
afterScore: number;
/** Impact on reachability */
reachabilityImpact: ReachabilityImpact;
/** Impact on exploitability */
exploitabilityImpact: ExploitabilityImpact;
/** Human-readable proof steps */
proofSteps: string[];
}
/**
* Summary statistics for the change trace.
*/
export interface ChangeTraceSummary {
/** Number of packages with changes */
changedPackages: number;
/** Number of packages added */
packagesAdded: number;
/** Number of packages removed */
packagesRemoved: number;
/** Number of symbols with changes */
changedSymbols: number;
/** Number of bytes changed */
changedBytes: number;
/** Net risk/trust delta */
riskDelta: number;
/** Overall verdict */
verdict: ChangeTraceVerdict;
}
/**
* Cryptographic commitment for determinism verification.
*/
export interface ChangeTraceCommitment {
/** SHA-256 hash of canonical JSON */
sha256: string;
/** Algorithm used */
algorithm: string;
}
/**
* Type of package change.
*/
export enum ChangeType {
Unchanged = 'unchanged',
Added = 'added',
Removed = 'removed',
Upgraded = 'upgraded',
Downgraded = 'downgraded',
Patched = 'patched',
Rebuilt = 'rebuilt'
}
/**
* Type of symbol change.
*/
export enum SymbolChangeType {
Unchanged = 'unchanged',
Added = 'added',
Removed = 'removed',
Modified = 'modified',
Patched = 'patched'
}
/**
* Impact on reachability.
*/
export enum ReachabilityImpact {
Unchanged = 'unchanged',
Introduced = 'introduced',
Eliminated = 'eliminated',
Reduced = 'reduced',
Increased = 'increased'
}
/**
* Impact on exploitability.
*/
export enum ExploitabilityImpact {
Unchanged = 'unchanged',
Introduced = 'introduced',
Eliminated = 'eliminated',
Up = 'up',
Down = 'down'
}
/**
* Overall verdict of the change trace.
*/
export enum ChangeTraceVerdict {
RiskDown = 'risk_down',
Neutral = 'neutral',
RiskUp = 'risk_up',
Inconclusive = 'inconclusive'
}

View File

@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------------
// change-trace.service.ts
// Sprint: SPRINT_20260112_200_007_FE_ui_components
// Description: Service for change-trace API operations.
// -----------------------------------------------------------------------------
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ChangeTrace } from '../models/change-trace.models';
@Injectable({
providedIn: 'root',
})
export class ChangeTraceService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/change-traces';
/**
* Get a change trace by ID.
*/
getTrace(traceId: string): Observable<ChangeTrace> {
return this.http.get<ChangeTrace>(`${this.apiUrl}/${traceId}`);
}
/**
* Build a new change trace from two scan IDs.
*/
buildTrace(
fromScanId: string,
toScanId: string,
includeByteDiff: boolean
): Observable<ChangeTrace> {
return this.http.post<ChangeTrace>(`${this.apiUrl}/build`, {
fromScanId,
toScanId,
includeByteDiff,
});
}
/**
* Load a change trace from a local JSON file.
*/
loadFromFile(file: File): Observable<ChangeTrace> {
return new Observable((observer) => {
const reader = new FileReader();
reader.onload = () => {
try {
const trace = JSON.parse(reader.result as string) as ChangeTrace;
observer.next(trace);
observer.complete();
} catch (e) {
observer.error(new Error('Invalid change trace file'));
}
};
reader.onerror = () => observer.error(reader.error);
reader.readAsText(file);
});
}
/**
* Export a change trace to a downloadable file.
*/
exportToFile(trace: ChangeTrace, filename?: string): void {
const json = JSON.stringify(trace, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || `trace-${Date.now()}.cdxchange.json`;
link.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,158 @@
# Evidence Thread Feature
A comprehensive Angular feature module for viewing and managing evidence threads - the unified explainability view that traces SBOM diffs, reachability analysis, VEX statements, and attestations for artifacts.
## Overview
The Evidence Thread feature provides:
- **Thread List View**: Browse and filter evidence threads with pagination
- **Thread Detail View**: Tabbed interface with Graph, Timeline, and Transcript views
- **Graph Visualization**: D3.js force-directed graph showing evidence nodes and their relationships
- **Timeline View**: Chronological view of evidence events
- **Transcript Generation**: Natural language explanations with optional LLM enhancement
- **Export Capabilities**: Export evidence bundles in DSSE, JSON, PDF, or Markdown formats
## Components
### Main Components
| Component | Path | Description |
|-----------|------|-------------|
| `EvidenceThreadListComponent` | `components/evidence-thread-list/` | List view with filtering and pagination |
| `EvidenceThreadViewComponent` | `components/evidence-thread-view/` | Main detail view with tabbed interface |
| `EvidenceNodeCardComponent` | `components/evidence-node-card/` | Card display for individual evidence nodes |
| `EvidenceGraphPanelComponent` | `components/evidence-graph-panel/` | D3.js graph visualization |
| `EvidenceTimelinePanelComponent` | `components/evidence-timeline-panel/` | Timeline chronological view |
| `EvidenceTranscriptPanelComponent` | `components/evidence-transcript-panel/` | Transcript generation and display |
| `EvidenceExportDialogComponent` | `components/evidence-export-dialog/` | Export configuration dialog |
### Services
| Service | Description |
|---------|-------------|
| `EvidenceThreadService` | API integration and state management |
## API Endpoints
The service integrates with the following REST endpoints:
```
GET /api/v1/evidence - List threads
GET /api/v1/evidence/{artifactDigest} - Get thread graph
GET /api/v1/evidence/{artifactDigest}/nodes - Get nodes
GET /api/v1/evidence/{artifactDigest}/links - Get links
POST /api/v1/evidence/{artifactDigest}/transcript - Generate transcript
POST /api/v1/evidence/{artifactDigest}/export - Export thread
```
## Usage
### Routes
Add to your app routes:
```typescript
{
path: 'evidence-thread',
canMatch: [requireAuthGuard],
loadChildren: () =>
import('./features/evidence-thread/evidence-thread.routes').then(
(m) => m.EVIDENCE_THREAD_ROUTES
)
}
```
### Service Usage
```typescript
import { EvidenceThreadService } from './features/evidence-thread';
@Component({...})
export class MyComponent {
private readonly evidenceService = inject(EvidenceThreadService);
loadThread(digest: string): void {
this.evidenceService.getThreadByDigest(digest).subscribe(graph => {
console.log('Loaded thread:', graph);
});
}
}
```
## Evidence Node Types
| Kind | Icon | Description |
|------|------|-------------|
| `sbom_diff` | compare_arrows | SBOM comparison between versions |
| `reachability` | route | Reachability analysis results |
| `vex` | security | VEX statement evaluation |
| `attestation` | verified_user | Cryptographic attestation |
| `policy_eval` | policy | Policy evaluation outcome |
| `runtime_observation` | visibility | Runtime behavior observation |
| `patch_verification` | check_circle | Patch verification result |
| `approval` | thumb_up | Manual approval record |
| `ai_rationale` | psychology | AI-generated rationale |
## Link Relations
| Relation | Description |
|----------|-------------|
| `supports` | Evidence supports a conclusion |
| `contradicts` | Evidence contradicts a conclusion |
| `precedes` | Temporal ordering |
| `triggers` | Causal relationship |
| `derived_from` | Source relationship |
| `references` | Reference relationship |
## Verdicts
| Verdict | Color | Description |
|---------|-------|-------------|
| `allow` | Green | Artifact is allowed |
| `warn` | Orange | Artifact has warnings |
| `block` | Red | Artifact is blocked |
| `pending` | Blue | Awaiting evaluation |
| `unknown` | Gray | Status unknown |
## Development
### Running Tests
```bash
# Run all evidence-thread tests
npm test -- --include='**/evidence-thread/**'
# Run with coverage
npm test -- --include='**/evidence-thread/**' --coverage
```
### Dependencies
- Angular 17.3+
- Angular Material 17.3+
- D3.js v7 (for graph visualization)
- RxJS 7.8+
## File Structure
```
evidence-thread/
├── components/
│ ├── evidence-thread-list/
│ ├── evidence-thread-view/
│ ├── evidence-node-card/
│ ├── evidence-graph-panel/
│ ├── evidence-timeline-panel/
│ ├── evidence-transcript-panel/
│ └── evidence-export-dialog/
├── services/
│ └── evidence-thread.service.ts
├── __tests__/
│ ├── evidence-thread.service.spec.ts
│ ├── evidence-thread-view.component.spec.ts
│ └── evidence-node-card.component.spec.ts
├── evidence-thread.routes.ts
├── index.ts
└── README.md
```

View File

@@ -0,0 +1,228 @@
// <copyright file="evidence-node-card.component.spec.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { EvidenceNodeCardComponent } from '../components/evidence-node-card/evidence-node-card.component';
import { EvidenceThreadService, EvidenceNode } from '../services/evidence-thread.service';
describe('EvidenceNodeCardComponent', () => {
let component: EvidenceNodeCardComponent;
let fixture: ComponentFixture<EvidenceNodeCardComponent>;
let mockEvidenceService: jasmine.SpyObj<EvidenceThreadService>;
const mockNode: EvidenceNode = {
id: 'node-1',
tenantId: 'tenant-1',
threadId: 'thread-1',
kind: 'sbom_diff',
refId: 'ref-1',
refDigest: 'sha256:digest123',
title: 'SBOM Comparison',
summary: 'Compared SBOMs between versions',
confidence: 0.95,
anchors: [
{ type: 'cve', id: 'CVE-2024-1234', label: 'CVE-2024-1234' }
],
content: {
addedPackages: ['pkg1', 'pkg2'],
removedPackages: [],
changedPackages: 5
},
createdAt: '2024-01-15T10:30:00Z'
};
beforeEach(async () => {
mockEvidenceService = jasmine.createSpyObj('EvidenceThreadService', [
'getNodeKindLabel',
'getNodeKindIcon'
]);
mockEvidenceService.getNodeKindLabel.and.callFake((kind: string) => {
const labels: Record<string, string> = {
sbom_diff: 'SBOM Diff',
reachability: 'Reachability',
vex: 'VEX'
};
return labels[kind] ?? kind;
});
mockEvidenceService.getNodeKindIcon.and.callFake((kind: string) => {
const icons: Record<string, string> = {
sbom_diff: 'compare_arrows',
reachability: 'route',
vex: 'security'
};
return icons[kind] ?? 'help_outline';
});
await TestBed.configureTestingModule({
imports: [
EvidenceNodeCardComponent,
NoopAnimationsModule
],
providers: [
{ provide: EvidenceThreadService, useValue: mockEvidenceService }
]
}).compileComponents();
fixture = TestBed.createComponent(EvidenceNodeCardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should compute kind label correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.kindLabel()).toBe('SBOM Diff');
});
it('should compute kind icon correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.kindIcon()).toBe('compare_arrows');
});
it('should compute confidence percent correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.confidencePercent()).toBe(95);
});
it('should return null confidence percent when undefined', () => {
const nodeWithoutConfidence = { ...mockNode, confidence: undefined };
fixture.componentRef.setInput('node', nodeWithoutConfidence);
fixture.detectChanges();
expect(component.confidencePercent()).toBeNull();
});
it('should compute confidence class for high confidence', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.confidenceClass()).toBe('confidence-high');
});
it('should compute confidence class for medium confidence', () => {
const nodeWithMediumConfidence = { ...mockNode, confidence: 0.65 };
fixture.componentRef.setInput('node', nodeWithMediumConfidence);
fixture.detectChanges();
expect(component.confidenceClass()).toBe('confidence-medium');
});
it('should compute confidence class for low confidence', () => {
const nodeWithLowConfidence = { ...mockNode, confidence: 0.35 };
fixture.componentRef.setInput('node', nodeWithLowConfidence);
fixture.detectChanges();
expect(component.confidenceClass()).toBe('confidence-low');
});
it('should detect content presence correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.hasContent()).toBe(true);
});
it('should detect empty content correctly', () => {
const nodeWithEmptyContent = { ...mockNode, content: {} };
fixture.componentRef.setInput('node', nodeWithEmptyContent);
fixture.detectChanges();
expect(component.hasContent()).toBe(false);
});
it('should compute content keys correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
const keys = component.contentKeys();
expect(keys).toContain('addedPackages');
expect(keys).toContain('removedPackages');
expect(keys).toContain('changedPackages');
});
it('should format date correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
const formatted = component.formattedDate();
expect(formatted).toContain('Jan');
expect(formatted).toContain('15');
expect(formatted).toContain('2024');
});
it('should compute anchor count correctly', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
expect(component.anchorCount()).toBe(1);
});
it('should emit select event on click when selectable', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.componentRef.setInput('selectable', true);
fixture.detectChanges();
spyOn(component.select, 'emit');
component.onSelect();
expect(component.select.emit).toHaveBeenCalledWith('node-1');
});
it('should not emit select event when not selectable', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.componentRef.setInput('selectable', false);
fixture.detectChanges();
spyOn(component.select, 'emit');
component.onSelect();
expect(component.select.emit).not.toHaveBeenCalled();
});
it('should emit close event', () => {
fixture.componentRef.setInput('node', mockNode);
fixture.detectChanges();
spyOn(component.close, 'emit');
component.onClose();
expect(component.close.emit).toHaveBeenCalled();
});
it('should format string content value', () => {
expect(component.formatContentValue('test')).toBe('test');
});
it('should format number content value', () => {
expect(component.formatContentValue(42)).toBe('42');
});
it('should format boolean content value', () => {
expect(component.formatContentValue(true)).toBe('Yes');
expect(component.formatContentValue(false)).toBe('No');
});
it('should format array content value', () => {
expect(component.formatContentValue(['a', 'b', 'c'])).toBe('[3 items]');
});
it('should format null content value', () => {
expect(component.formatContentValue(null)).toBe('-');
expect(component.formatContentValue(undefined)).toBe('-');
});
it('should detect complex values', () => {
expect(component.isComplexValue({ a: 1 })).toBe(true);
expect(component.isComplexValue(['a', 'b'])).toBe(true);
expect(component.isComplexValue('string')).toBe(false);
expect(component.isComplexValue(42)).toBe(false);
expect(component.isComplexValue(null)).toBe(false);
});
it('should return correct anchor icons', () => {
expect(component.getAnchorIcon('cve')).toBe('bug_report');
expect(component.getAnchorIcon('package')).toBe('inventory_2');
expect(component.getAnchorIcon('file')).toBe('description');
expect(component.getAnchorIcon('unknown')).toBe('link');
});
});

View File

@@ -0,0 +1,163 @@
// <copyright file="evidence-thread-view.component.spec.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of, BehaviorSubject } from 'rxjs';
import { EvidenceThreadViewComponent } from '../components/evidence-thread-view/evidence-thread-view.component';
import { EvidenceThreadService, EvidenceThreadGraph, EvidenceNode } from '../services/evidence-thread.service';
describe('EvidenceThreadViewComponent', () => {
let component: EvidenceThreadViewComponent;
let fixture: ComponentFixture<EvidenceThreadViewComponent>;
let mockEvidenceService: jasmine.SpyObj<EvidenceThreadService>;
let routeParams$: BehaviorSubject<any>;
const mockThread: EvidenceThreadGraph = {
thread: {
id: 'thread-1',
tenantId: 'tenant-1',
artifactDigest: 'sha256:abc123',
artifactName: 'test-image:latest',
status: 'active',
verdict: 'allow',
riskScore: 2.5,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
},
nodes: [
{
id: 'node-1',
tenantId: 'tenant-1',
threadId: 'thread-1',
kind: 'sbom_diff',
refId: 'ref-1',
title: 'SBOM Comparison',
anchors: [],
content: {},
createdAt: '2024-01-01T00:00:00Z'
}
],
links: []
};
beforeEach(async () => {
routeParams$ = new BehaviorSubject({ artifactDigest: 'sha256:abc123' });
mockEvidenceService = jasmine.createSpyObj('EvidenceThreadService', [
'getThreadByDigest',
'clearCurrentThread',
'getVerdictColor',
'getNodeKindLabel',
'getNodeKindIcon'
], {
currentThread: jasmine.createSpy().and.returnValue(mockThread),
loading: jasmine.createSpy().and.returnValue(false),
error: jasmine.createSpy().and.returnValue(null),
currentNodes: jasmine.createSpy().and.returnValue(mockThread.nodes),
currentLinks: jasmine.createSpy().and.returnValue([]),
nodesByKind: jasmine.createSpy().and.returnValue({ sbom_diff: mockThread.nodes })
});
mockEvidenceService.getThreadByDigest.and.returnValue(of(mockThread));
mockEvidenceService.getVerdictColor.and.returnValue('success');
await TestBed.configureTestingModule({
imports: [
EvidenceThreadViewComponent,
RouterTestingModule,
NoopAnimationsModule
],
providers: [
{ provide: EvidenceThreadService, useValue: mockEvidenceService },
{
provide: ActivatedRoute,
useValue: {
params: routeParams$.asObservable()
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(EvidenceThreadViewComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load thread on init', () => {
fixture.detectChanges();
expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalledWith('sha256:abc123');
});
it('should set artifact digest from route params', () => {
fixture.detectChanges();
expect(component.artifactDigest()).toBe('sha256:abc123');
});
it('should clear thread on destroy', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(mockEvidenceService.clearCurrentThread).toHaveBeenCalled();
});
it('should refresh thread when onRefresh is called', () => {
fixture.detectChanges();
mockEvidenceService.getThreadByDigest.calls.reset();
component.onRefresh();
expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalled();
});
it('should update selected tab index', () => {
fixture.detectChanges();
expect(component.selectedTabIndex()).toBe(0);
component.onTabChange(1);
expect(component.selectedTabIndex()).toBe(1);
});
it('should update selected node ID', () => {
fixture.detectChanges();
expect(component.selectedNodeId()).toBeNull();
component.onNodeSelect('node-1');
expect(component.selectedNodeId()).toBe('node-1');
});
it('should return correct verdict label', () => {
expect(component.getVerdictLabel('allow')).toBe('Allow');
expect(component.getVerdictLabel('block')).toBe('Block');
expect(component.getVerdictLabel(undefined)).toBe('Unknown');
});
it('should return correct verdict icon', () => {
expect(component.getVerdictIcon('allow')).toBe('check_circle');
expect(component.getVerdictIcon('warn')).toBe('warning');
expect(component.getVerdictIcon('block')).toBe('block');
expect(component.getVerdictIcon('pending')).toBe('schedule');
expect(component.getVerdictIcon('unknown')).toBe('help_outline');
});
it('should compute short digest correctly', () => {
fixture.detectChanges();
const shortDigest = component.shortDigest();
expect(shortDigest).toBe('sha256:abc123...');
});
it('should compute node count correctly', () => {
fixture.detectChanges();
expect(component.nodeCount()).toBe(1);
});
it('should compute link count correctly', () => {
fixture.detectChanges();
expect(component.linkCount()).toBe(0);
});
});

View File

@@ -0,0 +1,310 @@
// <copyright file="evidence-thread.service.spec.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import {
EvidenceThreadService,
EvidenceThread,
EvidenceThreadGraph,
EvidenceNode,
EvidenceTranscript
} from '../services/evidence-thread.service';
describe('EvidenceThreadService', () => {
let service: EvidenceThreadService;
let httpMock: HttpTestingController;
const mockThread: EvidenceThread = {
id: 'thread-1',
tenantId: 'tenant-1',
artifactDigest: 'sha256:abc123',
artifactName: 'test-image',
status: 'active',
verdict: 'allow',
riskScore: 2.5,
reachabilityMode: 'unreachable',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
};
const mockNode: EvidenceNode = {
id: 'node-1',
tenantId: 'tenant-1',
threadId: 'thread-1',
kind: 'sbom_diff',
refId: 'ref-1',
title: 'SBOM Comparison',
summary: 'Test summary',
confidence: 0.95,
anchors: [],
content: {},
createdAt: '2024-01-01T00:00:00Z'
};
const mockGraph: EvidenceThreadGraph = {
thread: mockThread,
nodes: [mockNode],
links: []
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [EvidenceThreadService]
});
service = TestBed.inject(EvidenceThreadService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getThreads', () => {
it('should fetch threads with default parameters', () => {
const mockResponse = {
items: [mockThread],
total: 1,
page: 1,
pageSize: 20
};
service.getThreads().subscribe(response => {
expect(response.items.length).toBe(1);
expect(response.items[0].id).toBe('thread-1');
});
const req = httpMock.expectOne('/api/v1/evidence');
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should include filter parameters in request', () => {
const mockResponse = {
items: [],
total: 0,
page: 1,
pageSize: 20
};
service.getThreads({
status: 'active',
verdict: 'allow',
page: 2,
pageSize: 50
}).subscribe();
const req = httpMock.expectOne(request => {
return request.url === '/api/v1/evidence' &&
request.params.get('status') === 'active' &&
request.params.get('verdict') === 'allow' &&
request.params.get('page') === '2' &&
request.params.get('pageSize') === '50';
});
req.flush(mockResponse);
});
it('should update loading state', () => {
const mockResponse = { items: [], total: 0, page: 1, pageSize: 20 };
expect(service.loading()).toBe(false);
service.getThreads().subscribe();
// Loading should be true during request
expect(service.loading()).toBe(true);
httpMock.expectOne('/api/v1/evidence').flush(mockResponse);
// Loading should be false after response
expect(service.loading()).toBe(false);
});
it('should handle errors gracefully', () => {
service.getThreads().subscribe(response => {
expect(response.items.length).toBe(0);
});
const req = httpMock.expectOne('/api/v1/evidence');
req.error(new ErrorEvent('Network error'));
expect(service.error()).toBeTruthy();
expect(service.loading()).toBe(false);
});
});
describe('getThreadByDigest', () => {
it('should fetch thread graph by digest', () => {
const digest = 'sha256:abc123';
service.getThreadByDigest(digest).subscribe(graph => {
expect(graph).toBeTruthy();
expect(graph?.thread.id).toBe('thread-1');
expect(graph?.nodes.length).toBe(1);
});
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`);
expect(req.request.method).toBe('GET');
req.flush(mockGraph);
});
it('should update current thread state', () => {
const digest = 'sha256:abc123';
service.getThreadByDigest(digest).subscribe();
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph);
expect(service.currentThread()).toEqual(mockGraph);
expect(service.currentNodes().length).toBe(1);
});
});
describe('generateTranscript', () => {
it('should generate transcript with options', () => {
const digest = 'sha256:abc123';
const mockTranscript: EvidenceTranscript = {
id: 'transcript-1',
tenantId: 'tenant-1',
threadId: 'thread-1',
transcriptType: 'summary',
templateVersion: '1.0',
content: 'Test transcript content',
anchors: [],
generatedAt: '2024-01-01T00:00:00Z'
};
service.generateTranscript(digest, {
transcriptType: 'summary',
useLlm: true
}).subscribe(result => {
expect(result).toBeTruthy();
expect(result?.content).toBe('Test transcript content');
});
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/transcript`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
transcriptType: 'summary',
useLlm: true
});
req.flush(mockTranscript);
});
});
describe('exportThread', () => {
it('should export thread with signing options', () => {
const digest = 'sha256:abc123';
const mockExport = {
id: 'export-1',
tenantId: 'tenant-1',
threadId: 'thread-1',
exportFormat: 'dsse',
contentHash: 'sha256:export123',
storagePath: '/exports/export-1.dsse',
createdAt: '2024-01-01T00:00:00Z'
};
service.exportThread(digest, {
format: 'dsse',
sign: true,
keyRef: 'my-key'
}).subscribe(result => {
expect(result).toBeTruthy();
expect(result?.exportFormat).toBe('dsse');
});
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/export`);
expect(req.request.method).toBe('POST');
expect(req.request.body.sign).toBe(true);
req.flush(mockExport);
});
});
describe('helper methods', () => {
it('should return correct node kind labels', () => {
expect(service.getNodeKindLabel('sbom_diff')).toBe('SBOM Diff');
expect(service.getNodeKindLabel('reachability')).toBe('Reachability');
expect(service.getNodeKindLabel('vex')).toBe('VEX');
expect(service.getNodeKindLabel('attestation')).toBe('Attestation');
});
it('should return correct node kind icons', () => {
expect(service.getNodeKindIcon('sbom_diff')).toBe('compare_arrows');
expect(service.getNodeKindIcon('reachability')).toBe('route');
expect(service.getNodeKindIcon('vex')).toBe('security');
});
it('should return correct verdict colors', () => {
expect(service.getVerdictColor('allow')).toBe('success');
expect(service.getVerdictColor('warn')).toBe('warning');
expect(service.getVerdictColor('block')).toBe('error');
expect(service.getVerdictColor('pending')).toBe('info');
expect(service.getVerdictColor('unknown')).toBe('neutral');
expect(service.getVerdictColor(undefined)).toBe('neutral');
});
it('should return correct link relation labels', () => {
expect(service.getLinkRelationLabel('supports')).toBe('Supports');
expect(service.getLinkRelationLabel('contradicts')).toBe('Contradicts');
expect(service.getLinkRelationLabel('derived_from')).toBe('Derived From');
});
});
describe('computed signals', () => {
it('should compute nodesByKind correctly', () => {
const digest = 'sha256:abc123';
const graphWithMultipleNodes: EvidenceThreadGraph = {
thread: mockThread,
nodes: [
{ ...mockNode, id: 'node-1', kind: 'sbom_diff' },
{ ...mockNode, id: 'node-2', kind: 'vex' },
{ ...mockNode, id: 'node-3', kind: 'sbom_diff' }
],
links: []
};
service.getThreadByDigest(digest).subscribe();
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(graphWithMultipleNodes);
const byKind = service.nodesByKind();
expect(byKind['sbom_diff']?.length).toBe(2);
expect(byKind['vex']?.length).toBe(1);
});
});
describe('clearCurrentThread', () => {
it('should clear current thread state', () => {
const digest = 'sha256:abc123';
service.getThreadByDigest(digest).subscribe();
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph);
expect(service.currentThread()).toBeTruthy();
service.clearCurrentThread();
expect(service.currentThread()).toBeNull();
});
});
describe('clearError', () => {
it('should clear error state', () => {
service.getThreads().subscribe();
httpMock.expectOne('/api/v1/evidence').error(new ErrorEvent('Error'));
expect(service.error()).toBeTruthy();
service.clearError();
expect(service.error()).toBeNull();
});
});
});

View File

@@ -0,0 +1,86 @@
<!-- Evidence Export Dialog Component -->
<h2 mat-dialog-title>
<mat-icon>download</mat-icon>
Export Evidence Thread
</h2>
<mat-dialog-content>
<!-- Thread Info -->
<div class="thread-info">
<div class="info-row">
<span class="label">Artifact:</span>
<code class="digest">{{ shortDigest }}</code>
</div>
@if (thread.artifactName) {
<div class="info-row">
<span class="label">Name:</span>
<span class="value">{{ thread.artifactName }}</span>
</div>
}
</div>
<!-- Export Format Selection -->
<div class="format-selection">
<h3>Export Format</h3>
<div class="format-options">
@for (format of exportFormats; track format.value) {
<div
class="format-option"
[class.selected]="exportFormat === format.value"
(click)="exportFormat = format.value">
<mat-icon>{{ format.icon }}</mat-icon>
<div class="format-details">
<span class="format-label">{{ format.label }}</span>
<span class="format-description">{{ format.description }}</span>
</div>
<mat-icon class="check-icon" *ngIf="exportFormat === format.value">check_circle</mat-icon>
</div>
}
</div>
</div>
<!-- Signing Options (for DSSE and JSON) -->
@if (canSign) {
<div class="signing-options">
<h3>Signing Options</h3>
<mat-checkbox [(ngModel)]="signExport">
Sign the export with cryptographic signature
</mat-checkbox>
@if (signExport) {
<mat-form-field appearance="outline" class="key-ref-field">
<mat-label>Key Reference (optional)</mat-label>
<input matInput [(ngModel)]="keyRef" placeholder="e.g., stella-authority-prod">
<mat-hint>Leave empty to use the default signing key</mat-hint>
</mat-form-field>
}
</div>
}
<!-- Error Display -->
@if (error()) {
<div class="error-banner">
<mat-icon>error_outline</mat-icon>
<span>{{ error() }}</span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onCancel()" [disabled]="exporting()">
Cancel
</button>
<button
mat-raised-button
color="primary"
(click)="onExport()"
[disabled]="exporting()">
@if (exporting()) {
<mat-spinner diameter="20"></mat-spinner>
Exporting...
} @else {
<mat-icon>{{ selectedFormatDetails?.icon }}</mat-icon>
Export as {{ selectedFormatDetails?.label }}
}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,172 @@
// Evidence Export Dialog Component Styles
h2[mat-dialog-title] {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
color: var(--mat-sys-primary);
}
}
mat-dialog-content {
min-width: 400px;
max-width: 500px;
}
// Thread Info
.thread-info {
background: var(--mat-sys-surface-variant);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
.info-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
min-width: 60px;
}
.digest {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface);
}
.value {
font-size: 0.875rem;
color: var(--mat-sys-on-surface);
}
}
}
// Format Selection
.format-selection {
margin-bottom: 24px;
h3 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
}
.format-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.format-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--mat-sys-surface-variant);
border-color: var(--mat-sys-outline);
}
&.selected {
background: var(--mat-sys-primary-container);
border-color: var(--mat-sys-primary);
mat-icon:first-child {
color: var(--mat-sys-primary);
}
}
mat-icon:first-child {
font-size: 24px;
width: 24px;
height: 24px;
color: var(--mat-sys-on-surface-variant);
}
.format-details {
flex: 1;
min-width: 0;
.format-label {
display: block;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.format-description {
display: block;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
margin-top: 2px;
}
}
.check-icon {
color: var(--mat-sys-primary);
}
}
}
// Signing Options
.signing-options {
margin-bottom: 24px;
h3 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
}
mat-checkbox {
display: block;
margin-bottom: 16px;
}
.key-ref-field {
width: 100%;
}
}
// Error Banner
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
border-radius: 8px;
margin-top: 16px;
mat-icon {
flex-shrink: 0;
}
}
// Dialog Actions
mat-dialog-actions {
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}

View File

@@ -0,0 +1,141 @@
// <copyright file="evidence-export-dialog.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { EvidenceThread, EvidenceThreadService, ExportFormat } from '../../services/evidence-thread.service';
export interface ExportDialogData {
artifactDigest: string;
thread: EvidenceThread;
}
export interface ExportDialogResult {
success: boolean;
exportId?: string;
downloadUrl?: string;
}
@Component({
selector: 'stella-evidence-export-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatSelectModule,
MatCheckboxModule,
MatProgressSpinnerModule,
MatInputModule,
MatFormFieldModule
],
templateUrl: './evidence-export-dialog.component.html',
styleUrls: ['./evidence-export-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceExportDialogComponent {
private readonly dialogRef = inject(MatDialogRef<EvidenceExportDialogComponent>);
private readonly data = inject<ExportDialogData>(MAT_DIALOG_DATA);
private readonly evidenceService = inject(EvidenceThreadService);
readonly exporting = signal<boolean>(false);
readonly error = signal<string | null>(null);
// Form state
exportFormat: ExportFormat = 'dsse';
signExport = true;
keyRef = '';
readonly exportFormats: { value: ExportFormat; label: string; description: string; icon: string }[] = [
{
value: 'dsse',
label: 'DSSE Bundle',
description: 'Dead Simple Signing Envelope - cryptographically signed evidence bundle',
icon: 'verified'
},
{
value: 'json',
label: 'JSON',
description: 'Raw JSON export of all evidence data',
icon: 'data_object'
},
{
value: 'pdf',
label: 'PDF Report',
description: 'Human-readable PDF document for review and archival',
icon: 'picture_as_pdf'
},
{
value: 'markdown',
label: 'Markdown',
description: 'Markdown document suitable for documentation systems',
icon: 'description'
}
];
get artifactDigest(): string {
return this.data.artifactDigest;
}
get thread(): EvidenceThread {
return this.data.thread;
}
get shortDigest(): string {
const digest = this.artifactDigest;
return digest.length > 19 ? `${digest.substring(0, 19)}...` : digest;
}
get selectedFormatDetails(): typeof this.exportFormats[0] | undefined {
return this.exportFormats.find(f => f.value === this.exportFormat);
}
get canSign(): boolean {
return this.exportFormat === 'dsse' || this.exportFormat === 'json';
}
onExport(): void {
this.exporting.set(true);
this.error.set(null);
this.evidenceService.exportThread(this.artifactDigest, {
format: this.exportFormat,
sign: this.canSign && this.signExport,
keyRef: this.keyRef || undefined
}).subscribe({
next: (result) => {
this.exporting.set(false);
if (result) {
this.dialogRef.close({
success: true,
exportId: result.id,
downloadUrl: result.downloadUrl
} as ExportDialogResult);
} else {
this.error.set('Export failed - no result returned');
}
},
error: (err) => {
this.exporting.set(false);
this.error.set(err.message ?? 'Failed to export evidence thread');
}
});
}
onCancel(): void {
this.dialogRef.close({ success: false } as ExportDialogResult);
}
}

View File

@@ -0,0 +1,71 @@
<!-- Evidence Graph Panel Component -->
<div class="evidence-graph-panel">
<!-- Toolbar -->
<div class="graph-toolbar">
<div class="toolbar-group">
<button mat-icon-button (click)="onZoomIn()" matTooltip="Zoom in">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button (click)="onZoomOut()" matTooltip="Zoom out">
<mat-icon>zoom_out</mat-icon>
</button>
<button mat-icon-button (click)="onResetZoom()" matTooltip="Reset zoom">
<mat-icon>center_focus_strong</mat-icon>
</button>
<button mat-icon-button (click)="onFitToView()" matTooltip="Fit to view">
<mat-icon>fit_screen</mat-icon>
</button>
</div>
<div class="toolbar-group">
<button mat-icon-button (click)="onToggleLabels()" [matTooltip]="showLabels ? 'Hide labels' : 'Show labels'">
<mat-icon>{{ showLabels ? 'label_off' : 'label' }}</mat-icon>
</button>
</div>
<div class="zoom-indicator">
{{ (zoomLevel * 100) | number:'1.0-0' }}%
</div>
</div>
<!-- Graph Container -->
<div #graphContainer class="graph-container">
@if (nodes().length === 0) {
<div class="empty-state">
<mat-icon>account_tree</mat-icon>
<p>No evidence nodes to display</p>
</div>
}
</div>
<!-- Legend -->
<div class="graph-legend">
<span class="legend-title">Node Types:</span>
<div class="legend-items">
<div class="legend-item">
<span class="legend-dot" style="background: #1565c0"></span>
<span>SBOM Diff</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: #ef6c00"></span>
<span>Reachability</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: #2e7d32"></span>
<span>VEX</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: #7b1fa2"></span>
<span>Attestation</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: #00695c"></span>
<span>Policy</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: #ff8f00"></span>
<span>AI Rationale</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,148 @@
// Evidence Graph Panel Component Styles
.evidence-graph-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
background: var(--mat-sys-surface);
border-radius: 8px;
overflow: hidden;
}
// Toolbar
.graph-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background: var(--mat-sys-surface-variant);
border-bottom: 1px solid var(--mat-sys-outline-variant);
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.zoom-indicator {
margin-left: auto;
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface);
padding: 4px 12px;
border-radius: 12px;
min-width: 60px;
text-align: center;
}
}
// Graph Container
.graph-container {
flex: 1;
position: relative;
overflow: hidden;
::ng-deep svg {
display: block;
.node {
transition: stroke 0.2s ease, stroke-width 0.2s ease;
&:hover {
filter: brightness(1.1);
}
}
.link {
pointer-events: none;
}
.label {
pointer-events: none;
user-select: none;
font-family: var(--mat-sys-body-medium-font);
}
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
opacity: 0.3;
}
p {
margin: 0;
}
}
}
// Legend
.graph-legend {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--mat-sys-surface-variant);
border-top: 1px solid var(--mat-sys-outline-variant);
flex-wrap: wrap;
.legend-title {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
}
.legend-items {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--mat-sys-on-surface);
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.graph-legend {
.legend-items {
gap: 8px;
}
.legend-item {
font-size: 0.625rem;
.legend-dot {
width: 8px;
height: 8px;
}
}
}
}

View File

@@ -0,0 +1,397 @@
// <copyright file="evidence-graph-panel.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import {
Component,
ElementRef,
ViewChild,
AfterViewInit,
OnDestroy,
OnChanges,
SimpleChanges,
input,
output,
inject,
ChangeDetectionStrategy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSliderModule } from '@angular/material/slider';
import { FormsModule } from '@angular/forms';
import * as d3 from 'd3';
import { EvidenceNode, EvidenceLink, EvidenceThreadService } from '../../services/evidence-thread.service';
interface GraphNode extends d3.SimulationNodeDatum {
id: string;
kind: string;
title: string;
summary?: string;
confidence?: number;
}
interface GraphLink extends d3.SimulationLinkDatum<GraphNode> {
id: string;
relation: string;
weight?: number;
}
@Component({
selector: 'stella-evidence-graph-panel',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatSliderModule,
FormsModule
],
templateUrl: './evidence-graph-panel.component.html',
styleUrls: ['./evidence-graph-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceGraphPanelComponent implements AfterViewInit, OnDestroy, OnChanges {
private readonly evidenceService = inject(EvidenceThreadService);
@ViewChild('graphContainer', { static: true }) graphContainer!: ElementRef<HTMLDivElement>;
// Inputs
nodes = input<EvidenceNode[]>([]);
links = input<EvidenceLink[]>([]);
selectedNodeId = input<string | null>(null);
// Outputs
nodeSelect = output<string>();
// Graph state
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private simulation: d3.Simulation<GraphNode, GraphLink> | null = null;
private zoom: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null;
private resizeObserver: ResizeObserver | null = null;
// View controls
zoomLevel = 1;
showLabels = true;
// Color mapping for node kinds
private readonly kindColors: Record<string, string> = {
sbom_diff: '#1565c0',
reachability: '#ef6c00',
vex: '#2e7d32',
attestation: '#7b1fa2',
policy_eval: '#00695c',
runtime_observation: '#c2185b',
patch_verification: '#3949ab',
approval: '#558b2f',
ai_rationale: '#ff8f00'
};
ngAfterViewInit(): void {
this.initGraph();
this.setupResizeObserver();
}
ngOnChanges(changes: SimpleChanges): void {
if ((changes['nodes'] || changes['links']) && this.svg) {
this.updateGraph();
}
if (changes['selectedNodeId'] && this.svg) {
this.updateSelection();
}
}
ngOnDestroy(): void {
this.simulation?.stop();
this.resizeObserver?.disconnect();
}
private initGraph(): void {
const container = this.graphContainer.nativeElement;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
// Create SVG
this.svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`);
// Add zoom behavior
this.zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
this.zoomLevel = event.transform.k;
mainGroup.attr('transform', event.transform.toString());
});
this.svg.call(this.zoom);
// Add main group for transformations
const mainGroup = this.svg.append('g').attr('class', 'main-group');
// Add arrow marker for links
this.svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 20)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.append('path')
.attr('d', 'M 0,-5 L 10,0 L 0,5')
.attr('fill', '#999');
// Create groups for links and nodes
mainGroup.append('g').attr('class', 'links');
mainGroup.append('g').attr('class', 'nodes');
mainGroup.append('g').attr('class', 'labels');
// Initialize force simulation
this.simulation = d3.forceSimulation<GraphNode, GraphLink>()
.force('link', d3.forceLink<GraphNode, GraphLink>().id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(40));
this.updateGraph();
}
private updateGraph(): void {
if (!this.svg || !this.simulation) return;
const nodesData = this.nodes();
const linksData = this.links();
// Transform data for D3
const graphNodes: GraphNode[] = nodesData.map(n => ({
id: n.id,
kind: n.kind,
title: n.title ?? this.evidenceService.getNodeKindLabel(n.kind),
summary: n.summary,
confidence: n.confidence
}));
const graphLinks: GraphLink[] = linksData.map(l => ({
id: l.id,
source: l.srcNodeId,
target: l.dstNodeId,
relation: l.relation,
weight: l.weight
}));
const mainGroup = this.svg.select<SVGGElement>('.main-group');
// Update links
const linkGroup = mainGroup.select<SVGGElement>('.links');
const linkSelection = linkGroup.selectAll<SVGLineElement, GraphLink>('line')
.data(graphLinks, d => d.id);
linkSelection.exit().remove();
const linkEnter = linkSelection.enter()
.append('line')
.attr('class', 'link')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', d => (d.weight ?? 1) * 2)
.attr('marker-end', 'url(#arrowhead)');
const allLinks = linkEnter.merge(linkSelection);
// Update nodes
const nodeGroup = mainGroup.select<SVGGElement>('.nodes');
const nodeSelection = nodeGroup.selectAll<SVGCircleElement, GraphNode>('circle')
.data(graphNodes, d => d.id);
nodeSelection.exit().remove();
const nodeEnter = nodeSelection.enter()
.append('circle')
.attr('class', 'node')
.attr('r', 16)
.attr('fill', d => this.kindColors[d.kind] ?? '#666')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
this.nodeSelect.emit(d.id);
})
.call(this.dragBehavior());
const allNodes = nodeEnter.merge(nodeSelection);
// Add tooltips
allNodes
.append('title')
.text(d => `${d.title}\n${d.summary ?? ''}`);
// Update labels
const labelGroup = mainGroup.select<SVGGElement>('.labels');
const labelSelection = labelGroup.selectAll<SVGTextElement, GraphNode>('text')
.data(graphNodes, d => d.id);
labelSelection.exit().remove();
const labelEnter = labelSelection.enter()
.append('text')
.attr('class', 'label')
.attr('text-anchor', 'middle')
.attr('dy', 30)
.attr('font-size', 10)
.attr('fill', '#333')
.text(d => this.truncateLabel(d.title, 15));
const allLabels = labelEnter.merge(labelSelection);
allLabels.style('display', this.showLabels ? 'block' : 'none');
// Update simulation
this.simulation
.nodes(graphNodes)
.on('tick', () => {
allLinks
.attr('x1', d => (d.source as GraphNode).x ?? 0)
.attr('y1', d => (d.source as GraphNode).y ?? 0)
.attr('x2', d => (d.target as GraphNode).x ?? 0)
.attr('y2', d => (d.target as GraphNode).y ?? 0);
allNodes
.attr('cx', d => d.x ?? 0)
.attr('cy', d => d.y ?? 0);
allLabels
.attr('x', d => d.x ?? 0)
.attr('y', d => d.y ?? 0);
});
(this.simulation.force('link') as d3.ForceLink<GraphNode, GraphLink>)
.links(graphLinks);
this.simulation.alpha(1).restart();
this.updateSelection();
}
private updateSelection(): void {
if (!this.svg) return;
const selectedId = this.selectedNodeId();
this.svg.selectAll<SVGCircleElement, GraphNode>('.node')
.attr('stroke', d => d.id === selectedId ? '#1976d2' : '#fff')
.attr('stroke-width', d => d.id === selectedId ? 4 : 2);
}
private dragBehavior(): d3.DragBehavior<SVGCircleElement, GraphNode, GraphNode | d3.SubjectPosition> {
return d3.drag<SVGCircleElement, GraphNode>()
.on('start', (event, d) => {
if (!event.active) this.simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) this.simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
});
}
private setupResizeObserver(): void {
this.resizeObserver = new ResizeObserver(() => {
this.handleResize();
});
this.resizeObserver.observe(this.graphContainer.nativeElement);
}
private handleResize(): void {
if (!this.svg || !this.simulation) return;
const container = this.graphContainer.nativeElement;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
this.svg.attr('viewBox', `0 0 ${width} ${height}`);
(this.simulation.force('center') as d3.ForceCenter<GraphNode>)
.x(width / 2)
.y(height / 2);
this.simulation.alpha(0.3).restart();
}
private truncateLabel(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
// View control methods
onZoomIn(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.3);
}
onZoomOut(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.7);
}
onResetZoom(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.transform, d3.zoomIdentity);
}
onToggleLabels(): void {
this.showLabels = !this.showLabels;
if (this.svg) {
this.svg.selectAll('.label')
.style('display', this.showLabels ? 'block' : 'none');
}
}
onFitToView(): void {
if (!this.svg || !this.zoom) return;
const container = this.graphContainer.nativeElement;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
// Get bounds of all nodes
const nodeElements = this.svg.selectAll<SVGCircleElement, GraphNode>('.node');
if (nodeElements.empty()) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nodeElements.each((d) => {
if (d.x !== undefined) {
minX = Math.min(minX, d.x);
maxX = Math.max(maxX, d.x);
}
if (d.y !== undefined) {
minY = Math.min(minY, d.y);
maxY = Math.max(maxY, d.y);
}
});
if (!isFinite(minX)) return;
const padding = 50;
const graphWidth = maxX - minX + padding * 2;
const graphHeight = maxY - minY + padding * 2;
const scale = Math.min(width / graphWidth, height / graphHeight, 2);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-centerX, -centerY);
this.svg.transition().duration(500).call(this.zoom.transform, transform);
}
}

View File

@@ -0,0 +1,111 @@
<!-- Evidence Node Card Component -->
<mat-card class="evidence-node-card" [class.compact]="compact()" [class.expanded]="expanded()" [class.selectable]="selectable()">
<!-- Card Header -->
<mat-card-header (click)="onSelect()">
<mat-icon mat-card-avatar class="node-icon" [class]="'kind-' + node().kind">
{{ kindIcon() }}
</mat-icon>
<mat-card-title>
@if (node().title) {
{{ node().title }}
} @else {
{{ kindLabel() }}
}
</mat-card-title>
<mat-card-subtitle>
<span class="kind-badge">{{ kindLabel() }}</span>
<span class="date">{{ formattedDate() }}</span>
</mat-card-subtitle>
@if (expanded()) {
<button mat-icon-button class="close-btn" (click)="onClose(); $event.stopPropagation()" matTooltip="Close">
<mat-icon>close</mat-icon>
</button>
}
</mat-card-header>
<!-- Card Content -->
<mat-card-content>
<!-- Summary -->
@if (node().summary) {
<p class="node-summary">{{ node().summary }}</p>
}
<!-- Metadata chips -->
<div class="node-metadata">
@if (confidencePercent() !== null) {
<mat-chip [ngClass]="confidenceClass()">
<mat-icon matChipAvatar>speed</mat-icon>
{{ confidencePercent() }}% confidence
</mat-chip>
}
@if (anchorCount() > 0) {
<mat-chip>
<mat-icon matChipAvatar>link</mat-icon>
{{ anchorCount() }} anchors
</mat-chip>
}
@if (node().refDigest) {
<mat-chip matTooltip="{{ node().refDigest }}">
<mat-icon matChipAvatar>fingerprint</mat-icon>
{{ node().refDigest | slice:0:12 }}...
</mat-chip>
}
</div>
<!-- Expanded Content -->
@if (expanded() && hasContent()) {
<mat-divider></mat-divider>
<div class="node-content">
<h4>Content</h4>
@for (key of contentKeys(); track key) {
<div class="content-item">
<span class="content-key">{{ key }}:</span>
@if (isComplexValue(node().content[key])) {
<pre class="content-value complex">{{ formatContentValue(node().content[key]) }}</pre>
} @else {
<span class="content-value">{{ formatContentValue(node().content[key]) }}</span>
}
</div>
}
</div>
}
<!-- Anchors (expanded view) -->
@if (expanded() && anchorCount() > 0) {
<mat-divider></mat-divider>
<div class="node-anchors">
<h4>Anchors</h4>
@for (anchor of node().anchors; track anchor.id) {
<div class="anchor-item">
<mat-icon>{{ getAnchorIcon(anchor.type) }}</mat-icon>
<div class="anchor-info">
<span class="anchor-type">{{ anchor.type }}</span>
@if (anchor.label) {
<span class="anchor-label">{{ anchor.label }}</span>
}
<code class="anchor-id">{{ anchor.id }}</code>
</div>
</div>
}
</div>
}
</mat-card-content>
<!-- Card Actions (compact mode) -->
@if (!compact() && !expanded()) {
<mat-card-actions align="end">
<button mat-button (click)="select.emit(node().id)">
<mat-icon>open_in_full</mat-icon>
Details
</button>
</mat-card-actions>
}
</mat-card>

View File

@@ -0,0 +1,260 @@
// Evidence Node Card Component Styles
.evidence-node-card {
margin-bottom: 12px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
&.selectable {
cursor: pointer;
&:hover {
box-shadow: var(--mat-sys-level3);
transform: translateY(-1px);
}
}
&.compact {
mat-card-content {
padding: 8px 16px;
}
.node-summary {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
&.expanded {
box-shadow: var(--mat-sys-level2);
}
}
// Card Header
mat-card-header {
position: relative;
cursor: inherit;
.node-icon {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
&.kind-sbom_diff {
background: #e3f2fd;
color: #1565c0;
}
&.kind-reachability {
background: #fff3e0;
color: #ef6c00;
}
&.kind-vex {
background: #e8f5e9;
color: #2e7d32;
}
&.kind-attestation {
background: #f3e5f5;
color: #7b1fa2;
}
&.kind-policy_eval {
background: #e0f2f1;
color: #00695c;
}
&.kind-runtime_observation {
background: #fce4ec;
color: #c2185b;
}
&.kind-patch_verification {
background: #e8eaf6;
color: #3949ab;
}
&.kind-approval {
background: #f1f8e9;
color: #558b2f;
}
&.kind-ai_rationale {
background: #fff8e1;
color: #ff8f00;
}
}
.close-btn {
position: absolute;
top: 8px;
right: 8px;
}
}
mat-card-subtitle {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.kind-badge {
font-size: 0.75rem;
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-on-surface-variant);
padding: 2px 8px;
border-radius: 4px;
}
.date {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
}
// Card Content
mat-card-content {
padding-top: 8px;
}
.node-summary {
color: var(--mat-sys-on-surface);
line-height: 1.5;
margin: 0 0 12px 0;
}
.node-metadata {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
mat-chip {
font-size: 0.75rem;
}
}
// Confidence styling
.confidence-high {
--mat-chip-elevated-container-color: #e8f5e9;
--mat-chip-label-text-color: #2e7d32;
}
.confidence-medium {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #ef6c00;
}
.confidence-low {
--mat-chip-elevated-container-color: #ffebee;
--mat-chip-label-text-color: #c62828;
}
// Content section
.node-content {
margin-top: 16px;
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
}
.content-item {
margin-bottom: 8px;
.content-key {
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
margin-right: 8px;
}
.content-value {
color: var(--mat-sys-on-surface);
&.complex {
display: block;
background: var(--mat-sys-surface-variant);
padding: 12px;
border-radius: 4px;
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
overflow-x: auto;
margin-top: 4px;
white-space: pre-wrap;
word-break: break-word;
}
}
}
}
// Anchors section
.node-anchors {
margin-top: 16px;
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
}
.anchor-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
background: var(--mat-sys-surface-variant);
border-radius: 4px;
margin-bottom: 8px;
mat-icon {
color: var(--mat-sys-on-surface-variant);
font-size: 18px;
width: 18px;
height: 18px;
margin-top: 2px;
}
.anchor-info {
flex: 1;
min-width: 0;
.anchor-type {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
text-transform: uppercase;
display: block;
}
.anchor-label {
font-size: 0.875rem;
color: var(--mat-sys-on-surface);
display: block;
margin-top: 2px;
}
.anchor-id {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
display: block;
margin-top: 2px;
word-break: break-all;
}
}
}
}
mat-divider {
margin: 16px 0;
}

View File

@@ -0,0 +1,127 @@
// <copyright file="evidence-node-card.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, input, output, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
import { EvidenceNode, EvidenceThreadService } from '../../services/evidence-thread.service';
@Component({
selector: 'stella-evidence-node-card',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatExpansionModule,
MatTooltipModule,
MatDividerModule
],
templateUrl: './evidence-node-card.component.html',
styleUrls: ['./evidence-node-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceNodeCardComponent {
private readonly evidenceService = inject(EvidenceThreadService);
// Inputs
node = input.required<EvidenceNode>();
expanded = input<boolean>(false);
selectable = input<boolean>(true);
compact = input<boolean>(false);
// Outputs
select = output<string>();
close = output<void>();
// Computed properties
readonly kindLabel = computed(() => this.evidenceService.getNodeKindLabel(this.node().kind));
readonly kindIcon = computed(() => this.evidenceService.getNodeKindIcon(this.node().kind));
readonly confidencePercent = computed(() => {
const confidence = this.node().confidence;
if (confidence === undefined || confidence === null) return null;
return Math.round(confidence * 100);
});
readonly confidenceClass = computed(() => {
const percent = this.confidencePercent();
if (percent === null) return '';
if (percent >= 80) return 'confidence-high';
if (percent >= 50) return 'confidence-medium';
return 'confidence-low';
});
readonly hasContent = computed(() => {
const content = this.node().content;
return content && Object.keys(content).length > 0;
});
readonly contentKeys = computed(() => {
const content = this.node().content;
if (!content) return [];
return Object.keys(content);
});
readonly formattedDate = computed(() => {
const date = new Date(this.node().createdAt);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
});
readonly anchorCount = computed(() => this.node().anchors?.length ?? 0);
onSelect(): void {
if (this.selectable()) {
this.select.emit(this.node().id);
}
}
onClose(): void {
this.close.emit();
}
formatContentValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString();
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (Array.isArray(value)) return `[${value.length} items]`;
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}
isComplexValue(value: unknown): boolean {
return typeof value === 'object' && value !== null;
}
getAnchorIcon(type: string): string {
const icons: Record<string, string> = {
cve: 'bug_report',
package: 'inventory_2',
file: 'description',
function: 'functions',
line: 'format_list_numbered',
advisory: 'warning',
attestation: 'verified_user',
vex: 'security',
sbom: 'list_alt'
};
return icons[type.toLowerCase()] ?? 'link';
}
}

View File

@@ -0,0 +1,174 @@
<!-- Evidence Thread List Component -->
<div class="evidence-thread-list">
<!-- Header -->
<header class="list-header">
<div class="header-left">
<h1>Evidence Threads</h1>
<p class="subtitle">View and manage evidence chains for your artifacts</p>
</div>
<div class="header-right">
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
<mat-icon>refresh</mat-icon>
</button>
</div>
</header>
<!-- Filters -->
<mat-card class="filters-card">
<div class="filters-row">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search artifacts</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
[(ngModel)]="searchQuery"
(keyup.enter)="onSearch()"
placeholder="Search by artifact name or digest...">
@if (searchQuery) {
<button mat-icon-button matSuffix (click)="searchQuery = ''; onSearch()">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Status</mat-label>
<mat-select [(value)]="statusFilter" (selectionChange)="onFilterChange()">
@for (option of statusOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Verdict</mat-label>
<mat-select [(value)]="verdictFilter" (selectionChange)="onFilterChange()">
@for (option of verdictOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-raised-button color="primary" (click)="onSearch()">
<mat-icon>search</mat-icon>
Search
</button>
</div>
</mat-card>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading evidence threads...</p>
</div>
}
<!-- Error State -->
@if (error() && !loading()) {
<div class="error-container">
<mat-icon>error_outline</mat-icon>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="onRefresh()">
Try Again
</button>
</div>
}
<!-- Data Table -->
@if (!loading() && !error()) {
<mat-card class="table-card">
@if (threads().length === 0) {
<div class="empty-state">
<mat-icon>folder_open</mat-icon>
<p>No evidence threads found</p>
<p class="hint">Evidence threads are created when artifacts are scanned and evaluated.</p>
</div>
} @else {
<table mat-table [dataSource]="threads()" class="threads-table">
<!-- Artifact Name Column -->
<ng-container matColumnDef="artifactName">
<th mat-header-cell *matHeaderCellDef>Artifact</th>
<td mat-cell *matCellDef="let thread">
<div class="artifact-cell">
<span class="artifact-name">{{ thread.artifactName ?? 'Unnamed' }}</span>
<code class="artifact-digest">{{ shortDigest(thread.artifactDigest) }}</code>
</div>
</td>
</ng-container>
<!-- Verdict Column -->
<ng-container matColumnDef="verdict">
<th mat-header-cell *matHeaderCellDef>Verdict</th>
<td mat-cell *matCellDef="let thread">
<mat-chip [ngClass]="'verdict-' + getVerdictColor(thread.verdict)">
<mat-icon matChipAvatar>{{ getVerdictIcon(thread.verdict) }}</mat-icon>
{{ thread.verdict ?? 'Unknown' | titlecase }}
</mat-chip>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let thread">
<span class="status-badge" [class]="'status-' + thread.status">
<mat-icon>{{ getStatusIcon(thread.status) }}</mat-icon>
{{ thread.status | titlecase }}
</span>
</td>
</ng-container>
<!-- Risk Score Column -->
<ng-container matColumnDef="riskScore">
<th mat-header-cell *matHeaderCellDef>Risk Score</th>
<td mat-cell *matCellDef="let thread">
@if (thread.riskScore !== undefined && thread.riskScore !== null) {
<span class="risk-score" [class]="getRiskClass(thread.riskScore)">
{{ thread.riskScore | number:'1.1-1' }}
</span>
} @else {
<span class="no-score">-</span>
}
</td>
</ng-container>
<!-- Updated At Column -->
<ng-container matColumnDef="updatedAt">
<th mat-header-cell *matHeaderCellDef>Last Updated</th>
<td mat-cell *matCellDef="let thread">
{{ formatDate(thread.updatedAt) }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let thread">
<button mat-icon-button matTooltip="View details" (click)="onRowClick(thread); $event.stopPropagation()">
<mat-icon>open_in_new</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="onRowClick(row)"
class="clickable-row">
</tr>
</table>
<mat-paginator
[length]="totalItems()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
showFirstLastButtons>
</mat-paginator>
}
</mat-card>
}
</div>

View File

@@ -0,0 +1,240 @@
// Evidence Thread List Component Styles
.evidence-thread-list {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
// Header
.list-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
.header-left {
h1 {
font-size: 1.5rem;
font-weight: 500;
margin: 0 0 4px 0;
color: var(--mat-sys-on-surface);
}
.subtitle {
font-size: 0.875rem;
color: var(--mat-sys-on-surface-variant);
margin: 0;
}
}
}
// Filters
.filters-card {
margin-bottom: 24px;
.filters-row {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
.search-field {
flex: 1;
min-width: 250px;
}
.filter-field {
min-width: 150px;
}
}
}
// Loading state
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
p {
color: var(--mat-sys-on-surface-variant);
margin: 0;
}
}
// Error state
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--mat-sys-error);
}
p {
color: var(--mat-sys-error);
margin: 0;
text-align: center;
max-width: 400px;
}
}
// Empty state
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.3;
margin-bottom: 16px;
}
p {
color: var(--mat-sys-on-surface-variant);
margin: 0 0 8px 0;
&.hint {
font-size: 0.875rem;
}
}
}
// Table
.table-card {
overflow: hidden;
}
.threads-table {
width: 100%;
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--mat-sys-surface-variant);
}
}
.artifact-cell {
.artifact-name {
display: block;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.artifact-digest {
display: block;
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
margin-top: 2px;
}
}
}
// Verdict chips
.verdict-success {
--mat-chip-elevated-container-color: #e8f5e9;
--mat-chip-label-text-color: #2e7d32;
}
.verdict-warning {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #ef6c00;
}
.verdict-error {
--mat-chip-elevated-container-color: #ffebee;
--mat-chip-label-text-color: #c62828;
}
.verdict-info {
--mat-chip-elevated-container-color: #e3f2fd;
--mat-chip-label-text-color: #1565c0;
}
.verdict-neutral {
--mat-chip-elevated-container-color: var(--mat-sys-surface-variant);
--mat-chip-label-text-color: var(--mat-sys-on-surface-variant);
}
// Status badges
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&.status-active {
background: #e8f5e9;
color: #2e7d32;
}
&.status-archived {
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-on-surface-variant);
}
&.status-exported {
background: #e3f2fd;
color: #1565c0;
}
}
// Risk scores
.risk-score {
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
&.risk-critical {
background: #ffebee;
color: #c62828;
}
&.risk-high {
background: #fff3e0;
color: #ef6c00;
}
&.risk-medium {
background: #fff8e1;
color: #f9a825;
}
&.risk-low {
background: #e8f5e9;
color: #2e7d32;
}
}
.no-score {
color: var(--mat-sys-on-surface-variant);
}

View File

@@ -0,0 +1,183 @@
// <copyright file="evidence-thread-list.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatCardModule } from '@angular/material/card';
import {
EvidenceThread,
EvidenceThreadService,
EvidenceThreadStatus,
EvidenceVerdict,
EvidenceThreadFilter
} from '../../services/evidence-thread.service';
@Component({
selector: 'stella-evidence-thread-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatInputModule,
MatFormFieldModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatCardModule
],
templateUrl: './evidence-thread-list.component.html',
styleUrls: ['./evidence-thread-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceThreadListComponent implements OnInit {
private readonly router = inject(Router);
readonly evidenceService = inject(EvidenceThreadService);
readonly displayedColumns = ['artifactName', 'verdict', 'status', 'riskScore', 'updatedAt', 'actions'];
readonly threads = this.evidenceService.threads;
readonly loading = this.evidenceService.loading;
readonly error = this.evidenceService.error;
// Pagination
readonly totalItems = signal<number>(0);
readonly pageSize = signal<number>(20);
readonly pageIndex = signal<number>(0);
// Filters
searchQuery = '';
statusFilter: EvidenceThreadStatus | '' = '';
verdictFilter: EvidenceVerdict | '' = '';
readonly statusOptions: { value: EvidenceThreadStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
{ value: 'exported', label: 'Exported' }
];
readonly verdictOptions: { value: EvidenceVerdict | ''; label: string }[] = [
{ value: '', label: 'All Verdicts' },
{ value: 'allow', label: 'Allow' },
{ value: 'warn', label: 'Warn' },
{ value: 'block', label: 'Block' },
{ value: 'pending', label: 'Pending' },
{ value: 'unknown', label: 'Unknown' }
];
ngOnInit(): void {
this.loadThreads();
}
loadThreads(): void {
const filter: EvidenceThreadFilter = {
page: this.pageIndex() + 1,
pageSize: this.pageSize()
};
if (this.statusFilter) {
filter.status = this.statusFilter;
}
if (this.verdictFilter) {
filter.verdict = this.verdictFilter;
}
if (this.searchQuery) {
filter.artifactName = this.searchQuery;
}
this.evidenceService.getThreads(filter).subscribe(response => {
this.totalItems.set(response.total);
});
}
onSearch(): void {
this.pageIndex.set(0);
this.loadThreads();
}
onFilterChange(): void {
this.pageIndex.set(0);
this.loadThreads();
}
onPageChange(event: PageEvent): void {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
this.loadThreads();
}
onRowClick(thread: EvidenceThread): void {
const encodedDigest = encodeURIComponent(thread.artifactDigest);
this.router.navigate(['/evidence-thread', encodedDigest]);
}
onRefresh(): void {
this.loadThreads();
}
getVerdictColor(verdict?: EvidenceVerdict): string {
return this.evidenceService.getVerdictColor(verdict);
}
getVerdictIcon(verdict?: EvidenceVerdict): string {
const icons: Record<EvidenceVerdict, string> = {
allow: 'check_circle',
warn: 'warning',
block: 'block',
pending: 'schedule',
unknown: 'help_outline'
};
return icons[verdict ?? 'unknown'] ?? 'help_outline';
}
getStatusIcon(status: EvidenceThreadStatus): string {
const icons: Record<EvidenceThreadStatus, string> = {
active: 'play_circle',
archived: 'archive',
exported: 'cloud_done'
};
return icons[status] ?? 'help_outline';
}
formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
shortDigest(digest: string): string {
return digest.length > 19 ? `${digest.substring(0, 19)}...` : digest;
}
getRiskClass(riskScore: number): string {
if (riskScore >= 7) return 'risk-critical';
if (riskScore >= 4) return 'risk-high';
if (riskScore >= 2) return 'risk-medium';
return 'risk-low';
}
}

View File

@@ -0,0 +1,170 @@
<!-- Evidence Thread View Component -->
<div class="evidence-thread-view">
<!-- Header -->
<header class="thread-header">
<div class="header-left">
<button mat-icon-button (click)="onBack()" matTooltip="Back to list">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="thread-info">
<h1 class="thread-title">
@if (thread()?.thread?.artifactName) {
{{ thread()?.thread?.artifactName }}
} @else {
Evidence Thread
}
</h1>
<div class="thread-digest">
<code>{{ shortDigest() }}</code>
<button mat-icon-button (click)="copyDigest()" matTooltip="Copy full digest" class="copy-btn">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
<div class="header-right">
@if (thread()?.thread) {
<mat-chip-set>
<mat-chip [ngClass]="'verdict-' + verdictClass()">
<mat-icon matChipAvatar>{{ getVerdictIcon(thread()?.thread?.verdict) }}</mat-icon>
{{ getVerdictLabel(thread()?.thread?.verdict) }}
</mat-chip>
@if (thread()?.thread?.riskScore !== undefined && thread()?.thread?.riskScore !== null) {
<mat-chip>
<mat-icon matChipAvatar>speed</mat-icon>
Risk: {{ thread()?.thread?.riskScore | number:'1.1-1' }}
</mat-chip>
}
<mat-chip>
<mat-icon matChipAvatar>account_tree</mat-icon>
{{ nodeCount() }} nodes
</mat-chip>
</mat-chip-set>
}
<div class="header-actions">
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
<mat-icon>refresh</mat-icon>
</button>
<button mat-raised-button color="primary" (click)="onExport()" [disabled]="loading() || !thread()">
<mat-icon>download</mat-icon>
Export
</button>
</div>
</div>
</header>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading evidence thread...</p>
</div>
}
<!-- Error State -->
@if (error() && !loading()) {
<div class="error-container">
<mat-icon>error_outline</mat-icon>
<p>{{ error() }}</p>
<button mat-raised-button color="primary" (click)="onRefresh()">
Try Again
</button>
</div>
}
<!-- Content -->
@if (thread() && !loading()) {
<div class="thread-content">
<!-- Tab Navigation -->
<mat-tab-group
[selectedIndex]="selectedTabIndex()"
(selectedIndexChange)="onTabChange($event)"
animationDuration="200ms">
<!-- Graph Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>account_tree</mat-icon>
<span>Graph</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-graph-panel
[nodes]="nodes()"
[links]="links()"
[selectedNodeId]="selectedNodeId()"
(nodeSelect)="onNodeSelect($event)">
</stella-evidence-graph-panel>
</div>
</ng-template>
</mat-tab>
<!-- Timeline Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>timeline</mat-icon>
<span>Timeline</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-timeline-panel
[nodes]="nodes()"
[selectedNodeId]="selectedNodeId()"
(nodeSelect)="onNodeSelect($event)">
</stella-evidence-timeline-panel>
</div>
</ng-template>
</mat-tab>
<!-- Transcript Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>description</mat-icon>
<span>Transcript</span>
</ng-template>
<ng-template matTabContent>
<div class="tab-content">
<stella-evidence-transcript-panel
[artifactDigest]="artifactDigest()"
[thread]="thread()?.thread"
[nodes]="nodes()">
</stella-evidence-transcript-panel>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
<!-- Selected Node Detail Panel (Side Panel) -->
@if (selectedNodeId()) {
<aside class="node-detail-panel">
@for (node of nodes(); track node.id) {
@if (node.id === selectedNodeId()) {
<stella-evidence-node-card
[node]="node"
[expanded]="true"
(close)="selectedNodeId.set(null)">
</stella-evidence-node-card>
}
}
</aside>
}
</div>
}
<!-- Empty State -->
@if (!thread() && !loading() && !error()) {
<div class="empty-container">
<mat-icon>search_off</mat-icon>
<p>No evidence thread found for this artifact.</p>
<button mat-raised-button (click)="onBack()">
Back to List
</button>
</div>
}
</div>

View File

@@ -0,0 +1,232 @@
// Evidence Thread View Component Styles
.evidence-thread-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
// Header
.thread-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
gap: 16px;
flex-wrap: wrap;
.header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.thread-info {
min-width: 0;
flex: 1;
}
.thread-title {
font-size: 1.25rem;
font-weight: 500;
margin: 0;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thread-digest {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
code {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-variant);
padding: 2px 8px;
border-radius: 4px;
}
.copy-btn {
width: 24px;
height: 24px;
line-height: 24px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
// Verdict chips styling
.verdict-success {
--mat-chip-elevated-container-color: var(--mat-sys-tertiary-container);
--mat-chip-label-text-color: var(--mat-sys-on-tertiary-container);
}
.verdict-warning {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #e65100;
}
.verdict-error {
--mat-chip-elevated-container-color: var(--mat-sys-error-container);
--mat-chip-label-text-color: var(--mat-sys-on-error-container);
}
.verdict-info {
--mat-chip-elevated-container-color: var(--mat-sys-secondary-container);
--mat-chip-label-text-color: var(--mat-sys-on-secondary-container);
}
.verdict-neutral {
--mat-chip-elevated-container-color: var(--mat-sys-surface-variant);
--mat-chip-label-text-color: var(--mat-sys-on-surface-variant);
}
// Loading state
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
p {
color: var(--mat-sys-on-surface-variant);
margin: 0;
}
}
// Error state
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--mat-sys-error);
}
p {
color: var(--mat-sys-error);
margin: 0;
text-align: center;
max-width: 400px;
}
}
// Empty state
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
p {
color: var(--mat-sys-on-surface-variant);
margin: 0;
}
}
// Content area
.thread-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
mat-tab-group {
flex: 1;
display: flex;
flex-direction: column;
::ng-deep .mat-mdc-tab-body-wrapper {
flex: 1;
}
}
.tab-content {
height: 100%;
padding: 16px;
overflow: auto;
}
}
// Tab label styling
::ng-deep .mat-mdc-tab {
.mat-mdc-tab-label-content {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
// Node detail side panel
.node-detail-panel {
width: 400px;
max-width: 40%;
border-left: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
overflow-y: auto;
padding: 16px;
@media (max-width: 768px) {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 100%;
z-index: 100;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
}
}

View File

@@ -0,0 +1,176 @@
// <copyright file="evidence-thread-view.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { Subject, takeUntil } from 'rxjs';
import { EvidenceThreadService, EvidenceThreadGraph, EvidenceVerdict } from '../../services/evidence-thread.service';
import { EvidenceGraphPanelComponent } from '../evidence-graph-panel/evidence-graph-panel.component';
import { EvidenceTimelinePanelComponent } from '../evidence-timeline-panel/evidence-timeline-panel.component';
import { EvidenceTranscriptPanelComponent } from '../evidence-transcript-panel/evidence-transcript-panel.component';
import { EvidenceNodeCardComponent } from '../evidence-node-card/evidence-node-card.component';
import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidence-export-dialog.component';
@Component({
selector: 'stella-evidence-thread-view',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatTabsModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatTooltipModule,
MatMenuModule,
MatSnackBarModule,
MatDialogModule,
EvidenceGraphPanelComponent,
EvidenceTimelinePanelComponent,
EvidenceTranscriptPanelComponent,
EvidenceNodeCardComponent
],
templateUrl: './evidence-thread-view.component.html',
styleUrls: ['./evidence-thread-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
readonly evidenceService = inject(EvidenceThreadService);
private readonly destroy$ = new Subject<void>();
readonly artifactDigest = signal<string>('');
readonly selectedTabIndex = signal<number>(0);
readonly selectedNodeId = signal<string | null>(null);
readonly thread = this.evidenceService.currentThread;
readonly loading = this.evidenceService.loading;
readonly error = this.evidenceService.error;
readonly nodes = this.evidenceService.currentNodes;
readonly links = this.evidenceService.currentLinks;
readonly nodesByKind = this.evidenceService.nodesByKind;
readonly verdictClass = computed(() => {
const verdict = this.thread()?.thread?.verdict;
return this.evidenceService.getVerdictColor(verdict);
});
readonly nodeCount = computed(() => this.nodes().length);
readonly linkCount = computed(() => this.links().length);
readonly shortDigest = computed(() => {
const digest = this.artifactDigest();
if (!digest) return '';
// Show first 12 chars of digest (sha256:xxxx...)
return digest.length > 19 ? `${digest.substring(0, 19)}...` : digest;
});
ngOnInit(): void {
this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => {
const digest = params['artifactDigest'];
if (digest) {
this.artifactDigest.set(decodeURIComponent(digest));
this.loadThread();
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.evidenceService.clearCurrentThread();
}
private loadThread(): void {
const digest = this.artifactDigest();
if (!digest) return;
this.evidenceService.getThreadByDigest(digest)
.pipe(takeUntil(this.destroy$))
.subscribe({
error: () => {
this.snackBar.open('Failed to load evidence thread', 'Dismiss', {
duration: 5000
});
}
});
}
onRefresh(): void {
this.loadThread();
}
onTabChange(index: number): void {
this.selectedTabIndex.set(index);
}
onNodeSelect(nodeId: string): void {
this.selectedNodeId.set(nodeId);
}
onExport(): void {
const thread = this.thread();
if (!thread) return;
const dialogRef = this.dialog.open(EvidenceExportDialogComponent, {
width: '500px',
data: {
artifactDigest: this.artifactDigest(),
thread: thread.thread
}
});
dialogRef.afterClosed().subscribe(result => {
if (result?.success) {
this.snackBar.open('Export started successfully', 'Dismiss', {
duration: 3000
});
}
});
}
onBack(): void {
this.router.navigate(['/evidence-thread']);
}
getVerdictLabel(verdict?: EvidenceVerdict): string {
if (!verdict) return 'Unknown';
return verdict.charAt(0).toUpperCase() + verdict.slice(1);
}
getVerdictIcon(verdict?: EvidenceVerdict): string {
const icons: Record<EvidenceVerdict, string> = {
allow: 'check_circle',
warn: 'warning',
block: 'block',
pending: 'schedule',
unknown: 'help_outline'
};
return icons[verdict ?? 'unknown'] ?? 'help_outline';
}
copyDigest(): void {
navigator.clipboard.writeText(this.artifactDigest()).then(() => {
this.snackBar.open('Digest copied to clipboard', 'Dismiss', {
duration: 2000
});
});
}
}

View File

@@ -0,0 +1,79 @@
<!-- Evidence Timeline Panel Component -->
<div class="evidence-timeline-panel">
@if (timelineEntries().length === 0) {
<div class="empty-state">
<mat-icon>timeline</mat-icon>
<p>No evidence events to display</p>
</div>
} @else {
<div class="timeline">
@for (group of entriesByDate(); track group.date) {
<div class="timeline-date-group">
<div class="date-header">
<span class="date-text">{{ group.date }}</span>
</div>
@for (entry of group.entries; track entry.node.id) {
<div
class="timeline-entry"
[class.selected]="isSelected(entry.node.id)"
[class.first]="entry.isFirst"
[class.last]="entry.isLast"
(click)="onNodeClick(entry.node.id)">
<div class="timeline-connector">
<div class="connector-line top" [class.hidden]="entry.isFirst"></div>
<div class="connector-dot" [class]="'kind-' + entry.node.kind">
<mat-icon>{{ getNodeKindIcon(entry.node.kind) }}</mat-icon>
</div>
<div class="connector-line bottom" [class.hidden]="entry.isLast"></div>
</div>
<div class="timeline-content">
<div class="entry-header">
<span class="entry-kind">{{ getNodeKindLabel(entry.node.kind) }}</span>
<span class="entry-time">{{ entry.formattedTime }}</span>
</div>
<div class="entry-title">
@if (entry.node.title) {
{{ entry.node.title }}
} @else {
{{ getNodeKindLabel(entry.node.kind) }} Evidence
}
</div>
@if (entry.node.summary) {
<div class="entry-summary">{{ entry.node.summary }}</div>
}
<div class="entry-footer">
@if (entry.node.confidence !== undefined && entry.node.confidence !== null) {
<span class="confidence-badge" [class]="getConfidenceClass(entry.node.confidence)">
{{ (entry.node.confidence * 100) | number:'1.0-0' }}% confidence
</span>
}
@if (entry.node.anchors?.length) {
<span class="anchor-count">
<mat-icon>link</mat-icon>
{{ entry.node.anchors.length }}
</span>
}
</div>
</div>
<button
mat-icon-button
class="expand-btn"
matTooltip="View details"
(click)="onNodeClick(entry.node.id); $event.stopPropagation()">
<mat-icon>open_in_full</mat-icon>
</button>
</div>
}
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,264 @@
// Evidence Timeline Panel Component Styles
.evidence-timeline-panel {
height: 100%;
overflow-y: auto;
padding: 16px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
gap: 16px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
opacity: 0.3;
}
p {
margin: 0;
}
}
.timeline {
max-width: 800px;
margin: 0 auto;
}
.timeline-date-group {
margin-bottom: 24px;
.date-header {
padding: 8px 0;
margin-bottom: 8px;
.date-text {
font-size: 0.875rem;
font-weight: 600;
color: var(--mat-sys-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.timeline-entry {
display: flex;
align-items: stretch;
gap: 16px;
padding: 12px;
margin-left: 8px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background: var(--mat-sys-surface-variant);
}
&.selected {
background: var(--mat-sys-primary-container);
box-shadow: var(--mat-sys-level1);
}
}
.timeline-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
flex-shrink: 0;
.connector-line {
width: 2px;
flex: 1;
background: var(--mat-sys-outline-variant);
&.hidden {
visibility: hidden;
}
&.top {
min-height: 8px;
}
&.bottom {
min-height: 8px;
}
}
.connector-dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface);
border: 2px solid var(--mat-sys-outline);
flex-shrink: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
// Kind-specific colors
&.kind-sbom_diff {
background: #e3f2fd;
border-color: #1565c0;
color: #1565c0;
}
&.kind-reachability {
background: #fff3e0;
border-color: #ef6c00;
color: #ef6c00;
}
&.kind-vex {
background: #e8f5e9;
border-color: #2e7d32;
color: #2e7d32;
}
&.kind-attestation {
background: #f3e5f5;
border-color: #7b1fa2;
color: #7b1fa2;
}
&.kind-policy_eval {
background: #e0f2f1;
border-color: #00695c;
color: #00695c;
}
&.kind-runtime_observation {
background: #fce4ec;
border-color: #c2185b;
color: #c2185b;
}
&.kind-patch_verification {
background: #e8eaf6;
border-color: #3949ab;
color: #3949ab;
}
&.kind-approval {
background: #f1f8e9;
border-color: #558b2f;
color: #558b2f;
}
&.kind-ai_rationale {
background: #fff8e1;
border-color: #ff8f00;
color: #ff8f00;
}
}
}
.timeline-content {
flex: 1;
min-width: 0;
.entry-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.entry-kind {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
text-transform: uppercase;
}
.entry-time {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
}
.entry-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin-bottom: 4px;
}
.entry-summary {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.entry-footer {
display: flex;
align-items: center;
gap: 12px;
.confidence-badge {
font-size: 0.625rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
&.confidence-high {
background: #e8f5e9;
color: #2e7d32;
}
&.confidence-medium {
background: #fff3e0;
color: #ef6c00;
}
&.confidence-low {
background: #ffebee;
color: #c62828;
}
}
.anchor-count {
display: flex;
align-items: center;
gap: 2px;
font-size: 0.625rem;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
}
}
}
.expand-btn {
align-self: center;
opacity: 0;
transition: opacity 0.2s ease;
.timeline-entry:hover & {
opacity: 1;
}
}

View File

@@ -0,0 +1,127 @@
// <copyright file="evidence-timeline-panel.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, input, output, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { EvidenceNode, EvidenceThreadService } from '../../services/evidence-thread.service';
interface TimelineEntry {
node: EvidenceNode;
date: Date;
formattedDate: string;
formattedTime: string;
isFirst: boolean;
isLast: boolean;
}
@Component({
selector: 'stella-evidence-timeline-panel',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatIconModule,
MatButtonModule,
MatTooltipModule
],
templateUrl: './evidence-timeline-panel.component.html',
styleUrls: ['./evidence-timeline-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceTimelinePanelComponent {
private readonly evidenceService = inject(EvidenceThreadService);
// Inputs
nodes = input<EvidenceNode[]>([]);
selectedNodeId = input<string | null>(null);
// Outputs
nodeSelect = output<string>();
// Computed timeline entries sorted by date
readonly timelineEntries = computed<TimelineEntry[]>(() => {
const nodesArray = this.nodes();
if (!nodesArray.length) return [];
// Sort by creation date
const sorted = [...nodesArray].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateA - dateB;
});
return sorted.map((node, index) => {
const date = new Date(node.createdAt);
return {
node,
date,
formattedDate: this.formatDate(date),
formattedTime: this.formatTime(date),
isFirst: index === 0,
isLast: index === sorted.length - 1
};
});
});
// Group entries by date
readonly entriesByDate = computed(() => {
const entries = this.timelineEntries();
const groups = new Map<string, TimelineEntry[]>();
for (const entry of entries) {
const dateKey = entry.formattedDate;
const existing = groups.get(dateKey) ?? [];
existing.push(entry);
groups.set(dateKey, existing);
}
return Array.from(groups.entries()).map(([date, items]) => ({
date,
entries: items
}));
});
getNodeKindLabel(kind: string): string {
return this.evidenceService.getNodeKindLabel(kind as any);
}
getNodeKindIcon(kind: string): string {
return this.evidenceService.getNodeKindIcon(kind as any);
}
onNodeClick(nodeId: string): void {
this.nodeSelect.emit(nodeId);
}
isSelected(nodeId: string): boolean {
return this.selectedNodeId() === nodeId;
}
private formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
private formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
getConfidenceClass(confidence: number): string {
const percent = confidence * 100;
if (percent >= 80) return 'confidence-high';
if (percent >= 50) return 'confidence-medium';
return 'confidence-low';
}
}

View File

@@ -0,0 +1,121 @@
<!-- Evidence Transcript Panel Component -->
<div class="evidence-transcript-panel">
<!-- Generation Form -->
<mat-card class="generation-form">
<mat-card-header>
<mat-icon mat-card-avatar>description</mat-icon>
<mat-card-title>Generate Transcript</mat-card-title>
<mat-card-subtitle>Create a natural language explanation of the evidence chain</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="form-row">
<mat-form-field appearance="outline" class="type-select">
<mat-label>Transcript Type</mat-label>
<mat-select [(value)]="transcriptType">
@for (type of transcriptTypes; track type.value) {
<mat-option [value]="type.value">
{{ type.label }}
</mat-option>
}
</mat-select>
<mat-hint>{{ transcriptTypes.find(t => t.value === transcriptType)?.description }}</mat-hint>
</mat-form-field>
<mat-checkbox [(ngModel)]="useLlm" class="llm-checkbox">
<span class="checkbox-label">
<mat-icon>psychology</mat-icon>
Use AI Enhancement
</span>
<span class="checkbox-hint">Uses LLM to generate more natural explanations</span>
</mat-checkbox>
</div>
</mat-card-content>
<mat-card-actions align="end">
<button
mat-raised-button
color="primary"
(click)="onGenerate()"
[disabled]="generating()">
@if (generating()) {
<mat-spinner diameter="20"></mat-spinner>
Generating...
} @else {
<mat-icon>auto_awesome</mat-icon>
Generate Transcript
}
</button>
</mat-card-actions>
</mat-card>
<!-- Error State -->
@if (error()) {
<div class="error-banner">
<mat-icon>error_outline</mat-icon>
<span>{{ error() }}</span>
<button mat-button (click)="error.set(null)">Dismiss</button>
</div>
}
<!-- Transcript Display -->
@if (transcript()) {
<mat-card class="transcript-card">
<mat-card-header>
<mat-card-title>Generated Transcript</mat-card-title>
<mat-card-subtitle>
<span class="type-badge">{{ getTranscriptTypeBadge() }}</span>
<span class="separator">|</span>
<span>{{ formatGeneratedDate() }}</span>
@if (transcript()?.llmModel) {
<span class="separator">|</span>
<span class="llm-badge">
<mat-icon>psychology</mat-icon>
{{ transcript()?.llmModel }}
</span>
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="transcript-content">
<pre class="transcript-text">{{ transcript()?.content }}</pre>
</div>
@if (transcript()?.anchors?.length) {
<div class="transcript-anchors">
<h4>Referenced Evidence</h4>
<div class="anchor-list">
@for (anchor of transcript()?.anchors; track anchor.id) {
<span class="anchor-chip">
<mat-icon>link</mat-icon>
{{ anchor.label ?? anchor.id }}
</span>
}
</div>
</div>
}
</mat-card-content>
<mat-card-actions align="end">
<button mat-button (click)="onCopy()" matTooltip="Copy to clipboard">
<mat-icon>content_copy</mat-icon>
Copy
</button>
<button mat-button (click)="onDownload()" matTooltip="Download as Markdown">
<mat-icon>download</mat-icon>
Download
</button>
</mat-card-actions>
</mat-card>
}
<!-- Empty State -->
@if (!transcript() && !generating() && !error()) {
<div class="empty-state">
<mat-icon>article</mat-icon>
<p>No transcript generated yet</p>
<p class="hint">Select options above and click "Generate Transcript" to create a natural language explanation of the evidence chain.</p>
</div>
}
</div>

View File

@@ -0,0 +1,225 @@
// Evidence Transcript Panel Component Styles
.evidence-transcript-panel {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
}
// Generation Form
.generation-form {
mat-card-header {
mat-icon[mat-card-avatar] {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border-radius: 50%;
padding: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
}
.form-row {
display: flex;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
.type-select {
min-width: 200px;
flex: 1;
}
.llm-checkbox {
padding-top: 8px;
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.checkbox-hint {
display: block;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
margin-top: 4px;
margin-left: 24px;
}
}
}
mat-card-actions {
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}
}
// Error Banner
.error-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
border-radius: 8px;
mat-icon {
flex-shrink: 0;
}
span {
flex: 1;
}
}
// Transcript Card
.transcript-card {
flex: 1;
display: flex;
flex-direction: column;
mat-card-subtitle {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.type-badge {
font-size: 0.75rem;
font-weight: 500;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
padding: 2px 8px;
border-radius: 4px;
}
.separator {
color: var(--mat-sys-outline);
}
.llm-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
background: #fff8e1;
color: #ff8f00;
padding: 2px 8px;
border-radius: 4px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
mat-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.transcript-content {
flex: 1;
overflow: auto;
background: var(--mat-sys-surface-variant);
border-radius: 8px;
padding: 16px;
min-height: 200px;
.transcript-text {
font-family: var(--mat-sys-body-medium-font);
font-size: 0.875rem;
line-height: 1.6;
color: var(--mat-sys-on-surface);
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
}
.transcript-anchors {
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 8px 0;
}
.anchor-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.anchor-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
background: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
padding: 4px 12px;
border-radius: 16px;
border: 1px solid var(--mat-sys-outline-variant);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--mat-sys-primary);
}
}
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
flex: 1;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.3;
margin-bottom: 16px;
}
p {
color: var(--mat-sys-on-surface-variant);
margin: 0 0 8px 0;
&.hint {
font-size: 0.875rem;
max-width: 400px;
}
}
}

View File

@@ -0,0 +1,150 @@
// <copyright file="evidence-transcript-panel.component.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Component, input, signal, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
EvidenceThread,
EvidenceNode,
EvidenceTranscript,
EvidenceThreadService,
TranscriptType
} from '../../services/evidence-thread.service';
@Component({
selector: 'stella-evidence-transcript-panel',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatSelectModule,
MatCheckboxModule,
MatTooltipModule,
MatSnackBarModule,
FormsModule
],
templateUrl: './evidence-transcript-panel.component.html',
styleUrls: ['./evidence-transcript-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceTranscriptPanelComponent {
private readonly evidenceService = inject(EvidenceThreadService);
private readonly snackBar = inject(MatSnackBar);
// Inputs
artifactDigest = input.required<string>();
thread = input<EvidenceThread | undefined>();
nodes = input<EvidenceNode[]>([]);
// Local state
readonly transcript = signal<EvidenceTranscript | null>(null);
readonly generating = signal<boolean>(false);
readonly error = signal<string | null>(null);
// Form state
transcriptType: TranscriptType = 'summary';
useLlm = false;
readonly transcriptTypes: { value: TranscriptType; label: string; description: string }[] = [
{
value: 'summary',
label: 'Summary',
description: 'Brief overview of the evidence chain'
},
{
value: 'detailed',
label: 'Detailed',
description: 'Comprehensive analysis with all evidence'
},
{
value: 'audit',
label: 'Audit',
description: 'Formal audit trail suitable for compliance'
}
];
onGenerate(): void {
const digest = this.artifactDigest();
if (!digest) return;
this.generating.set(true);
this.error.set(null);
this.evidenceService.generateTranscript(digest, {
transcriptType: this.transcriptType,
useLlm: this.useLlm
}).subscribe({
next: (result) => {
this.transcript.set(result);
this.generating.set(false);
if (result) {
this.snackBar.open('Transcript generated successfully', 'Dismiss', {
duration: 3000
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Failed to generate transcript');
this.generating.set(false);
this.snackBar.open('Failed to generate transcript', 'Dismiss', {
duration: 5000
});
}
});
}
onCopy(): void {
const content = this.transcript()?.content;
if (!content) return;
navigator.clipboard.writeText(content).then(() => {
this.snackBar.open('Transcript copied to clipboard', 'Dismiss', {
duration: 2000
});
});
}
onDownload(): void {
const content = this.transcript()?.content;
if (!content) return;
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `evidence-transcript-${this.artifactDigest().slice(0, 12)}.md`;
link.click();
URL.revokeObjectURL(url);
}
getTranscriptTypeBadge(): string {
return this.transcriptTypes.find(t => t.value === this.transcript()?.transcriptType)?.label ?? '';
}
formatGeneratedDate(): string {
const date = this.transcript()?.generatedAt;
if (!date) return '';
return new Date(date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}

View File

@@ -0,0 +1,24 @@
// <copyright file="evidence-thread.routes.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Routes } from '@angular/router';
export const EVIDENCE_THREAD_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./components/evidence-thread-list/evidence-thread-list.component').then(
(m) => m.EvidenceThreadListComponent
),
title: 'Evidence Threads'
},
{
path: ':artifactDigest',
loadComponent: () =>
import('./components/evidence-thread-view/evidence-thread-view.component').then(
(m) => m.EvidenceThreadViewComponent
),
title: 'Evidence Thread Detail'
}
];

View File

@@ -0,0 +1,20 @@
// <copyright file="index.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
// Evidence Thread Feature Module Public API
// Routes
export * from './evidence-thread.routes';
// Services
export * from './services/evidence-thread.service';
// Components
export * from './components/evidence-thread-list/evidence-thread-list.component';
export * from './components/evidence-thread-view/evidence-thread-view.component';
export * from './components/evidence-node-card/evidence-node-card.component';
export * from './components/evidence-graph-panel/evidence-graph-panel.component';
export * from './components/evidence-timeline-panel/evidence-timeline-panel.component';
export * from './components/evidence-transcript-panel/evidence-transcript-panel.component';
export * from './components/evidence-export-dialog/evidence-export-dialog.component';

View File

@@ -0,0 +1,377 @@
// <copyright file="evidence-thread.service.ts" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, tap, of, BehaviorSubject, map } from 'rxjs';
// Evidence Thread Models
export type EvidenceThreadStatus = 'active' | 'archived' | 'exported';
export type EvidenceVerdict = 'allow' | 'warn' | 'block' | 'pending' | 'unknown';
export type ReachabilityMode = 'exploitable' | 'likely_exploitable' | 'possibly_exploitable' | 'unreachable' | 'unknown';
export type EvidenceNodeKind = 'sbom_diff' | 'reachability' | 'vex' | 'attestation' | 'policy_eval' | 'runtime_observation' | 'patch_verification' | 'approval' | 'ai_rationale';
export type EvidenceLinkRelation = 'supports' | 'contradicts' | 'precedes' | 'triggers' | 'derived_from' | 'references';
export type TranscriptType = 'summary' | 'detailed' | 'audit';
export type ExportFormat = 'dsse' | 'json' | 'pdf' | 'markdown';
export interface EvidenceThread {
id: string;
tenantId: string;
artifactDigest: string;
artifactName?: string;
status: EvidenceThreadStatus;
verdict?: EvidenceVerdict;
riskScore?: number;
reachabilityMode?: ReachabilityMode;
knowledgeSnapshotHash?: string;
engineVersion?: string;
createdAt: string;
updatedAt: string;
}
export interface EvidenceAnchor {
type: string;
id: string;
label?: string;
position?: { line?: number; column?: number };
}
export interface EvidenceNode {
id: string;
tenantId: string;
threadId: string;
kind: EvidenceNodeKind;
refId: string;
refDigest?: string;
title?: string;
summary?: string;
confidence?: number;
anchors: EvidenceAnchor[];
content: Record<string, unknown>;
createdAt: string;
}
export interface EvidenceLink {
id: string;
tenantId: string;
threadId: string;
srcNodeId: string;
dstNodeId: string;
relation: EvidenceLinkRelation;
weight?: number;
createdAt: string;
}
export interface EvidenceTranscript {
id: string;
tenantId: string;
threadId: string;
transcriptType: TranscriptType;
templateVersion: string;
llmModel?: string;
content: string;
anchors: EvidenceAnchor[];
generatedAt: string;
expiresAt?: string;
}
export interface EvidenceExport {
id: string;
tenantId: string;
threadId: string;
exportFormat: ExportFormat;
contentHash: string;
signature?: string;
signatureAlgorithm?: string;
signerKeyRef?: string;
signerKeyFingerprint?: string;
storagePath: string;
storageSizeBytes?: number;
downloadUrl?: string;
expiresAt?: string;
createdAt: string;
}
export interface EvidenceThreadGraph {
thread: EvidenceThread;
nodes: EvidenceNode[];
links: EvidenceLink[];
}
export interface EvidenceThreadListResponse {
items: EvidenceThread[];
total: number;
page: number;
pageSize: number;
}
export interface TranscriptRequest {
transcriptType: TranscriptType;
useLlm?: boolean;
}
export interface ExportRequest {
format: ExportFormat;
sign?: boolean;
keyRef?: string;
}
export interface EvidenceThreadFilter {
status?: EvidenceThreadStatus;
verdict?: EvidenceVerdict;
artifactName?: string;
page?: number;
pageSize?: number;
}
/**
* Service for managing Evidence Threads.
* Provides API integration and local state management for evidence thread operations.
*/
@Injectable({
providedIn: 'root'
})
export class EvidenceThreadService {
private readonly httpClient = inject(HttpClient);
private readonly apiBase = '/api/v1/evidence';
// Local state signals
private readonly _currentThread = signal<EvidenceThreadGraph | null>(null);
private readonly _threads = signal<EvidenceThread[]>([]);
private readonly _loading = signal<boolean>(false);
private readonly _error = signal<string | null>(null);
// Public computed signals
readonly currentThread = this._currentThread.asReadonly();
readonly threads = this._threads.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly currentNodes = computed(() => this._currentThread()?.nodes ?? []);
readonly currentLinks = computed(() => this._currentThread()?.links ?? []);
readonly nodesByKind = computed(() => {
const nodes = this.currentNodes();
return nodes.reduce((acc, node) => {
if (!acc[node.kind]) {
acc[node.kind] = [];
}
acc[node.kind].push(node);
return acc;
}, {} as Record<EvidenceNodeKind, EvidenceNode[]>);
});
/**
* Fetches a list of evidence threads with optional filtering.
*/
getThreads(filter?: EvidenceThreadFilter): Observable<EvidenceThreadListResponse> {
this._loading.set(true);
this._error.set(null);
let params = new HttpParams();
if (filter?.status) {
params = params.set('status', filter.status);
}
if (filter?.verdict) {
params = params.set('verdict', filter.verdict);
}
if (filter?.artifactName) {
params = params.set('artifactName', filter.artifactName);
}
if (filter?.page !== undefined) {
params = params.set('page', filter.page.toString());
}
if (filter?.pageSize !== undefined) {
params = params.set('pageSize', filter.pageSize.toString());
}
return this.httpClient.get<EvidenceThreadListResponse>(this.apiBase, { params }).pipe(
tap(response => {
this._threads.set(response.items);
this._loading.set(false);
}),
catchError(err => {
this._error.set(err.message ?? 'Failed to fetch evidence threads');
this._loading.set(false);
return of({ items: [], total: 0, page: 1, pageSize: 20 });
})
);
}
/**
* Fetches a single evidence thread by artifact digest, including its full graph.
*/
getThreadByDigest(artifactDigest: string): Observable<EvidenceThreadGraph | null> {
this._loading.set(true);
this._error.set(null);
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceThreadGraph>(`${this.apiBase}/${encodedDigest}`).pipe(
tap(graph => {
this._currentThread.set(graph);
this._loading.set(false);
}),
catchError(err => {
this._error.set(err.message ?? 'Failed to fetch evidence thread');
this._loading.set(false);
return of(null);
})
);
}
/**
* Fetches nodes for a specific thread.
*/
getNodes(artifactDigest: string): Observable<EvidenceNode[]> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceNode[]>(`${this.apiBase}/${encodedDigest}/nodes`).pipe(
catchError(err => {
this._error.set(err.message ?? 'Failed to fetch evidence nodes');
return of([]);
})
);
}
/**
* Fetches links for a specific thread.
*/
getLinks(artifactDigest: string): Observable<EvidenceLink[]> {
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.get<EvidenceLink[]>(`${this.apiBase}/${encodedDigest}/links`).pipe(
catchError(err => {
this._error.set(err.message ?? 'Failed to fetch evidence links');
return of([]);
})
);
}
/**
* Generates a transcript for the evidence thread.
*/
generateTranscript(artifactDigest: string, request: TranscriptRequest): Observable<EvidenceTranscript | null> {
this._loading.set(true);
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.post<EvidenceTranscript>(
`${this.apiBase}/${encodedDigest}/transcript`,
request
).pipe(
tap(() => this._loading.set(false)),
catchError(err => {
this._error.set(err.message ?? 'Failed to generate transcript');
this._loading.set(false);
return of(null);
})
);
}
/**
* Exports the evidence thread in the specified format.
*/
exportThread(artifactDigest: string, request: ExportRequest): Observable<EvidenceExport | null> {
this._loading.set(true);
const encodedDigest = encodeURIComponent(artifactDigest);
return this.httpClient.post<EvidenceExport>(
`${this.apiBase}/${encodedDigest}/export`,
request
).pipe(
tap(() => this._loading.set(false)),
catchError(err => {
this._error.set(err.message ?? 'Failed to export evidence thread');
this._loading.set(false);
return of(null);
})
);
}
/**
* Downloads an exported evidence bundle.
*/
downloadExport(exportId: string): Observable<Blob> {
return this.httpClient.get(`${this.apiBase}/exports/${exportId}/download`, {
responseType: 'blob'
});
}
/**
* Clears the current thread from local state.
*/
clearCurrentThread(): void {
this._currentThread.set(null);
}
/**
* Clears any error state.
*/
clearError(): void {
this._error.set(null);
}
/**
* Gets the display label for a node kind.
*/
getNodeKindLabel(kind: EvidenceNodeKind): string {
const labels: Record<EvidenceNodeKind, string> = {
sbom_diff: 'SBOM Diff',
reachability: 'Reachability',
vex: 'VEX',
attestation: 'Attestation',
policy_eval: 'Policy Evaluation',
runtime_observation: 'Runtime Observation',
patch_verification: 'Patch Verification',
approval: 'Approval',
ai_rationale: 'AI Rationale'
};
return labels[kind] ?? kind;
}
/**
* Gets the icon name for a node kind.
*/
getNodeKindIcon(kind: EvidenceNodeKind): string {
const icons: Record<EvidenceNodeKind, string> = {
sbom_diff: 'compare_arrows',
reachability: 'route',
vex: 'security',
attestation: 'verified_user',
policy_eval: 'policy',
runtime_observation: 'visibility',
patch_verification: 'check_circle',
approval: 'thumb_up',
ai_rationale: 'psychology'
};
return icons[kind] ?? 'help_outline';
}
/**
* Gets the color class for a verdict.
*/
getVerdictColor(verdict?: EvidenceVerdict): string {
if (!verdict) return 'neutral';
const colors: Record<EvidenceVerdict, string> = {
allow: 'success',
warn: 'warning',
block: 'error',
pending: 'info',
unknown: 'neutral'
};
return colors[verdict] ?? 'neutral';
}
/**
* Gets the display label for a link relation.
*/
getLinkRelationLabel(relation: EvidenceLinkRelation): string {
const labels: Record<EvidenceLinkRelation, string> = {
supports: 'Supports',
contradicts: 'Contradicts',
precedes: 'Precedes',
triggers: 'Triggers',
derived_from: 'Derived From',
references: 'References'
};
return labels[relation] ?? relation;
}
}

View File

@@ -538,7 +538,7 @@
</button>
<!-- Page number buttons (show max 5) -->
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
@for (page of [].constructor(Math.min(5, totalPages())); let i = $index; track i) {
<button
type="button"
class="pagination-btn pagination-btn--number"

View File

@@ -573,16 +573,11 @@ export class MirrorListComponent {
}
private emitFilter(): void {
const filter: FeedMirrorFilter = {};
if (this.searchTerm()) {
filter.searchTerm = this.searchTerm();
}
if (this.statusFilter()) {
filter.syncStatuses = [this.statusFilter() as MirrorSyncStatus];
}
if (this.feedTypeFilter()) {
filter.feedTypes = [this.feedTypeFilter() as FeedType];
}
const filter: FeedMirrorFilter = {
searchTerm: this.searchTerm() || undefined,
syncStatuses: this.statusFilter() ? [this.statusFilter() as MirrorSyncStatus] : undefined,
feedTypes: this.feedTypeFilter() ? [this.feedTypeFilter() as FeedType] : undefined,
};
this.filterChange.emit(filter);
}

View File

@@ -581,20 +581,3 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy {
return this.getEventClass(type);
}
}
text-align: center;
color: var(--text-secondary, #666);
font-style: italic;
padding: 2rem;
}
.placeholder-hint {
color: var(--text-secondary, #666);
margin-top: 0;
}
.placeholder-list {
color: var(--text-secondary, #666);
}
`]
})
export class IntegrationActivityComponent {}

View File

@@ -2,7 +2,16 @@ import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { Integration, IntegrationHealthResponse, TestConnectionResponse } from './integration.models';
import {
Integration,
IntegrationHealthResponse,
TestConnectionResponse,
IntegrationStatus,
getIntegrationStatusLabel,
getIntegrationStatusColor,
getIntegrationTypeLabel,
getProviderLabel,
} from './integration.models';
/**
* Integration detail component showing health, activity, and configuration.
@@ -17,33 +26,33 @@ import { Integration, IntegrationHealthResponse, TestConnectionResponse } from '
<header class="detail-header">
<a routerLink="/integrations" class="back-link">← Back to Integrations</a>
<h1>{{ integration.name }}</h1>
<span [class]="'status-badge status-' + integration.status.toLowerCase()">
{{ integration.status }}
<span [class]="'status-badge status-' + getStatusColor(integration.status)">
{{ getStatusLabel(integration.status) }}
</span>
</header>
<section class="detail-summary">
<div class="summary-item">
<label>Type</label>
<span>{{ integration.type }}</span>
<span>{{ getTypeLabel(integration.type) }}</span>
</div>
<div class="summary-item">
<label>Provider</label>
<span>{{ integration.provider }}</span>
<span>{{ getProviderName(integration.provider) }}</span>
</div>
<div class="summary-item">
<label>Endpoint</label>
<span>{{ integration.endpoint }}</span>
<span>{{ integration.baseUrl || 'Not configured' }}</span>
</div>
<div class="summary-item">
<label>Health</label>
<span [class]="'health-badge health-' + integration.lastHealthStatus.toLowerCase()">
{{ integration.lastHealthStatus }}
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')">
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }}
</span>
</div>
<div class="summary-item">
<label>Last Checked</label>
<span>{{ integration.lastHealthCheckAt | date:'medium' }}</span>
<span>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</span>
</div>
</section>
@@ -64,21 +73,21 @@ import { Integration, IntegrationHealthResponse, TestConnectionResponse } from '
<h3>Configuration</h3>
<dl class="config-list">
<dt>Organization</dt>
<dd>{{ integration.organizationId || 'Not set' }}</dd>
<dt>Tenant</dt>
<dd>{{ integration.tenantId || 'Not set' }}</dd>
<dt>Has Auth</dt>
<dd>{{ integration.hasAuth ? 'Yes (AuthRef)' : 'No' }}</dd>
<dd>{{ integration.authRef ? 'Yes (AuthRef)' : 'No' }}</dd>
<dt>Created</dt>
<dd>{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}</dd>
<dt>Updated</dt>
<dd>{{ integration.updatedAt | date:'medium' }} by {{ integration.updatedBy || 'system' }}</dd>
<dd>{{ integration.modifiedAt ? (integration.modifiedAt | date:'medium') : 'Never' }} by {{ integration.modifiedBy || 'system' }}</dd>
</dl>
<h3>Tags</h3>
<div class="tags" *ngIf="integration.tags?.length">
<span class="tag" *ngFor="let tag of integration.tags">{{ tag }}</span>
<div class="tags" *ngIf="integration.tags">
<span class="tag" *ngFor="let tag of getTagsArray(integration.tags)">{{ tag }}</span>
</div>
<p *ngIf="!integration.tags?.length" class="placeholder">No tags.</p>
<p *ngIf="!integration.tags" class="placeholder">No tags.</p>
</div>
}
@case ('health') {
@@ -98,17 +107,17 @@ import { Integration, IntegrationHealthResponse, TestConnectionResponse } from '
<div [class]="lastTestResult.success ? 'result-success' : 'result-failure'">
{{ lastTestResult.success ? '✓ Success' : '✗ Failed' }}
</div>
<p>{{ lastTestResult.message }}</p>
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} ({{ lastTestResult.duration }}ms)</small>
<p>{{ lastTestResult.errorMessage || 'Connection successful' }}</p>
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} ({{ lastTestResult.latencyMs || 0 }}ms)</small>
</div>
<div *ngIf="lastHealthResult" class="result-card">
<h3>Last Health Check</h3>
<div [class]="'health-badge health-' + lastHealthResult.status.toLowerCase()">
{{ lastHealthResult.status }}
<div [class]="'health-badge health-' + getStatusColor(lastHealthResult.status)">
{{ getStatusLabel(lastHealthResult.status) }}
</div>
<p>{{ lastHealthResult.message }}</p>
<small>Checked at {{ lastHealthResult.checkedAt | date:'medium' }} ({{ lastHealthResult.duration }}ms)</small>
<p>{{ lastHealthResult.lastTestSuccess ? 'Service is healthy' : 'Service has issues' }}</p>
<small>Checked at {{ lastHealthResult.lastTestedAt | date:'medium' }} ({{ lastHealthResult.averageLatencyMs || 0 }}ms avg)</small>
</div>
</div>
}
@@ -334,11 +343,11 @@ export class IntegrationDetailComponent implements OnInit {
testConnection(): void {
if (!this.integration) return;
this.testing = true;
this.integrationService.testConnection(this.integration.id).subscribe({
this.integrationService.testConnection(this.integration.integrationId).subscribe({
next: (result) => {
this.lastTestResult = result;
this.testing = false;
this.loadIntegration(this.integration!.id);
this.loadIntegration(this.integration!.integrationId);
},
error: (err) => {
console.error('Test connection failed:', err);
@@ -350,11 +359,11 @@ export class IntegrationDetailComponent implements OnInit {
checkHealth(): void {
if (!this.integration) return;
this.checking = true;
this.integrationService.checkHealth(this.integration.id).subscribe({
this.integrationService.getHealth(this.integration.integrationId).subscribe({
next: (result) => {
this.lastHealthResult = result;
this.checking = false;
this.loadIntegration(this.integration!.id);
this.loadIntegration(this.integration!.integrationId);
},
error: (err) => {
console.error('Health check failed:', err);
@@ -363,6 +372,27 @@ export class IntegrationDetailComponent implements OnInit {
});
}
// Helper methods for displaying enums
getStatusLabel(status: IntegrationStatus): string {
return getIntegrationStatusLabel(status);
}
getStatusColor(status: IntegrationStatus): string {
return getIntegrationStatusColor(status);
}
getTypeLabel(type: number): string {
return getIntegrationTypeLabel(type);
}
getProviderName(provider: number): string {
return getProviderLabel(provider);
}
getTagsArray(tags: string): string[] {
return tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [];
}
editIntegration(): void {
// TODO: Open edit dialog
console.log('Edit integration clicked');
@@ -371,7 +401,7 @@ export class IntegrationDetailComponent implements OnInit {
deleteIntegration(): void {
if (!this.integration) return;
if (confirm('Are you sure you want to delete this integration?')) {
this.integrationService.delete(this.integration.id).subscribe({
this.integrationService.delete(this.integration.integrationId).subscribe({
next: () => {
// Navigate back to list
window.location.href = '/integrations';

View File

@@ -28,11 +28,11 @@ describe('IntegrationHubComponent', () => {
return of(mockListResponse(5));
case IntegrationType.Scm:
return of(mockListResponse(3));
case IntegrationType.CiCd:
case IntegrationType.Ci:
return of(mockListResponse(2));
case IntegrationType.RuntimeHost:
case IntegrationType.Host:
return of(mockListResponse(8));
case IntegrationType.FeedMirror:
case IntegrationType.Feed:
return of(mockListResponse(4));
default:
return of(mockListResponse(0));

View File

@@ -187,13 +187,13 @@ export class IntegrationHubComponent {
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
next: (res) => this.stats.scm = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.Ci, pageSize: 1 }).subscribe({
next: (res) => this.stats.ci = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.Host, pageSize: 1 }).subscribe({
next: (res) => this.stats.hosts = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.Feed, pageSize: 1 }).subscribe({
next: (res) => this.stats.feeds = res.totalCount,
});
}

View File

@@ -3,7 +3,14 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { Integration, IntegrationType, IntegrationStatus } from './integration.models';
import {
Integration,
IntegrationType,
IntegrationStatus,
getIntegrationStatusLabel,
getIntegrationStatusColor,
getProviderLabel,
} from './integration.models';
/**
* Integration list component filtered by type.
@@ -23,10 +30,11 @@ import { Integration, IntegrationType, IntegrationStatus } from './integration.m
<section class="filters">
<select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select">
<option [ngValue]="undefined">All Statuses</option>
<option [value]="IntegrationStatus.Active">Active</option>
<option [value]="IntegrationStatus.Pending">Pending</option>
<option [value]="IntegrationStatus.Failed">Failed</option>
<option [value]="IntegrationStatus.Disabled">Disabled</option>
<option [ngValue]="IntegrationStatus.Active">Active</option>
<option [ngValue]="IntegrationStatus.PendingVerification">Pending</option>
<option [ngValue]="IntegrationStatus.Degraded">Degraded</option>
<option [ngValue]="IntegrationStatus.Paused">Paused</option>
<option [ngValue]="IntegrationStatus.Failed">Failed</option>
</select>
<input
type="text"
@@ -57,27 +65,27 @@ import { Integration, IntegrationType, IntegrationStatus } from './integration.m
</tr>
</thead>
<tbody>
@for (integration of integrations; track integration.id) {
@for (integration of integrations; track integration.integrationId) {
<tr>
<td>
<a [routerLink]="['/integrations', integration.id]">{{ integration.name }}</a>
<a [routerLink]="['/integrations', integration.integrationId]">{{ integration.name }}</a>
</td>
<td>{{ integration.provider }}</td>
<td>{{ getProviderName(integration.provider) }}</td>
<td>
<span [class]="'status-badge status-' + integration.status.toLowerCase()">
{{ integration.status }}
<span [class]="'status-badge status-' + getStatusColor(integration.status)">
{{ getStatusLabel(integration.status) }}
</span>
</td>
<td>
<span [class]="'health-badge health-' + integration.lastHealthStatus.toLowerCase()">
{{ integration.lastHealthStatus }}
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')">
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }}
</span>
</td>
<td>{{ integration.lastHealthCheckAt | date:'short' }}</td>
<td>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'short') : 'Never' }}</td>
<td class="actions">
<button (click)="testConnection(integration)" title="Test Connection">🔌</button>
<button (click)="checkHealth(integration)" title="Check Health">❤️</button>
<a [routerLink]="['/integrations', integration.id]" title="View Details">👁️</a>
<a [routerLink]="['/integrations', integration.integrationId]" title="View Details">👁️</a>
</td>
</tr>
}
@@ -264,7 +272,7 @@ export class IntegrationListComponent implements OnInit {
next: (response) => {
this.integrations = response.items;
this.totalCount = response.totalCount;
this.totalPages = response.totalPages;
this.totalPages = Math.ceil(response.totalCount / this.pageSize) || 1;
this.loading = false;
},
error: (err) => {
@@ -275,9 +283,9 @@ export class IntegrationListComponent implements OnInit {
}
testConnection(integration: Integration): void {
this.integrationService.testConnection(integration.id).subscribe({
this.integrationService.testConnection(integration.integrationId).subscribe({
next: (result) => {
alert(result.success ? 'Connection successful!' : `Connection failed: ${result.message}`);
alert(result.success ? 'Connection successful!' : `Connection failed: ${result.errorMessage || 'Unknown error'}`);
this.loadIntegrations();
},
error: (err) => {
@@ -287,9 +295,9 @@ export class IntegrationListComponent implements OnInit {
}
checkHealth(integration: Integration): void {
this.integrationService.checkHealth(integration.id).subscribe({
this.integrationService.getHealth(integration.integrationId).subscribe({
next: (result) => {
alert(`Health: ${result.status} - ${result.message || 'OK'}`);
alert(`Health: ${getIntegrationStatusLabel(result.status)} - ${result.lastTestSuccess ? 'OK' : 'Issues detected'}`);
this.loadIntegrations();
},
error: (err) => {
@@ -298,6 +306,19 @@ export class IntegrationListComponent implements OnInit {
});
}
// Helper methods for displaying enums
getStatusLabel(status: IntegrationStatus): string {
return getIntegrationStatusLabel(status);
}
getStatusColor(status: IntegrationStatus): string {
return getIntegrationStatusColor(status);
}
getProviderName(provider: number): string {
return getProviderLabel(provider);
}
addIntegration(): void {
// TODO: Open add integration dialog
console.log('Add integration clicked');
@@ -307,9 +328,9 @@ export class IntegrationListComponent implements OnInit {
switch (typeStr) {
case 'Registry': return IntegrationType.Registry;
case 'Scm': return IntegrationType.Scm;
case 'Ci': return IntegrationType.CiCd;
case 'Host': return IntegrationType.RuntimeHost;
case 'Feed': return IntegrationType.FeedMirror;
case 'Ci': return IntegrationType.Ci;
case 'Host': return IntegrationType.Host;
case 'Feed': return IntegrationType.Feed;
default: return undefined;
}
}

View File

@@ -23,7 +23,7 @@ import {
})
export class IntegrationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/v1/integrations`;
private readonly baseUrl = `${environment.apiBaseUrl}/api/v1/integrations`;
/**
* List integrations with filtering and pagination.

View File

@@ -7,7 +7,7 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ViewOptions } from '../../models/lineage.models';
import { LineageViewOptions } from '../../models/lineage.models';
/**
* Control panel component providing:
@@ -224,24 +224,31 @@ import { ViewOptions } from '../../models/lineage.models';
`]
})
export class LineageControlsComponent {
@Input() viewOptions: ViewOptions = {
@Input() viewOptions: LineageViewOptions = {
showLabels: true,
showEdgeLabels: false,
layoutDirection: 'LR',
enablePanZoom: true,
showMinimap: true,
darkMode: false,
maxNodes: 100,
animationDuration: 300,
hoverDelay: 200,
showLanes: true,
showDigests: true,
showStatusBadges: true,
showAttestations: true,
showMinimap: true,
darkMode: false,
layout: 'horizontal',
};
@Input() compareMode = false;
@Output() optionsChange = new EventEmitter<Partial<ViewOptions>>();
@Output() optionsChange = new EventEmitter<Partial<LineageViewOptions>>();
@Output() zoomIn = new EventEmitter<void>();
@Output() zoomOut = new EventEmitter<void>();
@Output() resetView = new EventEmitter<void>();
@Output() toggleCompare = new EventEmitter<void>();
onToggle(key: keyof ViewOptions, event: any): void {
onToggle(key: keyof LineageViewOptions, event: any): void {
const checked = event.target?.checked ?? event;
this.optionsChange.emit({ [key]: checked });
}

View File

@@ -334,6 +334,16 @@ export interface LineageViewOptions {
animationDuration: number;
/** Hover delay in ms */
hoverDelay: number;
/** Show lane backgrounds (from ViewOptions) */
showLanes: boolean;
/** Show digest abbreviations (from ViewOptions) */
showDigests: boolean;
/** Show status badges (from ViewOptions) */
showStatusBadges: boolean;
/** Show attestation indicators (from ViewOptions) */
showAttestations: boolean;
/** Layout orientation (from ViewOptions) */
layout: 'horizontal' | 'vertical';
}
/**

View File

@@ -38,6 +38,11 @@ const DEFAULT_VIEW_OPTIONS: LineageViewOptions = {
maxNodes: 100,
animationDuration: 300,
hoverDelay: 200,
showLanes: true,
showDigests: true,
showStatusBadges: true,
showAttestations: true,
layout: 'horizontal',
};
/**

View File

@@ -213,7 +213,7 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
<div class="p-4">
@if (dependencyGraph()) {
<div class="space-y-2 text-sm">
@for (node of dependencyGraph()!.nodes.filter(n => n.type !== 'service'); track node.id) {
@for (node of nonServiceDependencies(); track node.id) {
<div class="flex items-center justify-between p-2 bg-gray-50 rounded">
<div class="flex items-center gap-2">
<span
@@ -317,7 +317,7 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
<div>
<span class="text-gray-500">Checks:</span>
<span class="ml-1 font-medium">
{{ service.checks.filter(c => c.status === 'pass').length }}/{{ service.checks.length }}
{{ getPassingChecksCount(service.checks) }}/{{ service.checks.length }}
</span>
</div>
</div>
@@ -374,6 +374,13 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
this.incidents().filter((i) => i.state === 'active')
);
recentIncidents = computed(() => this.incidents());
nonServiceDependencies = computed(() =>
(this.dependencyGraph()?.nodes ?? []).filter((n) => n.type !== 'service')
);
getPassingChecksCount(checks: { status: string }[]): number {
return checks.filter((c) => c.status === 'pass').length;
}
ngOnInit(): void {
// Auto-refresh

View File

@@ -0,0 +1,359 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
OnInit,
output,
signal,
} from '@angular/core';
import { AirGapStatus } from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
@Component({
selector: 'app-airgap-mode-switch',
standalone: true,
imports: [CommonModule],
template: `
<div class="airgap-container" [class.airgap-container--sealed]="status()?.isSealed">
<div class="airgap-header">
<div class="header-info">
<span class="header-icon" aria-hidden="true">
@if (status()?.isSealed) {
<span class="icon-sealed">&#128274;</span>
} @else {
<span class="icon-connected">&#127760;</span>
}
</span>
<div class="header-text">
<span class="header-title">
{{ status()?.isSealed ? 'Air-Gap Mode' : 'Connected Mode' }}
</span>
<span class="header-subtitle">
@if (status()?.isSealed) {
Offline verification enabled
} @else {
Live feed updates active
}
</span>
</div>
</div>
@if (showToggle()) {
<button
type="button"
class="toggle-btn"
[class.toggle-btn--sealed]="status()?.isSealed"
(click)="onToggle()"
[disabled]="loading()"
[attr.aria-pressed]="status()?.isSealed"
title="{{ status()?.isSealed ? 'Exit air-gap mode' : 'Enter air-gap mode' }}"
>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</button>
}
</div>
@if (status(); as s) {
<div class="airgap-details">
<div class="status-row">
<span class="status-label">All Feeds Synced:</span>
<span
class="status-value"
[class.status-value--ok]="s.allFeedsSynced"
[class.status-value--warn]="!s.allFeedsSynced"
>
{{ s.allFeedsSynced ? 'Yes' : 'No' }}
</span>
</div>
@if (s.lastRekorSync) {
<div class="status-row">
<span class="status-label">Last Rekor Sync:</span>
<span class="status-value">{{ formatDate(s.lastRekorSync) }}</span>
</div>
}
@if (s.staleFeedNames.length > 0) {
<div class="status-row status-row--stale">
<span class="status-label">Stale Feeds:</span>
<span class="status-value status-value--warn">
{{ s.staleFeedNames.join(', ') }}
</span>
</div>
}
@if (s.sealedAt) {
<div class="status-row">
<span class="status-label">Sealed At:</span>
<span class="status-value">{{ formatDate(s.sealedAt) }}</span>
</div>
}
</div>
@if (s.warnings.length > 0) {
<div class="airgap-warnings">
<span class="warnings-title">Warnings</span>
<ul class="warnings-list">
@for (warning of s.warnings; track warning) {
<li class="warning-item">{{ warning }}</li>
}
</ul>
</div>
}
}
@if (loading()) {
<div class="loading-overlay">
<span class="spinner"></span>
</div>
}
</div>
`,
styles: [`
.airgap-container {
position: relative;
padding: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
transition: border-color 0.2s;
&--sealed {
border-color: #7c3aed;
background: linear-gradient(to bottom, rgba(124, 58, 237, 0.1), transparent);
}
}
.airgap-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.header-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
font-size: 1.5rem;
}
.icon-sealed {
filter: grayscale(0);
}
.icon-connected {
filter: grayscale(0);
}
.header-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.header-title {
font-size: 0.9375rem;
font-weight: 600;
color: #e2e8f0;
}
.airgap-container--sealed .header-title {
color: #a78bfa;
}
.header-subtitle {
font-size: 0.75rem;
color: #64748b;
}
.toggle-btn {
position: relative;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.toggle-track {
display: flex;
align-items: center;
width: 44px;
height: 24px;
background: #334155;
border-radius: 12px;
transition: background 0.2s;
}
.toggle-btn--sealed .toggle-track {
background: #7c3aed;
}
.toggle-thumb {
width: 20px;
height: 20px;
margin: 2px;
background: #e2e8f0;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-btn--sealed .toggle-thumb {
transform: translateX(20px);
}
.airgap-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #334155;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8125rem;
&--stale {
padding: 0.375rem 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
margin: 0 -0.5rem;
}
}
.status-label {
color: #64748b;
}
.status-value {
color: #e2e8f0;
&--ok {
color: #10b981;
}
&--warn {
color: #f59e0b;
}
}
.airgap-warnings {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 6px;
}
.warnings-title {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #f59e0b;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.warnings-list {
margin: 0;
padding-left: 1.25rem;
}
.warning-item {
font-size: 0.8125rem;
color: #fcd34d;
line-height: 1.4;
& + & {
margin-top: 0.25rem;
}
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.7);
border-radius: 8px;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AirGapModeSwitchComponent implements OnInit {
private readonly api = inject(POLICY_GATES_API);
readonly showToggle = input<boolean>(true);
readonly modeChanged = output<boolean>();
readonly status = signal<AirGapStatus | null>(null);
readonly loading = signal<boolean>(false);
ngOnInit(): void {
this.loadStatus();
}
onToggle(): void {
// In a real implementation, this would call an API to toggle the mode
// For now, just emit the event
const currentSealed = this.status()?.isSealed ?? false;
this.modeChanged.emit(!currentSealed);
}
refresh(): void {
this.loadStatus();
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
private loadStatus(): void {
this.loading.set(true);
this.api.getAirGapStatus().subscribe({
next: (status) => {
this.status.set(status);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,628 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
OnInit,
signal,
} from '@angular/core';
import {
BundleSimulationResult,
ArtifactSimulationResult,
PolicyProfile,
PolicySimulationStatus,
getSimulationStatusLabel,
getSimulationStatusColor,
} from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
import { ProfileSelectorComponent } from '../profile-selector/profile-selector.component';
import { GateSimulationResultsComponent } from '../gate-simulation-results/gate-simulation-results.component';
@Component({
selector: 'app-bundle-simulator',
standalone: true,
imports: [CommonModule, ProfileSelectorComponent, GateSimulationResultsComponent],
template: `
<div class="bundle-simulator">
<div class="simulator-header">
<h3 class="header-title">Bundle Simulation</h3>
<span class="header-subtitle">
Simulate policy gates for a promotion bundle
</span>
</div>
<div class="simulator-controls">
<app-profile-selector
label="Policy Profile"
[showPreview]="false"
(profileSelected)="onProfileSelected($event)"
/>
<button
type="button"
class="simulate-btn"
(click)="runSimulation()"
[disabled]="loading() || !selectedProfile()"
>
@if (loading()) {
<span class="btn-spinner"></span>
Simulating...
} @else {
<span class="btn-icon">&#9654;</span>
Run Simulation
}
</button>
</div>
@if (result(); as r) {
<div class="simulation-result">
<div class="result-summary" [class]="'result-summary--' + r.overallStatus">
<div class="summary-header">
<span class="summary-status" [style.color]="getStatusColor(r.overallStatus)">
@switch (r.overallStatus) {
@case ('pass') { <span>&#10003; All Gates Passed</span> }
@case ('fail') { <span>&#10007; Gates Failed</span> }
@case ('warn') { <span>! Warnings Present</span> }
@case ('error') { <span>? Error Occurred</span> }
}
</span>
<span class="summary-duration">{{ r.durationMs }}ms</span>
</div>
<div class="summary-stats">
<div class="stat-item">
<span class="stat-value">{{ r.artifactResults.length }}</span>
<span class="stat-label">Artifacts</span>
</div>
<div class="stat-item stat-item--blocking">
<span class="stat-value">{{ r.blockingGates.length }}</span>
<span class="stat-label">Blocking</span>
</div>
<div class="stat-item stat-item--warning">
<span class="stat-value">{{ r.warningGates.length }}</span>
<span class="stat-label">Warnings</span>
</div>
</div>
</div>
@if (r.blockingGates.length > 0) {
<div class="blocking-gates">
<span class="gates-title">Blocking Gates</span>
<div class="gates-list">
@for (gate of r.blockingGates; track gate) {
<span class="gate-chip gate-chip--blocking">{{ gate }}</span>
}
</div>
</div>
}
@if (r.warningGates.length > 0) {
<div class="warning-gates">
<span class="gates-title">Warning Gates</span>
<div class="gates-list">
@for (gate of r.warningGates; track gate) {
<span class="gate-chip gate-chip--warning">{{ gate }}</span>
}
</div>
</div>
}
<div class="artifact-results">
<div class="artifacts-header">
<span class="artifacts-title">Artifact Results</span>
<button
type="button"
class="expand-all-btn"
(click)="toggleAllArtifacts()"
>
{{ allExpanded() ? 'Collapse All' : 'Expand All' }}
</button>
</div>
@for (artifact of r.artifactResults; track artifact.artifactDigest) {
<div
class="artifact-item"
[class]="'artifact-item--' + artifact.status"
[class.artifact-item--expanded]="isArtifactExpanded(artifact.artifactDigest)"
>
<button
type="button"
class="artifact-header"
(click)="toggleArtifact(artifact.artifactDigest)"
>
<span class="artifact-status-icon" [style.color]="getStatusColor(artifact.status)">
@switch (artifact.status) {
@case ('pass') { <span>&#10003;</span> }
@case ('fail') { <span>&#10007;</span> }
@case ('warn') { <span>!</span> }
@case ('error') { <span>?</span> }
}
</span>
<span class="artifact-name">{{ artifact.artifactName || artifact.artifactDigest }}</span>
<span class="artifact-gates">
{{ getPassedCount(artifact) }}/{{ artifact.gateResults.length }} gates
</span>
<span class="expand-icon">
{{ isArtifactExpanded(artifact.artifactDigest) ? '&#9650;' : '&#9660;' }}
</span>
</button>
@if (isArtifactExpanded(artifact.artifactDigest)) {
<div class="artifact-details">
@if (artifact.blockingGates.length > 0) {
<div class="artifact-blocking">
<span class="blocking-label">Blocking:</span>
<span class="blocking-value">{{ artifact.blockingGates.join(', ') }}</span>
</div>
}
<div class="artifact-gates-list">
@for (gate of artifact.gateResults; track gate.gateName) {
<div
class="artifact-gate"
[class.artifact-gate--passed]="gate.passed"
[class.artifact-gate--failed]="!gate.passed"
>
<span class="gate-icon">
@if (gate.passed) { &#10003; } @else { &#10007; }
</span>
<span class="gate-name">{{ gate.gateName }}</span>
<span class="gate-detail">{{ gate.details }}</span>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>
}
@if (!result() && !loading()) {
<div class="empty-state">
<span class="empty-icon">&#128269;</span>
<span class="empty-text">Select a profile and run simulation to see results</span>
</div>
}
</div>
`,
styles: [`
.bundle-simulator {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.simulator-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.header-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #e2e8f0;
}
.header-subtitle {
font-size: 0.8125rem;
color: #64748b;
}
.simulator-controls {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
}
.simulate-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #3b82f6;
border: none;
border-radius: 6px;
color: #ffffff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.btn-icon {
font-size: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.simulation-result {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result-summary {
padding: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
&--pass { border-top: 3px solid #10b981; }
&--fail { border-top: 3px solid #ef4444; }
&--warn { border-top: 3px solid #f59e0b; }
&--error { border-top: 3px solid #6b7280; }
}
.summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.summary-status {
font-size: 1rem;
font-weight: 600;
}
.summary-duration {
font-size: 0.75rem;
color: #64748b;
}
.summary-stats {
display: flex;
gap: 2rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #e2e8f0;
}
.stat-item--blocking .stat-value { color: #ef4444; }
.stat-item--warning .stat-value { color: #f59e0b; }
.stat-label {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
}
.blocking-gates,
.warning-gates {
padding: 0.75rem;
border-radius: 6px;
}
.blocking-gates {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.warning-gates {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.gates-title {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.blocking-gates .gates-title { color: #fca5a5; }
.warning-gates .gates-title { color: #fcd34d; }
.gates-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.gate-chip {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
&--blocking {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
&--warning {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
}
.artifact-results {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.artifacts-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.artifacts-title {
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
}
.expand-all-btn {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid #334155;
border-radius: 4px;
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #475569;
}
}
.artifact-item {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
&--pass { border-left: 3px solid #10b981; }
&--fail { border-left: 3px solid #ef4444; }
&--warn { border-left: 3px solid #f59e0b; }
&--error { border-left: 3px solid #6b7280; }
&--expanded {
border-color: #475569;
}
}
.artifact-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
background: transparent;
border: none;
color: #e2e8f0;
text-align: left;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.artifact-status-icon {
font-weight: bold;
}
.artifact-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.artifact-gates {
font-size: 0.75rem;
color: #64748b;
}
.expand-icon {
font-size: 0.625rem;
color: #64748b;
}
.artifact-details {
padding: 0.75rem;
border-top: 1px solid #334155;
background: rgba(0, 0, 0, 0.2);
}
.artifact-blocking {
margin-bottom: 0.75rem;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
font-size: 0.8125rem;
}
.blocking-label {
color: #ef4444;
font-weight: 500;
}
.blocking-value {
color: #fca5a5;
margin-left: 0.25rem;
}
.artifact-gates-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.artifact-gate {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
font-size: 0.75rem;
&--passed {
.gate-icon { color: #10b981; }
}
&--failed {
.gate-icon { color: #ef4444; }
}
}
.gate-icon {
flex-shrink: 0;
}
.gate-name {
color: #e2e8f0;
font-weight: 500;
min-width: 100px;
}
.gate-detail {
color: #64748b;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem;
background: #1e293b;
border: 1px dashed #334155;
border-radius: 8px;
}
.empty-icon {
font-size: 2rem;
opacity: 0.5;
}
.empty-text {
font-size: 0.875rem;
color: #64748b;
text-align: center;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleSimulatorComponent {
private readonly api = inject(POLICY_GATES_API);
readonly promotionId = input.required<string>();
readonly selectedProfile = signal<PolicyProfile | null>(null);
readonly result = signal<BundleSimulationResult | null>(null);
readonly loading = signal<boolean>(false);
readonly expandedArtifacts = signal<Set<string>>(new Set());
readonly allExpanded = computed(() => {
const r = this.result();
if (!r) return false;
return this.expandedArtifacts().size === r.artifactResults.length;
});
onProfileSelected(profile: PolicyProfile): void {
this.selectedProfile.set(profile);
}
runSimulation(): void {
const profile = this.selectedProfile();
if (!profile) return;
this.loading.set(true);
this.api.simulateBundle(this.promotionId(), profile.name).subscribe({
next: (result) => {
this.result.set(result);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
isArtifactExpanded(digest: string): boolean {
return this.expandedArtifacts().has(digest);
}
toggleArtifact(digest: string): void {
this.expandedArtifacts.update((set) => {
const newSet = new Set(set);
if (newSet.has(digest)) {
newSet.delete(digest);
} else {
newSet.add(digest);
}
return newSet;
});
}
toggleAllArtifacts(): void {
const r = this.result();
if (!r) return;
if (this.allExpanded()) {
this.expandedArtifacts.set(new Set());
} else {
this.expandedArtifacts.set(new Set(r.artifactResults.map((a) => a.artifactDigest)));
}
}
getPassedCount(artifact: ArtifactSimulationResult): number {
return artifact.gateResults.filter((g) => g.passed).length;
}
getStatusLabel(status: PolicySimulationStatus): string {
return getSimulationStatusLabel(status);
}
getStatusColor(status: PolicySimulationStatus): string {
return getSimulationStatusColor(status);
}
}

View File

@@ -0,0 +1,374 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
OnInit,
signal,
} from '@angular/core';
import {
FeedFreshness,
FeedFreshnessSummary,
FeedStalenessStatus,
getFeedStatusLabel,
getFeedStatusColor,
formatStalenessTime,
} from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
@Component({
selector: 'app-feed-freshness-badges',
standalone: true,
imports: [CommonModule],
template: `
<div class="feed-freshness-container">
<div class="freshness-header">
<span class="header-title">Feed Status</span>
@if (loading()) {
<span class="loading-dot"></span>
} @else {
<span
class="overall-status"
[style.color]="getStatusColor(summary()?.overallStatus ?? 'unknown')"
>
{{ getStatusLabel(summary()?.overallStatus ?? 'unknown') }}
</span>
}
</div>
@if (!compact()) {
<div class="freshness-summary">
@if (summary(); as s) {
<div class="summary-counts">
<span class="count-item count-fresh">
<span class="count-value">{{ s.freshCount }}</span>
<span class="count-label">Fresh</span>
</span>
<span class="count-item count-warning">
<span class="count-value">{{ s.warningCount }}</span>
<span class="count-label">Warning</span>
</span>
<span class="count-item count-stale">
<span class="count-value">{{ s.staleCount }}</span>
<span class="count-label">Stale</span>
</span>
</div>
}
</div>
}
<div class="feed-badges" [class.feed-badges--compact]="compact()">
@for (feed of visibleFeeds(); track feed.id) {
<div
class="feed-badge"
[class]="'feed-badge--' + feed.stalenessStatus"
[title]="getFeedTooltip(feed)"
>
<span class="badge-indicator"></span>
<span class="badge-name">{{ feed.feedName }}</span>
@if (!compact() && feed.stalenessSeconds) {
<span class="badge-age">{{ formatAge(feed.stalenessSeconds) }}</span>
}
</div>
}
@if (hiddenFeedCount() > 0) {
<button
type="button"
class="show-more-btn"
(click)="toggleShowAll()"
>
{{ showAll() ? 'Show less' : '+' + hiddenFeedCount() + ' more' }}
</button>
}
</div>
@if (showRefreshButton()) {
<button
type="button"
class="refresh-btn"
(click)="refresh()"
[disabled]="loading()"
title="Refresh feed status"
>
<span class="refresh-icon" [class.spinning]="loading()">&#8635;</span>
Refresh
</button>
}
</div>
`,
styles: [`
.feed-freshness-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
}
.freshness-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
font-size: 0.8125rem;
font-weight: 600;
color: #e2e8f0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.overall-status {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.loading-dot {
width: 8px;
height: 8px;
background: #3b82f6;
border-radius: 50%;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.freshness-summary {
padding-bottom: 0.5rem;
border-bottom: 1px solid #334155;
}
.summary-counts {
display: flex;
gap: 1rem;
}
.count-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.count-value {
font-size: 1.25rem;
font-weight: 700;
}
.count-label {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
}
.count-fresh .count-value { color: #10b981; }
.count-warning .count-value { color: #f59e0b; }
.count-stale .count-value { color: #ef4444; }
.feed-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
&--compact {
gap: 0.25rem;
}
}
.feed-badge {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
font-size: 0.75rem;
cursor: default;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
&--fresh {
.badge-indicator { background: #10b981; }
.badge-name { color: #e2e8f0; }
}
&--warning {
.badge-indicator { background: #f59e0b; }
.badge-name { color: #fcd34d; }
}
&--stale {
.badge-indicator { background: #ef4444; }
.badge-name { color: #fca5a5; }
}
&--unknown {
.badge-indicator { background: #6b7280; }
.badge-name { color: #9ca3af; }
}
}
.badge-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge-name {
font-weight: 500;
}
.badge-age {
color: #64748b;
font-size: 0.6875rem;
}
.show-more-btn {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px dashed #475569;
border-radius: 4px;
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #64748b;
color: #e2e8f0;
}
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid #334155;
border-radius: 4px;
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: #475569;
color: #e2e8f0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.refresh-icon {
font-size: 0.875rem;
&.spinning {
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeedFreshnessBadgesComponent implements OnInit {
private readonly api = inject(POLICY_GATES_API);
readonly compact = input<boolean>(false);
readonly maxVisible = input<number>(5);
readonly showRefreshButton = input<boolean>(true);
readonly autoRefresh = input<boolean>(false);
readonly summary = signal<FeedFreshnessSummary | null>(null);
readonly loading = signal<boolean>(false);
readonly showAll = signal<boolean>(false);
readonly feeds = computed(() => this.summary()?.feeds ?? []);
readonly visibleFeeds = computed(() => {
const allFeeds = this.feeds();
if (this.showAll() || allFeeds.length <= this.maxVisible()) {
return allFeeds;
}
return allFeeds.slice(0, this.maxVisible());
});
readonly hiddenFeedCount = computed(() => {
const total = this.feeds().length;
const max = this.maxVisible();
return total > max ? total - max : 0;
});
ngOnInit(): void {
this.loadFreshness();
}
refresh(): void {
this.loadFreshness();
}
toggleShowAll(): void {
this.showAll.update((v) => !v);
}
getStatusLabel(status: FeedStalenessStatus): string {
return getFeedStatusLabel(status);
}
getStatusColor(status: FeedStalenessStatus): string {
return getFeedStatusColor(status);
}
formatAge(seconds: number): string {
return formatStalenessTime(seconds);
}
getFeedTooltip(feed: FeedFreshness): string {
const parts = [feed.feedName];
if (feed.lastSyncAt) {
parts.push(`Last sync: ${new Date(feed.lastSyncAt).toLocaleString()}`);
}
if (feed.entryCount) {
parts.push(`${feed.entryCount.toLocaleString()} entries`);
}
if (feed.errorMessage) {
parts.push(`Error: ${feed.errorMessage}`);
}
return parts.join('\n');
}
private loadFreshness(): void {
this.loading.set(true);
this.api.getFeedFreshnessSummary().subscribe({
next: (summary) => {
this.summary.set(summary);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,176 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GateSimulationResultsComponent } from './gate-simulation-results.component';
import { PolicySimulationResult, GateResult } from '../../../../core/api/policy-gates.models';
describe('GateSimulationResultsComponent', () => {
let component: GateSimulationResultsComponent;
let fixture: ComponentFixture<GateSimulationResultsComponent>;
const mockPassingResult: PolicySimulationResult = {
simulationId: 'sim-001',
profileId: 'profile-001',
profileName: 'standard',
status: 'pass',
gateResults: [
{
gateName: 'SBOM Attestation',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'CycloneDX SBOM attestation present',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Provenance',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'SLSA Provenance verified',
evaluatedAt: '2026-01-12T10:00:00Z',
},
],
missingEvidence: [],
softFailures: [],
hardFailures: [],
inputDigest: 'sha256:input123',
executedAt: '2026-01-12T10:00:00Z',
durationMs: 150,
};
const mockFailingResult: PolicySimulationResult = {
simulationId: 'sim-002',
profileId: 'profile-002',
profileName: 'strict-prod',
status: 'fail',
gateResults: [
{
gateName: 'SBOM Attestation',
gateType: 'attestation',
passed: true,
isMissingEvidence: false,
details: 'CycloneDX SBOM attestation present',
evaluatedAt: '2026-01-12T10:00:00Z',
},
{
gateName: 'Provenance',
gateType: 'attestation',
passed: false,
isMissingEvidence: true,
details: 'Missing provenance attestation',
evaluatedAt: '2026-01-12T10:00:00Z',
},
],
missingEvidence: ['provenance.intoto'],
softFailures: [],
hardFailures: ['unsigned_provenance'],
inputDigest: 'sha256:input456',
executedAt: '2026-01-12T10:00:00Z',
durationMs: 180,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GateSimulationResultsComponent],
}).compileComponents();
fixture = TestBed.createComponent(GateSimulationResultsComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should compute passedGates correctly for passing result', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
expect(component.passedGates().length).toBe(2);
});
it('should compute passedGates correctly for failing result', () => {
fixture.componentRef.setInput('result', mockFailingResult);
fixture.detectChanges();
expect(component.passedGates().length).toBe(1);
});
it('should toggle gate expansion', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
expect(component.isExpanded('SBOM Attestation')).toBe(false);
component.toggleGate('SBOM Attestation');
expect(component.isExpanded('SBOM Attestation')).toBe(true);
component.toggleGate('SBOM Attestation');
expect(component.isExpanded('SBOM Attestation')).toBe(false);
});
it('should return correct status label', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
expect(component.getStatusLabel('pass')).toBe('Passed');
expect(component.getStatusLabel('fail')).toBe('Failed');
expect(component.getStatusLabel('warn')).toBe('Warning');
expect(component.getStatusLabel('error')).toBe('Error');
});
it('should return correct status color', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
expect(component.getStatusColor('pass')).toBe('#10b981');
expect(component.getStatusColor('fail')).toBe('#ef4444');
expect(component.getStatusColor('warn')).toBe('#f59e0b');
expect(component.getStatusColor('error')).toBe('#6b7280');
});
it('should format date correctly', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
const formatted = component.formatDate('2026-01-12T10:00:00Z');
expect(formatted).toBeTruthy();
expect(formatted).not.toBe('2026-01-12T10:00:00Z'); // Should be localized
});
it('should handle invalid date gracefully', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
const formatted = component.formatDate('invalid-date');
expect(formatted).toBe('invalid-date');
});
it('should display missing evidence for failing result', () => {
fixture.componentRef.setInput('result', mockFailingResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const missingEvidence = compiled.querySelector('.evidence-alert--missing');
expect(missingEvidence).toBeTruthy();
});
it('should display hard failures for failing result', () => {
fixture.componentRef.setInput('result', mockFailingResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const hardFailures = compiled.querySelector('.evidence-alert--hard');
expect(hardFailures).toBeTruthy();
});
it('should not display alerts for passing result', () => {
fixture.componentRef.setInput('result', mockPassingResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const alerts = compiled.querySelectorAll('.evidence-alert');
expect(alerts.length).toBe(0);
});
});

View File

@@ -0,0 +1,449 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicySimulationResult,
GateResult,
PolicySimulationStatus,
getSimulationStatusLabel,
getSimulationStatusColor,
} from '../../../../core/api/policy-gates.models';
@Component({
selector: 'app-gate-simulation-results',
standalone: true,
imports: [CommonModule],
template: `
<div class="simulation-results" [class]="'simulation-results--' + result().status">
<div class="results-header">
<div class="header-status">
<span class="status-icon" [style.color]="getStatusColor(result().status)">
@switch (result().status) {
@case ('pass') { <span>&#10003;</span> }
@case ('fail') { <span>&#10007;</span> }
@case ('warn') { <span>!</span> }
@case ('error') { <span>?</span> }
}
</span>
<span class="status-text" [style.color]="getStatusColor(result().status)">
{{ getStatusLabel(result().status) }}
</span>
</div>
<div class="header-meta">
<span class="meta-item">
Profile: <strong>{{ result().profileName }}</strong>
</span>
<span class="meta-item">
Duration: <strong>{{ result().durationMs }}ms</strong>
</span>
</div>
</div>
@if (result().missingEvidence.length > 0) {
<div class="evidence-alert evidence-alert--missing">
<span class="alert-icon">&#9888;</span>
<div class="alert-content">
<span class="alert-title">Missing Evidence</span>
<span class="alert-items">{{ result().missingEvidence.join(', ') }}</span>
</div>
</div>
}
@if (result().hardFailures.length > 0) {
<div class="evidence-alert evidence-alert--hard">
<span class="alert-icon">&#10007;</span>
<div class="alert-content">
<span class="alert-title">Hard Failures (Blocking)</span>
<span class="alert-items">{{ result().hardFailures.join(', ') }}</span>
</div>
</div>
}
@if (result().softFailures.length > 0) {
<div class="evidence-alert evidence-alert--soft">
<span class="alert-icon">!</span>
<div class="alert-content">
<span class="alert-title">Soft Failures (Warnings)</span>
<span class="alert-items">{{ result().softFailures.join(', ') }}</span>
</div>
</div>
}
<div class="gates-section">
<div class="section-header">
<span class="section-title">Gate Results</span>
<span class="section-count">
{{ passedGates().length }}/{{ result().gateResults.length }} passed
</span>
</div>
<div class="gates-list">
@for (gate of result().gateResults; track gate.gateName) {
<div
class="gate-item"
[class.gate-item--passed]="gate.passed"
[class.gate-item--failed]="!gate.passed"
[class.gate-item--expanded]="isExpanded(gate.gateName)"
>
<button
type="button"
class="gate-header"
(click)="toggleGate(gate.gateName)"
[attr.aria-expanded]="isExpanded(gate.gateName)"
>
<span class="gate-status-icon">
@if (gate.passed) {
<span class="icon-pass">&#10003;</span>
} @else if (gate.isMissingEvidence) {
<span class="icon-missing">?</span>
} @else {
<span class="icon-fail">&#10007;</span>
}
</span>
<span class="gate-name">{{ gate.gateName }}</span>
<span class="gate-type">{{ gate.gateType }}</span>
<span class="expand-arrow">{{ isExpanded(gate.gateName) ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (isExpanded(gate.gateName)) {
<div class="gate-details">
<p class="gate-detail-text">{{ gate.details }}</p>
<div class="gate-detail-meta">
<span>Evaluated: {{ formatDate(gate.evaluatedAt) }}</span>
</div>
</div>
}
</div>
}
</div>
</div>
<div class="results-footer">
<span class="footer-item">
Simulation ID: <code>{{ result().simulationId }}</code>
</span>
@if (result().cachedUntil) {
<span class="footer-item">
Cached until: {{ formatDate(result().cachedUntil!) }}
</span>
}
</div>
</div>
`,
styles: [`
.simulation-results {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
overflow: hidden;
&--pass {
border-top: 3px solid #10b981;
}
&--fail {
border-top: 3px solid #ef4444;
}
&--warn {
border-top: 3px solid #f59e0b;
}
&--error {
border-top: 3px solid #6b7280;
}
}
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #334155;
}
.header-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-icon {
font-size: 1.25rem;
font-weight: bold;
}
.status-text {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.header-meta {
display: flex;
gap: 1rem;
}
.meta-item {
font-size: 0.8125rem;
color: #64748b;
strong {
color: #e2e8f0;
}
}
.evidence-alert {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
&--missing {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
}
&--hard {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
&--soft {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
}
}
.alert-icon {
font-size: 1rem;
line-height: 1;
}
.evidence-alert--missing .alert-icon { color: #f59e0b; }
.evidence-alert--hard .alert-icon { color: #ef4444; }
.evidence-alert--soft .alert-icon { color: #3b82f6; }
.alert-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.alert-title {
font-size: 0.8125rem;
font-weight: 600;
}
.evidence-alert--missing .alert-title { color: #fcd34d; }
.evidence-alert--hard .alert-title { color: #fca5a5; }
.evidence-alert--soft .alert-title { color: #93c5fd; }
.alert-items {
font-size: 0.75rem;
color: #94a3b8;
font-family: monospace;
}
.gates-section {
padding: 1rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
}
.section-count {
font-size: 0.75rem;
color: #64748b;
}
.gates-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gate-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #10b981;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
color: #e2e8f0;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: bold;
}
.icon-pass {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.icon-fail {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.icon-missing {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
.gate-name {
flex: 1;
font-weight: 500;
}
.gate-type {
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
font-size: 0.6875rem;
color: #64748b;
}
.expand-arrow {
font-size: 0.625rem;
color: #64748b;
}
.gate-details {
padding: 0.75rem;
border-top: 1px solid #334155;
background: rgba(0, 0, 0, 0.2);
}
.gate-detail-text {
margin: 0 0 0.5rem 0;
font-size: 0.8125rem;
color: #94a3b8;
line-height: 1.4;
}
.gate-detail-meta {
font-size: 0.75rem;
color: #64748b;
}
.results-footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid #334155;
}
.footer-item {
font-size: 0.75rem;
color: #64748b;
code {
padding: 0.125rem 0.25rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
font-family: monospace;
color: #94a3b8;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GateSimulationResultsComponent {
readonly result = input.required<PolicySimulationResult>();
readonly expandedGates = signal<Set<string>>(new Set());
readonly passedGates = computed(() =>
this.result().gateResults.filter((g) => g.passed)
);
isExpanded(gateName: string): boolean {
return this.expandedGates().has(gateName);
}
toggleGate(gateName: string): void {
this.expandedGates.update((set) => {
const newSet = new Set(set);
if (newSet.has(gateName)) {
newSet.delete(gateName);
} else {
newSet.add(gateName);
}
return newSet;
});
}
getStatusLabel(status: PolicySimulationStatus): string {
return getSimulationStatusLabel(status);
}
getStatusColor(status: PolicySimulationStatus): string {
return getSimulationStatusColor(status);
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}

View File

@@ -0,0 +1,614 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core';
import {
PolicyProfile,
PolicySimulationRequest,
PolicySimulationResult,
PolicySimulationType,
getProfileTypeLabel,
getProfileTypeColor,
} from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
import { ProfileSelectorComponent } from '../profile-selector/profile-selector.component';
import { FeedFreshnessBadgesComponent } from '../feed-freshness-badges/feed-freshness-badges.component';
import { AirGapModeSwitchComponent } from '../airgap-mode-switch/airgap-mode-switch.component';
import { GateSimulationResultsComponent } from '../gate-simulation-results/gate-simulation-results.component';
@Component({
selector: 'app-policy-preview-panel',
standalone: true,
imports: [
CommonModule,
ProfileSelectorComponent,
FeedFreshnessBadgesComponent,
AirGapModeSwitchComponent,
GateSimulationResultsComponent,
],
template: `
<div class="policy-preview-panel">
<div class="panel-header">
<h2 class="panel-title">Policy Gate Preview</h2>
<p class="panel-subtitle">
Simulate policy gates before promoting artifacts
</p>
</div>
<div class="panel-content">
<div class="panel-sidebar">
<div class="sidebar-section">
<app-profile-selector
label="Select Policy Profile"
(profileSelected)="onProfileSelected($event)"
/>
</div>
<div class="sidebar-section">
<app-feed-freshness-badges
[compact]="false"
[showRefreshButton]="true"
/>
</div>
<div class="sidebar-section">
<app-airgap-mode-switch
[showToggle]="false"
/>
</div>
</div>
<div class="panel-main">
@if (selectedProfile(); as profile) {
<div class="profile-details">
<div class="details-header">
<span
class="profile-type-badge"
[style.background-color]="getTypeColor(profile.profileType) + '20'"
[style.color]="getTypeColor(profile.profileType)"
>
{{ getTypeLabel(profile.profileType) }}
</span>
<h3 class="profile-name">{{ profile.displayName }}</h3>
@if (profile.isDefault) {
<span class="default-indicator">Default</span>
}
</div>
@if (profile.description) {
<p class="profile-description">{{ profile.description }}</p>
}
<div class="requirements-grid">
<div class="requirement-card">
<h4 class="card-title">Attestation Requirements</h4>
@if (profile.attestationRequirements.length > 0) {
<ul class="requirement-list">
@for (req of profile.attestationRequirements; track req.type) {
<li class="requirement-item">
<span class="req-type">{{ req.type }}</span>
@if (req.required) {
<span class="req-badge req-badge--required">Required</span>
} @else {
<span class="req-badge req-badge--optional">Optional</span>
}
@if (req.signers && req.signers.length > 0) {
<span class="req-detail">Signers: {{ req.signers.join(', ') }}</span>
}
@if (req.slsaLevel) {
<span class="req-detail">SLSA: {{ req.slsaLevel }}</span>
}
</li>
}
</ul>
} @else {
<span class="no-requirements">No attestation requirements</span>
}
</div>
@if (profile.vexRequirements) {
<div class="requirement-card">
<h4 class="card-title">VEX Requirements</h4>
<ul class="requirement-list">
<li class="requirement-item">
<span class="req-label">Allowed Statuses:</span>
<span class="req-value">{{ profile.vexRequirements.allow.join(', ') }}</span>
</li>
<li class="requirement-item">
<span class="req-label">Require Rationale:</span>
<span class="req-value">{{ profile.vexRequirements.requireRationale ? 'Yes' : 'No' }}</span>
</li>
@if (profile.vexRequirements.requireJustification) {
<li class="requirement-item">
<span class="req-label">Require Justification:</span>
<span class="req-value">Yes</span>
</li>
}
</ul>
</div>
}
@if (profile.reachabilityRequirements) {
<div class="requirement-card">
<h4 class="card-title">Reachability Requirements</h4>
<ul class="requirement-list">
<li class="requirement-item">
<span class="req-label">Max Analysis Age:</span>
<span class="req-value">{{ profile.reachabilityRequirements.runtimeMaxAgeDays }} days</span>
</li>
</ul>
</div>
}
<div class="requirement-card">
<h4 class="card-title">Failure Handling</h4>
<div class="failure-groups">
<div class="failure-group">
<span class="failure-label failure-label--hard">Hard Failures (Blocking)</span>
@if (profile.onFailHard.length > 0) {
<div class="failure-items">
@for (gate of profile.onFailHard; track gate) {
<span class="failure-chip failure-chip--hard">{{ gate }}</span>
}
</div>
} @else {
<span class="no-failures">None configured</span>
}
</div>
<div class="failure-group">
<span class="failure-label failure-label--soft">Soft Failures (Warnings)</span>
@if (profile.onFailSoft.length > 0) {
<div class="failure-items">
@for (gate of profile.onFailSoft; track gate) {
<span class="failure-chip failure-chip--soft">{{ gate }}</span>
}
</div>
} @else {
<span class="no-failures">None configured</span>
}
</div>
</div>
</div>
</div>
<div class="simulation-section">
<div class="simulation-header">
<h4 class="simulation-title">Run Simulation</h4>
<div class="simulation-controls">
<label class="checkbox-label">
<input
type="checkbox"
[checked]="forceFresh()"
(change)="toggleForceFresh()"
/>
Force fresh evaluation
</label>
<button
type="button"
class="simulate-btn"
(click)="runSimulation()"
[disabled]="simulating()"
>
@if (simulating()) {
<span class="btn-spinner"></span>
Simulating...
} @else {
Simulate Gates
}
</button>
</div>
</div>
@if (simulationResult(); as result) {
<app-gate-simulation-results [result]="result" />
}
</div>
</div>
} @else {
<div class="empty-state">
<span class="empty-icon">&#128203;</span>
<span class="empty-title">Select a Policy Profile</span>
<span class="empty-text">
Choose a profile from the left to view its requirements and run simulations
</span>
</div>
}
</div>
</div>
</div>
`,
styles: [`
.policy-preview-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #0f172a;
color: #e2e8f0;
}
.panel-header {
padding: 1.5rem;
border-bottom: 1px solid #1e293b;
}
.panel-title {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
}
.panel-subtitle {
margin: 0;
font-size: 0.875rem;
color: #64748b;
}
.panel-content {
display: grid;
grid-template-columns: 320px 1fr;
flex: 1;
overflow: hidden;
}
.panel-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: #0f172a;
border-right: 1px solid #1e293b;
overflow-y: auto;
}
.sidebar-section {
/* Section styling handled by child components */
}
.panel-main {
padding: 1.5rem;
overflow-y: auto;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.details-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.profile-type-badge {
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.profile-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.default-indicator {
padding: 0.125rem 0.5rem;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.profile-description {
margin: 0;
font-size: 0.875rem;
color: #94a3b8;
line-height: 1.5;
}
.requirements-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.requirement-card {
padding: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
}
.card-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
}
.requirement-list {
margin: 0;
padding: 0;
list-style: none;
}
.requirement-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid #334155;
font-size: 0.8125rem;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:first-child {
padding-top: 0;
}
}
.req-type {
font-family: monospace;
color: #e2e8f0;
}
.req-badge {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
&--required {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
&--optional {
background: rgba(100, 116, 139, 0.2);
color: #94a3b8;
}
}
.req-detail {
font-size: 0.75rem;
color: #64748b;
}
.req-label {
color: #64748b;
}
.req-value {
color: #e2e8f0;
}
.no-requirements {
font-size: 0.8125rem;
color: #64748b;
font-style: italic;
}
.failure-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.failure-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.failure-label {
font-size: 0.75rem;
font-weight: 500;
&--hard { color: #ef4444; }
&--soft { color: #f59e0b; }
}
.failure-items {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.failure-chip {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
&--hard {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
&--soft {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
}
.no-failures {
font-size: 0.75rem;
color: #64748b;
font-style: italic;
}
.simulation-section {
padding-top: 1.5rem;
border-top: 1px solid #334155;
}
.simulation-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.simulation-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.simulation-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #94a3b8;
cursor: pointer;
input {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.simulate-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
border: none;
border-radius: 6px;
color: #ffffff;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
height: 100%;
padding: 3rem;
}
.empty-icon {
font-size: 3rem;
opacity: 0.5;
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
color: #e2e8f0;
}
.empty-text {
font-size: 0.875rem;
color: #64748b;
text-align: center;
max-width: 300px;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyPreviewPanelComponent {
private readonly api = inject(POLICY_GATES_API);
readonly artifactDigests = input<readonly string[]>([]);
readonly availableAttestations = input<readonly string[]>([]);
readonly selectedProfile = signal<PolicyProfile | null>(null);
readonly simulationResult = signal<PolicySimulationResult | null>(null);
readonly simulating = signal<boolean>(false);
readonly forceFresh = signal<boolean>(false);
onProfileSelected(profile: PolicyProfile): void {
this.selectedProfile.set(profile);
this.simulationResult.set(null);
}
toggleForceFresh(): void {
this.forceFresh.update((v) => !v);
}
runSimulation(): void {
const profile = this.selectedProfile();
if (!profile) return;
const request: PolicySimulationRequest = {
profileIdOrName: profile.name,
simulationType: 'preview',
artifactDigests: this.artifactDigests(),
availableAttestations: this.availableAttestations(),
forceFresh: this.forceFresh(),
};
this.simulating.set(true);
this.api.simulate(request).subscribe({
next: (result) => {
this.simulationResult.set(result);
this.simulating.set(false);
},
error: () => {
this.simulating.set(false);
},
});
}
getTypeLabel(type: string): string {
return getProfileTypeLabel(type as any);
}
getTypeColor(type: string): string {
return getProfileTypeColor(type as any);
}
}

View File

@@ -0,0 +1,136 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ProfileSelectorComponent } from './profile-selector.component';
import { POLICY_GATES_API, PolicyGatesApi } from '../../../../core/api/policy-gates.client';
import { PolicyProfile } from '../../../../core/api/policy-gates.models';
describe('ProfileSelectorComponent', () => {
let component: ProfileSelectorComponent;
let fixture: ComponentFixture<ProfileSelectorComponent>;
let mockApi: jest.Mocked<PolicyGatesApi>;
const mockProfiles: PolicyProfile[] = [
{
id: 'profile-001',
name: 'standard',
displayName: 'Standard',
description: 'Standard profile',
profileType: 'standard',
isDefault: true,
isBuiltin: true,
policyYaml: 'name: standard',
policyDigest: 'sha256:abc123',
attestationRequirements: [{ type: 'sbom.cyclonedx', required: true }],
onFailSoft: [],
onFailHard: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
{
id: 'profile-002',
name: 'strict-prod',
displayName: 'Strict (Production)',
description: 'Strict profile for production',
profileType: 'strict_prod',
isDefault: false,
isBuiltin: true,
policyYaml: 'name: strict-prod',
policyDigest: 'sha256:def456',
attestationRequirements: [
{ type: 'sbom.cyclonedx', required: true },
{ type: 'provenance.intoto', required: true },
],
onFailSoft: [],
onFailHard: ['unsigned_provenance'],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
];
beforeEach(async () => {
mockApi = {
listProfiles: jest.fn().mockReturnValue(of(mockProfiles)),
getProfile: jest.fn(),
getProfileByName: jest.fn(),
createProfile: jest.fn(),
updateProfile: jest.fn(),
deleteProfile: jest.fn(),
setDefaultProfile: jest.fn(),
getEffectiveProfile: jest.fn(),
validatePolicyYaml: jest.fn(),
simulate: jest.fn(),
simulateBundle: jest.fn(),
getFeedFreshnessSummary: jest.fn(),
getFeedFreshness: jest.fn(),
getAirGapStatus: jest.fn(),
} as unknown as jest.Mocked<PolicyGatesApi>;
await TestBed.configureTestingModule({
imports: [ProfileSelectorComponent],
providers: [{ provide: POLICY_GATES_API, useValue: mockApi }],
}).compileComponents();
fixture = TestBed.createComponent(ProfileSelectorComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load profiles on init', () => {
fixture.detectChanges();
expect(mockApi.listProfiles).toHaveBeenCalledWith(true);
});
it('should select default profile automatically', () => {
const profileSelectedSpy = jest.spyOn(component.profileSelected, 'emit');
fixture.detectChanges();
expect(component.selectedProfileId()).toBe('profile-001');
expect(profileSelectedSpy).toHaveBeenCalledWith(mockProfiles[0]);
});
it('should emit profileSelected when selection changes', () => {
const profileSelectedSpy = jest.spyOn(component.profileSelected, 'emit');
fixture.detectChanges();
component.onSelectionChange('profile-002');
expect(component.selectedProfileId()).toBe('profile-002');
expect(profileSelectedSpy).toHaveBeenCalledWith(mockProfiles[1]);
});
it('should display profile type label correctly', () => {
expect(component.getTypeLabel('standard')).toBe('Standard');
expect(component.getTypeLabel('strict_prod')).toBe('Strict (Production)');
expect(component.getTypeLabel('lenient_dev')).toBe('Lenient (Development)');
});
it('should format attestations correctly', () => {
const profile = mockProfiles[0];
const formatted = component.formatAttestations(profile);
expect(formatted).toBe('sbom.cyclonedx (required)');
});
it('should compute selectedProfile correctly', () => {
fixture.detectChanges();
component.selectedProfileId.set('profile-002');
expect(component.selectedProfile()?.name).toBe('strict-prod');
});
it('should respect includeBuiltin input', () => {
fixture.componentRef.setInput('includeBuiltin', false);
fixture.detectChanges();
expect(mockApi.listProfiles).toHaveBeenCalledWith(false);
});
it('should use initial profile id when provided', () => {
fixture.componentRef.setInput('initialProfileId', 'profile-002');
fixture.detectChanges();
expect(component.selectedProfileId()).toBe('profile-002');
});
});

View File

@@ -0,0 +1,319 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
PolicyProfile,
getProfileTypeLabel,
getProfileTypeColor,
} from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
@Component({
selector: 'app-profile-selector',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="profile-selector">
<label class="selector-label" [for]="selectorId">
{{ label() }}
</label>
<div class="selector-wrapper">
<select
[id]="selectorId"
class="selector-select"
[ngModel]="selectedProfileId()"
(ngModelChange)="onSelectionChange($event)"
[disabled]="loading()"
>
@if (placeholder()) {
<option value="" disabled>{{ placeholder() }}</option>
}
@for (profile of profiles(); track profile.id) {
<option [value]="profile.id">
{{ profile.displayName }}
@if (profile.isDefault) { (Default) }
@if (profile.isBuiltin) { [Built-in] }
</option>
}
</select>
@if (loading()) {
<span class="loading-indicator" aria-label="Loading profiles">
<span class="spinner"></span>
</span>
}
</div>
@if (selectedProfile(); as profile) {
<div class="profile-preview">
<div class="preview-header">
<span
class="profile-type-badge"
[style.background-color]="getTypeColor(profile.profileType) + '20'"
[style.color]="getTypeColor(profile.profileType)"
>
{{ getTypeLabel(profile.profileType) }}
</span>
@if (profile.isDefault) {
<span class="default-badge">Default</span>
}
</div>
@if (profile.description) {
<p class="preview-description">{{ profile.description }}</p>
}
<div class="preview-requirements">
@if (profile.attestationRequirements.length > 0) {
<div class="requirement-group">
<span class="requirement-label">Attestations:</span>
<span class="requirement-value">
{{ formatAttestations(profile) }}
</span>
</div>
}
@if (profile.reachabilityRequirements) {
<div class="requirement-group">
<span class="requirement-label">Max Reachability Age:</span>
<span class="requirement-value">
{{ profile.reachabilityRequirements.runtimeMaxAgeDays }} days
</span>
</div>
}
@if (profile.onFailHard.length > 0) {
<div class="requirement-group">
<span class="requirement-label">Hard Failures:</span>
<span class="requirement-value hard-failures">
{{ profile.onFailHard.length }} gates
</span>
</div>
}
</div>
</div>
}
</div>
`,
styles: [`
.profile-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selector-label {
font-size: 0.875rem;
font-weight: 500;
color: #e2e8f0;
}
.selector-wrapper {
position: relative;
display: flex;
align-items: center;
}
.selector-select {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.875rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2394a3b8'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1rem;
&:hover:not(:disabled) {
border-color: #475569;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.loading-indicator {
position: absolute;
right: 2.5rem;
display: flex;
align-items: center;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.profile-preview {
margin-top: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
}
.preview-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.profile-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.default-badge {
padding: 0.125rem 0.375rem;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.preview-description {
margin: 0 0 0.75rem 0;
font-size: 0.8125rem;
color: #94a3b8;
line-height: 1.4;
}
.preview-requirements {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.requirement-group {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.requirement-label {
color: #64748b;
}
.requirement-value {
color: #e2e8f0;
&.hard-failures {
color: #ef4444;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileSelectorComponent implements OnInit {
private readonly api = inject(POLICY_GATES_API);
readonly label = input<string>('Policy Profile');
readonly placeholder = input<string>('Select a profile...');
readonly initialProfileId = input<string>();
readonly includeBuiltin = input<boolean>(true);
readonly showPreview = input<boolean>(true);
readonly profileSelected = output<PolicyProfile>();
readonly profiles = signal<readonly PolicyProfile[]>([]);
readonly selectedProfileId = signal<string>('');
readonly loading = signal<boolean>(false);
readonly selectorId = `profile-selector-${Math.random().toString(36).slice(2, 9)}`;
readonly selectedProfile = computed(() => {
const id = this.selectedProfileId();
return this.profiles().find((p) => p.id === id) ?? null;
});
ngOnInit(): void {
this.loadProfiles();
}
private loadProfiles(): void {
this.loading.set(true);
this.api.listProfiles(this.includeBuiltin()).subscribe({
next: (profiles) => {
this.profiles.set(profiles);
this.loading.set(false);
// Set initial selection
const initialId = this.initialProfileId();
if (initialId) {
this.selectedProfileId.set(initialId);
} else {
// Select default profile if available
const defaultProfile = profiles.find((p) => p.isDefault);
if (defaultProfile) {
this.selectedProfileId.set(defaultProfile.id);
this.profileSelected.emit(defaultProfile);
}
}
},
error: () => {
this.loading.set(false);
},
});
}
onSelectionChange(profileId: string): void {
this.selectedProfileId.set(profileId);
const profile = this.profiles().find((p) => p.id === profileId);
if (profile) {
this.profileSelected.emit(profile);
}
}
getTypeLabel(type: string): string {
return getProfileTypeLabel(type as any);
}
getTypeColor(type: string): string {
return getProfileTypeColor(type as any);
}
formatAttestations(profile: PolicyProfile): string {
return profile.attestationRequirements
.map((r) => r.type + (r.required ? ' (required)' : ''))
.join(', ');
}
}

View File

@@ -0,0 +1,12 @@
// Policy Gates Feature Module Exports
// Components
export { PolicyPreviewPanelComponent } from './components/policy-preview-panel/policy-preview-panel.component';
export { ProfileSelectorComponent } from './components/profile-selector/profile-selector.component';
export { FeedFreshnessBadgesComponent } from './components/feed-freshness-badges/feed-freshness-badges.component';
export { AirGapModeSwitchComponent } from './components/airgap-mode-switch/airgap-mode-switch.component';
export { GateSimulationResultsComponent } from './components/gate-simulation-results/gate-simulation-results.component';
export { BundleSimulatorComponent } from './components/bundle-simulator/bundle-simulator.component';
// Routes
export { POLICY_GATES_ROUTES } from './policy-gates.routes';

View File

@@ -0,0 +1,20 @@
import { Routes } from '@angular/router';
export const POLICY_GATES_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./components/policy-preview-panel/policy-preview-panel.component').then(
(m) => m.PolicyPreviewPanelComponent
),
title: 'Policy Gates',
},
{
path: 'simulate/:promotionId',
loadComponent: () =>
import('./components/bundle-simulator/bundle-simulator.component').then(
(m) => m.BundleSimulatorComponent
),
title: 'Bundle Simulation',
},
];

View File

@@ -152,7 +152,7 @@ import {
<p class="config__section-desc">Configure actions to take when thresholds are reached.</p>
<div formArrayName="thresholds" class="threshold-list">
@for (threshold of thresholds.controls; track $index; let i = $index) {
@for (threshold of thresholds.controls; let i = $index; track i) {
<div class="threshold-item" [formGroupName]="i">
<div class="threshold-item__header">
<span class="threshold-item__level">{{ getThresholdLevel(i) }}% Threshold</span>

View File

@@ -118,7 +118,7 @@ import {
</div>
<div formArrayName="signals" class="signal-editor">
@for (signal of signals.controls; track $index; let i = $index) {
@for (signal of signals.controls; let i = $index; track i) {
<div class="signal-row" [formGroupName]="i">
<div class="signal-row__enable">
<label class="toggle-small">
@@ -156,7 +156,7 @@ import {
</div>
<div formArrayName="severityOverrides" class="override-list">
@for (override of severityOverrides.controls; track $index; let i = $index) {
@for (override of severityOverrides.controls; let i = $index; track i) {
<div class="override-card" [formGroupName]="i">
<div class="override-card__header">
<span class="override-card__label">Rule {{ i + 1 }}</span>
@@ -205,7 +205,7 @@ import {
</div>
<div formArrayName="actionOverrides" class="override-list">
@for (override of actionOverrides.controls; track $index; let i = $index) {
@for (override of actionOverrides.controls; let i = $index; track i) {
<div class="override-card" [formGroupName]="i">
<div class="override-card__header">
<span class="override-card__label">Rule {{ i + 1 }}</span>

View File

@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, FormArray } from '@angular/forms';
import { FormBuilder, FormGroup, ReactiveFormsModule, FormsModule, Validators, FormArray } from '@angular/forms';
import { finalize } from 'rxjs/operators';
import {
@@ -23,7 +23,7 @@ import {
@Component({
selector: 'app-sealed-mode-control',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, ReactiveFormsModule, FormsModule],
providers: [{ provide: POLICY_GOVERNANCE_API, useClass: MockPolicyGovernanceApi }],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `

Some files were not shown because too many files have changed in this diff Show More