release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '**',
|
||||
|
||||
390
src/Web/StellaOps.Web/src/app/core/api/approval.client.ts
Normal file
390
src/Web/StellaOps.Web/src/app/core/api/approval.client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
186
src/Web/StellaOps.Web/src/app/core/api/approval.models.ts
Normal file
186
src/Web/StellaOps.Web/src/app/core/api/approval.models.ts
Normal 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`;
|
||||
}
|
||||
333
src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts
Normal file
333
src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
185
src/Web/StellaOps.Web/src/app/core/api/deployment.models.ts
Normal file
185
src/Web/StellaOps.Web/src/app/core/api/deployment.models.ts
Normal 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);
|
||||
}
|
||||
581
src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts
Normal file
581
src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
290
src/Web/StellaOps.Web/src/app/core/api/policy-gates.models.ts
Normal file
290
src/Web/StellaOps.Web/src/app/core/api/policy-gates.models.ts
Normal 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`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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] || '';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
|
||||
319
src/Web/StellaOps.Web/src/app/core/api/workflow.client.ts
Normal file
319
src/Web/StellaOps.Web/src/app/core/api/workflow.client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
182
src/Web/StellaOps.Web/src/app/core/api/workflow.models.ts
Normal file
182
src/Web/StellaOps.Web/src/app/core/api/workflow.models.ts
Normal 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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -83,7 +83,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches';
|
||||
(click)="loadCoverage()"
|
||||
title="Refresh data"
|
||||
>
|
||||
<span class="btn-icon">@</span>
|
||||
<span class="btn-icon">@</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) + '...';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
24
src/Web/StellaOps.Web/src/app/features/change-trace/index.ts
Normal file
24
src/Web/StellaOps.Web/src/app/features/change-trace/index.ts
Normal 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';
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
158
src/Web/StellaOps.Web/src/app/features/evidence-thread/README.md
Normal file
158
src/Web/StellaOps.Web/src/app/features/evidence-thread/README.md
Normal 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
|
||||
```
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">🔒</span>
|
||||
} @else {
|
||||
<span class="icon-connected">🌐</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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">▶</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>✓ All Gates Passed</span> }
|
||||
@case ('fail') { <span>✗ 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>✓</span> }
|
||||
@case ('fail') { <span>✗</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) ? '▲' : '▼' }}
|
||||
</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) { ✓ } @else { ✗ }
|
||||
</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">🔍</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);
|
||||
}
|
||||
}
|
||||
@@ -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()">↻</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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>✓</span> }
|
||||
@case ('fail') { <span>✗</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">⚠</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">✗</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">✓</span>
|
||||
} @else if (gate.isMissingEvidence) {
|
||||
<span class="icon-missing">?</span>
|
||||
} @else {
|
||||
<span class="icon-fail">✗</span>
|
||||
}
|
||||
</span>
|
||||
<span class="gate-name">{{ gate.gateName }}</span>
|
||||
<span class="gate-type">{{ gate.gateType }}</span>
|
||||
<span class="expand-arrow">{{ isExpanded(gate.gateName) ? '▲' : '▼' }}</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">📋</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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
}
|
||||
12
src/Web/StellaOps.Web/src/app/features/policy-gates/index.ts
Normal file
12
src/Web/StellaOps.Web/src/app/features/policy-gates/index.ts
Normal 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';
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user