save checkpoint: save features

This commit is contained in:
master
2026-02-12 10:27:23 +02:00
parent dca86e1248
commit 5bca406787
8837 changed files with 1796879 additions and 5294 deletions

View File

@@ -8,6 +8,7 @@ const { resolveChromeBinary } = require('./scripts/chrome-path');
const port = process.env.PLAYWRIGHT_PORT
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
: 4400;
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `https://127.0.0.1:${port}`;
const chromiumExecutable = resolveChromeBinary(__dirname) as string | null;
@@ -16,14 +17,16 @@ export default defineConfig({
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
baseURL,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
...(chromiumExecutable ? { launchOptions: { executablePath: chromiumExecutable } } : {}),
},
webServer: {
command: 'npm run serve:test',
reuseExistingServer: !process.env.CI,
url: `http://127.0.0.1:${port}`,
url: baseURL,
ignoreHTTPSErrors: true,
stdout: 'ignore',
stderr: 'ignore',
timeout: 120_000,

View File

@@ -1,7 +1,7 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, TitleStrategy } from '@angular/router';
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
import { PageTitleStrategy } from './core/navigation/page-title.strategy';
import { routes } from './app.routes';
@@ -35,6 +35,7 @@ import { AppConfigService } from './core/config/app-config.service';
import { BackendProbeService } from './core/config/backend-probe.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { TenantActivationService } from './core/auth/tenant-activation.service';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
@@ -145,7 +146,7 @@ import {
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideRouter(routes, withComponentInputBinding()),
provideAnimationsAsync(),
{ provide: TitleStrategy, useClass: PageTitleStrategy },
provideHttpClient(withInterceptorsFromDi()),
@@ -193,16 +194,17 @@ export const appConfig: ApplicationConfig = {
useExisting: AuthorityConsoleApiHttpClient,
},
provideAppInitializer(() => {
const initializerFn = ((store: AuthSessionStore) => () => {
const initializerFn = ((store: AuthSessionStore, tenantActivation: TenantActivationService) => () => {
if (typeof window === 'undefined') return;
const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined;
if (!stub) return;
try {
seedAuthSession(store, stub);
tenantActivation.activateTenant(stub.tenant);
} catch (err) {
console.warn('Failed to seed test session', err);
}
})(inject(AuthSessionStore));
})(inject(AuthSessionStore), inject(TenantActivationService));
return initializerFn();
}),
{

View File

@@ -45,6 +45,46 @@ export const routes: Routes = [
),
},
// Release aliases used by legacy redirects and consolidated nav links.
{
path: 'environments',
title: 'Environments',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/release-orchestrator/environments/environments.routes').then(
(m) => m.ENVIRONMENT_ROUTES
),
},
{
path: 'releases',
title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/release-orchestrator/releases/releases.routes').then(
(m) => m.RELEASE_ROUTES
),
},
{
path: 'deployments',
title: 'Deployments',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/release-orchestrator/deployments/deployments.routes').then(
(m) => m.DEPLOYMENT_ROUTES
),
},
// Operations alias tree used by legacy redirects and consolidated sidebar.
{
path: 'operations',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/operations/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
// Security - consolidated security analysis (SEC-005, SEC-006)
{
path: 'security',
@@ -295,6 +335,18 @@ export const routes: Routes = [
(m) => m.ReachabilityCenterComponent
),
},
{
path: 'timeline',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/timeline/timeline.routes').then((m) => m.TIMELINE_ROUTES),
},
{
path: 'evidence-thread',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/evidence-thread/evidence-thread.routes').then((m) => m.EVIDENCE_THREAD_ROUTES),
},
{
path: 'vulnerabilities',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
@@ -303,6 +355,14 @@ export const routes: Routes = [
(m) => m.VulnerabilityExplorerComponent
),
},
{
path: 'vulnerabilities/triage',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/components/vuln-triage-dashboard/vuln-triage-dashboard.component').then(
(m) => m.VulnTriageDashboardComponent
),
},
// Findings container with diff-first default (SPRINT_1227_0005_0001)
{
path: 'findings',
@@ -320,6 +380,14 @@ export const routes: Routes = [
(m) => m.FindingsContainerComponent
),
},
{
path: 'triage',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/components/triage-canvas/triage-canvas.component').then(
(m) => m.TriageCanvasComponent
),
},
{
path: 'triage/artifacts',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
@@ -328,6 +396,14 @@ export const routes: Routes = [
(m) => m.TriageArtifactsComponent
),
},
{
path: 'triage/inbox',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage-inbox/triage-inbox.component').then(
(m) => m.TriageInboxComponent
),
},
{
path: 'triage/artifacts/:artifactId',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
@@ -352,6 +428,62 @@ export const routes: Routes = [
(m) => m.TriageAuditBundleNewComponent
),
},
{
path: 'triage/ai-recommendations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/ai-recommendation-workbench.component').then(
(m) => m.AiRecommendationWorkbenchComponent
),
},
{
path: 'triage/quiet-lane',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/quiet-lane-workbench.component').then(
(m) => m.QuietLaneWorkbenchComponent
),
},
{
path: 'audit/reasons',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/reason-capsule-workbench.component').then(
(m) => m.ReasonCapsuleWorkbenchComponent
),
},
{
path: 'qa/web-recheck',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/qa/web-feature-recheck-workbench.component').then(
(m) => m.WebFeatureRecheckWorkbenchComponent
),
},
{
path: 'qa/sbom-component-detail',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/sbom/pages/component-detail/component-detail.page').then(
(m) => m.ComponentDetailPage
),
},
{
path: 'ops/binary-index',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/binary-index/binary-index-ops.component').then(
(m) => m.BinaryIndexOpsComponent
),
},
{
path: 'settings/determinization-config',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/settings/determinization-config-pane.component').then(
(m) => m.DeterminizationConfigPaneComponent
),
},
{
path: 'compare/:currentId',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
@@ -615,6 +747,39 @@ export const routes: Routes = [
(m) => m.EvidencePackViewerComponent
),
},
// Advisory AI Autofix workbench (strict Tier 2 UI verification surface)
{
path: 'ai/autofix',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/advisory-ai/autofix-workbench.component').then(
(m) => m.AutofixWorkbenchComponent
),
},
{
path: 'aoc/verify',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/aoc/aoc-verification-workbench.component').then(
(m) => m.AocVerificationWorkbenchComponent
),
},
{
path: 'ai/chat',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/advisory-ai/chat/chat.component').then(
(m) => m.ChatComponent
),
},
{
path: 'ai/chips',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/advisory-ai/chip-showcase.component').then(
(m) => m.ChipShowcaseComponent
),
},
// AI Runs (SPRINT_20260109_011_003)
{
path: 'ai-runs',
@@ -659,6 +824,13 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/sbom-diff/sbom-diff.routes').then((m) => m.SBOM_DIFF_ROUTES),
},
// Deploy Diff View (SPRINT_20260125_006_FE_ab_deploy_diff_panel)
{
path: 'deploy/diff',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/deploy-diff/deploy-diff.routes').then((m) => m.DEPLOY_DIFF_ROUTES),
},
// VEX Timeline (SPRINT_0127_0001_FE - FE-PERSONA-03)
{
path: 'vex/timeline',

View File

@@ -147,7 +147,25 @@ export class AuditLogClient {
map((responses) => {
const allEvents = responses
.flatMap((r) => r.items)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.sort((a, b) => {
const timestampDelta =
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
if (timestampDelta !== 0) {
return timestampDelta;
}
const idDelta = a.id.localeCompare(b.id);
if (idDelta !== 0) {
return idDelta;
}
const moduleDelta = a.module.localeCompare(b.module);
if (moduleDelta !== 0) {
return moduleDelta;
}
return a.action.localeCompare(b.action);
})
.slice(0, limit);
return {

View File

@@ -71,7 +71,7 @@ export class ExportCenterHttpClient implements ExportCenterApi {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
const headers = this.buildHeaders({ ...options, traceId });
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
@@ -93,7 +93,7 @@ export class ExportCenterHttpClient implements ExportCenterApi {
return throwError(() => new Error('Unauthorized: missing export:write scope'));
}
let headers = this.buildHeaders(options);
let headers = this.buildHeaders({ ...options, traceId });
if (options.idempotencyKey) {
headers = headers.set('Idempotency-Key', options.idempotencyKey);
}
@@ -111,7 +111,7 @@ export class ExportCenterHttpClient implements ExportCenterApi {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
const headers = this.buildHeaders({ ...options, traceId });
return this.http.get<ExportRunResponse>(
`${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
@@ -126,6 +126,10 @@ export class ExportCenterHttpClient implements ExportCenterApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
return new Observable<ExportRunEvent>((observer) => {
@@ -176,7 +180,7 @@ export class ExportCenterHttpClient implements ExportCenterApi {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
const headers = this.buildHeaders({ ...options, traceId });
return this.http.get<DistributionResponse>(
`${this.baseUrl}/distributions/${encodeURIComponent(distributionId)}`,

View File

@@ -23,7 +23,7 @@ export interface TriageInboxApi {
@Injectable({ providedIn: 'root' })
export class TriageInboxClient implements TriageInboxApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.scannerApiUrl}/v1/triage`;
private readonly baseUrl = `${environment.apiBaseUrl}/v1/triage`;
/**
* Retrieves triage inbox with grouped exploit paths.

View File

@@ -32,6 +32,12 @@ export interface Vulnerability {
readonly reachabilityScore?: number;
/** Reachability status from signals. */
readonly reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown';
/** EPSS exploit probability (0.0-1.0). */
readonly epssScore?: number;
/** Known Exploited Vulnerabilities catalog membership. */
readonly kevListed?: boolean;
/** Number of assets potentially impacted by the vulnerability. */
readonly blastRadiusAssetCount?: number;
}
export interface AffectedComponent {

View File

@@ -63,7 +63,12 @@ export class I18nService {
return key;
}
return params ? this.interpolate(value, params) : value;
if (!params) {
return value;
}
const formatted = this.formatIcu(value, params);
return this.interpolate(formatted, params);
}
/**
@@ -76,7 +81,12 @@ export class I18nService {
return null;
}
return params ? this.interpolate(value, params) : value;
if (!params) {
return value;
}
const formatted = this.formatIcu(value, params);
return this.interpolate(formatted, params);
}
/**
@@ -101,4 +111,47 @@ export class I18nService {
return value !== undefined ? String(value) : match;
});
}
private formatIcu(template: string, params: TranslationParams): string {
return template.replace(
/\{(\w+),\s*(plural|select),\s*([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g,
(_match, key: string, type: string, body: string) => {
const options = this.parseIcuOptions(body);
if (options.size === 0) {
return _match;
}
if (type === 'plural') {
const rawValue = params[key];
const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue);
if (!Number.isFinite(numericValue)) {
return options.get('other') ?? _match;
}
const exact = options.get(`=${numericValue}`);
if (exact !== undefined) {
return exact.replace(/#/g, String(numericValue));
}
const pluralCategory = new Intl.PluralRules(this._locale()).select(numericValue);
const pluralMessage = options.get(pluralCategory) ?? options.get('other') ?? _match;
return pluralMessage.replace(/#/g, String(numericValue));
}
const selectKey = String(params[key] ?? 'other');
return options.get(selectKey) ?? options.get('other') ?? _match;
}
);
}
private parseIcuOptions(body: string): Map<string, string> {
const options = new Map<string, string>();
const optionPattern = /([=\w-]+)\s*\{([^{}]*)\}/g;
let match: RegExpExecArray | null = optionPattern.exec(body);
while (match) {
options.set(match[1], match[2]);
match = optionPattern.exec(body);
}
return options;
}
}

View File

@@ -25,7 +25,7 @@ export class PinnedExplanationService {
pin(item: Omit<PinnedItem, 'id' | 'pinnedAt'>): void {
const newItem: PinnedItem = {
...item,
id: crypto.randomUUID(),
id: this.generatePinnedId(),
pinnedAt: new Date()
};
@@ -221,6 +221,22 @@ export class PinnedExplanationService {
}
private saveToSession(): void {
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(this._items()));
try {
sessionStorage.setItem(this.STORAGE_KEY, JSON.stringify(this._items()));
} catch {
// Ignore storage write failures and keep in-memory state available.
}
}
private generatePinnedId(): string {
try {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
} catch {
// Fall back to generated ID when crypto API is unavailable.
}
return `pin-${Date.now()}-${Math.floor(Math.random() * 1_000_000_000)}`;
}
}

View File

@@ -125,11 +125,25 @@ export class HttpScoringApi implements ScoringApi {
// Mock Data Fixtures
// ============================================================================
function stableUnit(seed: string, salt: string): number {
const value = `${seed}:${salt}`;
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
function stableRange(seed: string, salt: string, min: number, max: number): number {
return min + stableUnit(seed, salt) * (max - min);
}
function generateMockScore(
findingId: string,
baseScore?: number
): EvidenceWeightedScoreResult {
const score = baseScore ?? Math.floor(Math.random() * 100);
const score = baseScore ?? Math.round(stableRange(findingId, 'score', 15, 98));
const bucket: ScoreBucket =
score >= 90
? 'ActNow'
@@ -140,19 +154,41 @@ function generateMockScore(
: 'Watchlist';
const flags: ScoreFlag[] = [];
if (Math.random() > 0.6) flags.push('live-signal');
if (Math.random() > 0.5) flags.push('proven-path');
if (Math.random() > 0.8) flags.push('vendor-na');
if (Math.random() > 0.7) flags.push('speculative');
if (stableUnit(findingId, 'flag-live-signal') > 0.58) flags.push('live-signal');
if (stableUnit(findingId, 'flag-proven-path') > 0.5) flags.push('proven-path');
if (stableUnit(findingId, 'flag-vendor-na') > 0.82) flags.push('vendor-na');
if (stableUnit(findingId, 'flag-speculative') > 0.73) flags.push('speculative');
const rch = Math.random() * 0.3 + 0.5;
const rts = Math.random() * 0.5;
const bkp = Math.random() * 0.3;
const xpl = Math.random() * 0.4 + 0.3;
const src = Math.random() * 0.3 + 0.5;
const mit = Math.random() * 0.3;
const idLower = findingId.toLowerCase();
const hasAnchoredSignal = idLower.includes('anchored');
const hasHardFailSignal = idLower.includes('hard-fail') || idLower.includes('hardfail');
return {
if (hasAnchoredSignal && !flags.includes('anchored')) {
flags.push('anchored');
}
if (hasHardFailSignal && !flags.includes('hard-fail')) {
flags.push('hard-fail');
}
const rch = stableRange(findingId, 'rch', 0.5, 0.8);
const rts = stableRange(findingId, 'rts', 0.0, 0.5);
const bkp = stableRange(findingId, 'bkp', 0.0, 0.3);
const xpl = stableRange(findingId, 'xpl', 0.3, 0.7);
const src = stableRange(findingId, 'src', 0.5, 0.8);
const mit = stableRange(findingId, 'mit', 0.0, 0.3);
const calculatedAt = new Date(
Date.UTC(
2026,
1,
11,
4 + Math.floor(stableRange(findingId, 'hour', 0, 5)),
Math.floor(stableRange(findingId, 'minute', 0, 60)),
Math.floor(stableRange(findingId, 'second', 0, 60))
)
).toISOString();
const result: EvidenceWeightedScoreResult = {
findingId,
score,
bucket,
@@ -174,9 +210,49 @@ function generateMockScore(
runtimeFloor: flags.includes('live-signal'),
},
policyDigest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
calculatedAt: new Date().toISOString(),
cachedUntil: new Date(Date.now() + 3600000).toISOString(),
calculatedAt,
cachedUntil: new Date(new Date(calculatedAt).getTime() + 3600000).toISOString(),
};
if (hasAnchoredSignal || hasHardFailSignal) {
const reductionAmount = Math.max(4, Math.round(stableRange(findingId, 'reduction', 4, 14)));
const originalScore = Math.min(100, score + reductionAmount);
result.reductionProfile = {
mode: hasHardFailSignal ? 'aggressive' : 'standard',
originalScore,
reductionAmount,
reductionFactor: originalScore > 0 ? reductionAmount / originalScore : 0,
contributingEvidence: hasAnchoredSignal
? ['dsse_attestation', 'rekor_inclusion', 'runtime_context']
: ['runtime_context'],
cappedByPolicy: hasHardFailSignal,
maxReductionPercent: hasHardFailSignal ? 25 : 15,
};
}
if (hasHardFailSignal) {
result.isHardFail = true;
result.hardFailStatus = 'critical_reachable';
result.shortCircuitReason = 'hard_fail_critical_reachable';
} else if (hasAnchoredSignal) {
result.shortCircuitReason = 'anchor_verified';
}
if (hasAnchoredSignal) {
result.proofAnchor = {
anchored: true,
dsseDigest:
'sha256:deadc0de111122223333444455556666777788889999aaaabbbbccccddddeeee',
rekorLogIndex: 4801122,
rekorEntryId: 'rekor-entry-attested-ui-01',
rekorLogId: 'rekor-log-attested-ui',
attestationUri: '/api/v1/attestor/records/attested-ui-01',
verifiedAt: calculatedAt,
verificationStatus: 'verified',
};
}
return result;
}
const mockPolicy: ScoringPolicy = {

View File

@@ -58,7 +58,7 @@ export class ViewModeService {
constructor() {
effect(() => {
const mode = this._mode();
localStorage.setItem(STORAGE_KEY, mode);
this.persistToStorage(mode);
});
}
@@ -75,10 +75,23 @@ export class ViewModeService {
}
private loadFromStorage(): ViewMode {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'operator' || stored === 'auditor') {
return stored;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'operator' || stored === 'auditor') {
return stored;
}
} catch {
// Keep default mode when storage is unavailable.
}
return 'operator';
}
private persistToStorage(mode: ViewMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// Ignore storage write failures to keep mode switching functional.
}
}
}

View File

@@ -568,10 +568,10 @@ export class EscalationConfigComponent implements OnInit {
const nextLevel = this.levelsArray.length + 1;
this.levelsArray.push(this.fb.group({
level: [existing?.level ?? nextLevel],
delayMinutes: [existing?.delayMinutes ?? (nextLevel === 1 ? 0 : 15)],
channels: [existing?.channels ? [...existing.channels] : []],
delayMinutes: [existing?.delayMinutes ?? (nextLevel === 1 ? 0 : 15), [Validators.required, Validators.min(0)]],
channels: [existing?.channels ? [...existing.channels] : [], [Validators.required]],
notifyOnAck: [existing?.notifyOnAck ?? false],
repeatCount: [existing?.repeatCount ?? 1],
repeatCount: [existing?.repeatCount ?? 1, [Validators.required, Validators.min(1), Validators.max(10)]],
}));
}
@@ -660,7 +660,29 @@ export class EscalationConfigComponent implements OnInit {
}
async onSubmit(): Promise<void> {
if (!this.form.valid) return;
if (!this.form.valid) {
const hasEmptyLevelChannels = this.levelsArray.controls.some((control) => {
const channels = control.get('channels')?.value as string[] | null | undefined;
return !Array.isArray(channels) || channels.length === 0;
});
this.error.set(
hasEmptyLevelChannels
? 'Each escalation level must include at least one channel.'
: 'Review form errors before saving escalation policy.'
);
this.form.markAllAsTouched();
return;
}
const hasEmptyLevelChannels = this.levelsArray.controls.some((control) => {
const channels = control.get('channels')?.value as string[] | null | undefined;
return !Array.isArray(channels) || channels.length === 0;
});
if (hasEmptyLevelChannels) {
this.error.set('Each escalation level must include at least one channel.');
return;
}
this.saving.set(true);
this.error.set(null);
@@ -674,10 +696,10 @@ export class EscalationConfigComponent implements OnInit {
enabled: formValue.enabled,
levels: formValue.levels.map((l: Record<string, unknown>, i: number) => ({
level: i + 1,
delayMinutes: l['delayMinutes'],
channels: l['channels'],
notifyOnAck: l['notifyOnAck'],
repeatCount: l['repeatCount'] || 1,
delayMinutes: Math.max(0, Number(l['delayMinutes'] ?? 0)),
channels: Array.from(new Set(((l['channels'] as string[] | undefined) ?? []).filter((channelId) => !!channelId))),
notifyOnAck: Boolean(l['notifyOnAck']),
repeatCount: Math.max(1, Number(l['repeatCount'] ?? 1)),
})),
};

View File

@@ -0,0 +1,276 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import type { PullRequestInfo, RemediationPlan } from '../../core/api/advisory-ai.models';
import { AutofixButtonComponent, type GeneratePlanRequestEvent } from './autofix-button.component';
import { PrTrackerComponent } from './pr-tracker.component';
import { RemediationPlanPreviewComponent } from './remediation-plan-preview.component';
type TrackerPullRequest = PullRequestInfo & {
repository: string;
scmProvider: string;
ciChecks: Array<{ name: string; status: 'pending' | 'running' | 'success' | 'failure' | 'skipped'; url?: string }>;
reviewStatus: {
approved: number;
required: number;
reviewers: Array<{ id: string; name: string; decision: 'approved' | 'changes_requested' | null }>;
};
};
@Component({
selector: 'stellaops-autofix-workbench',
standalone: true,
imports: [
CommonModule,
AutofixButtonComponent,
RemediationPlanPreviewComponent,
PrTrackerComponent,
],
template: `
<section class="autofix-workbench" aria-label="AI autofix workbench">
<header>
<h1>AI Autofix Workbench</h1>
<p class="subtitle">Interactive verification route for remediation plan and PR tracker workflow.</p>
<p class="status-line" role="status">{{ statusMessage() }}</p>
</header>
<div class="actions">
<stellaops-autofix-button
[findingId]="'finding-autofix-001'"
[vulnerabilityId]="'CVE-2026-1000'"
[componentPurl]="'pkg:npm/lodash@4.17.21'"
[artifactDigest]="'sha256:autofix-workbench'"
[scope]="'service'"
[scopeId]="'svc-payments'"
[scmProvider]="'github'"
[disabled]="busy()"
[hasPlan]="plan() !== null"
[showStrategyDropdown]="true"
(generatePlan)="onGeneratePlan($event)"
(viewPlan)="onViewPlan()"
/>
</div>
<div class="workspace-grid">
<div class="panel">
<stellaops-remediation-plan-preview
[plan]="plan()"
[loading]="busy()"
[error]="errorMessage()"
(retry)="onRetry()"
(dismiss)="onDismiss()"
(editPlan)="onEditPlan()"
(approvePlan)="onApprovePlan()"
(createPr)="onCreatePr()"
/>
</div>
@if (pullRequest()) {
<div class="panel">
<stellaops-pr-tracker
[pullRequest]="pullRequest()!"
(merge)="onMerge()"
(close)="onClosePr()"
/>
</div>
}
</div>
</section>
`,
styles: [`
.autofix-workbench {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: #4b5563;
}
.status-line {
margin: 0.5rem 0 0;
font-size: 0.875rem;
font-weight: 600;
color: #1d4ed8;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.workspace-grid {
display: grid;
gap: 1rem;
}
.panel {
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 1rem;
}
`],
})
export class AutofixWorkbenchComponent {
readonly plan = signal<RemediationPlan | null>(null);
readonly pullRequest = signal<TrackerPullRequest | null>(null);
readonly busy = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly statusMessage = signal('Ready. Use Auto-fix to generate a remediation plan.');
onGeneratePlan(event: GeneratePlanRequestEvent): void {
this.busy.set(true);
this.errorMessage.set(null);
this.plan.set({
planId: 'plan-autofix-001',
strategy: event.preferredStrategy ?? 'upgrade',
status: 'draft',
summary: {
line1: 'Upgrade lodash to a patched release.',
line2: 'Eliminates reachable vulnerability path for CVE-2026-1000.',
line3: 'Create a pull request and run CI before promotion.',
},
estimatedImpact: {
breakingChanges: 0,
filesAffected: 2,
dependenciesAffected: 1,
testCoverage: 87,
riskScore: 3,
},
steps: [
{
stepId: 'step-1',
type: 'upgrade',
title: 'Bump vulnerable dependency',
description: 'Update package manifest and lockfile to a patched lodash version.',
filePath: 'package.json',
command: 'npm install lodash@^4.17.22',
diff: '- lodash@4.17.21\n+ lodash@4.17.22',
riskLevel: 'low',
},
{
stepId: 'step-2',
type: 'config',
title: 'Run verification test suite',
description: 'Validate that regression coverage remains green after the dependency update.',
command: 'npm test -- --runInBand',
riskLevel: 'low',
},
],
createdAt: '2026-02-11T08:00:00Z',
updatedAt: '2026-02-11T08:00:00Z',
});
this.pullRequest.set(null);
this.busy.set(false);
event.onComplete();
this.statusMessage.set('Plan generated. Review steps and create a pull request.');
}
onViewPlan(): void {
this.statusMessage.set('Existing plan is ready for review.');
}
onRetry(): void {
this.errorMessage.set(null);
this.statusMessage.set('Retry requested for remediation plan generation.');
}
onDismiss(): void {
this.plan.set(null);
this.pullRequest.set(null);
this.statusMessage.set('Plan dismissed.');
}
onEditPlan(): void {
this.statusMessage.set('Plan edit requested.');
}
onApprovePlan(): void {
const current = this.plan();
if (!current) {
return;
}
this.plan.set({
...current,
status: 'validated',
updatedAt: '2026-02-11T08:05:00Z',
});
this.statusMessage.set('Plan approved for PR creation.');
}
onCreatePr(): void {
this.pullRequest.set({
prId: 'pr-214',
prNumber: 214,
title: 'chore: remediate CVE-2026-1000',
status: 'open',
prUrl: 'https://scm.local/org/repo/pull/214',
sourceBranch: 'remediation/cve-2026-1000',
targetBranch: 'main',
createdAt: '2026-02-11T08:10:00Z',
updatedAt: '2026-02-11T08:10:00Z',
authorUsername: 'stella-bot',
repository: 'org/repo',
scmProvider: 'github',
ciChecks: [
{ name: 'build', status: 'success' },
{ name: 'unit-tests', status: 'success' },
{ name: 'security-scan', status: 'success' },
],
reviewStatus: {
approved: 2,
required: 2,
reviewers: [
{ id: 'u1', name: 'alice', decision: 'approved' },
{ id: 'u2', name: 'bob', decision: 'approved' },
],
},
});
this.statusMessage.set('Pull request created and ready to merge.');
}
onMerge(): void {
const current = this.pullRequest();
if (!current) {
return;
}
this.pullRequest.set({
...current,
status: 'merged',
updatedAt: '2026-02-11T08:15:00Z',
mergedAt: '2026-02-11T08:15:00Z',
});
this.statusMessage.set('Pull request merged.');
}
onClosePr(): void {
const current = this.pullRequest();
if (!current) {
return;
}
this.pullRequest.set({
...current,
status: 'closed',
updatedAt: '2026-02-11T08:15:00Z',
closedAt: '2026-02-11T08:15:00Z',
});
this.statusMessage.set('Pull request closed.');
}
}

View File

@@ -646,7 +646,8 @@ export class ChatComponent implements OnInit, OnDestroy {
onActionExecute(action: ProposedAction): void {
const conversation = this.conversation();
const lastTurn = conversation?.turns.findLast((t) => t.role === 'assistant');
const turns = conversation?.turns ?? [];
const lastTurn = [...turns].reverse().find((t: ConversationTurn) => t.role === 'assistant');
if (lastTurn) {
this.actionExecute.emit({ action, turnId: lastTurn.turnId });
}

View File

@@ -0,0 +1,195 @@
import { Component, signal } from '@angular/core';
import {
AiChipRowComponent,
type FindingAiInsight,
} from '../findings/ai-chip-row.component';
import { AiChipComponent } from '../../shared/components/ai/ai-chip.component';
import {
AiSummaryComponent,
type AiSummaryCitation,
type AiSummaryExpanded,
} from '../../shared/components/ai/ai-summary.component';
@Component({
selector: 'stellaops-ai-chip-showcase',
standalone: true,
imports: [AiChipComponent, AiSummaryComponent, AiChipRowComponent],
template: `
<section class="chip-showcase" aria-label="AI chip showcase">
<header>
<h1>AI Chip Components</h1>
<p class="status-line" role="status">{{ statusMessage() }}</p>
</header>
<section class="panel">
<h2>Action Chips</h2>
<div class="chip-strip">
<stella-ai-chip
[label]="'Explain path'"
[icon]="'i'"
[variant]="'action'"
[showChevron]="true"
(clicked)="onExplainChip()"
/>
<stella-ai-chip
[label]="'Needs evidence'"
[icon]="'!'"
[variant]="'warning'"
[pressed]="warningPressed()"
(clicked)="onEvidenceChip()"
/>
</div>
</section>
<section class="panel">
<h2>Three-Line Summary</h2>
<stella-ai-summary
[line1]="'Policy context changed after dependency update.'"
[line2]="'Reachable exploit path remains in production workload.'"
[line3]="'Review evidence and prepare remediation PR.'"
[authority]="'evidence-backed'"
[hasMore]="true"
[expandLabel]="'evidence trail'"
[expandedContent]="expandedContent"
[modelLabel]="'stella-ai-v2'"
(citationClick)="onCitationClick($event)"
/>
</section>
<section class="panel">
<h2>Finding Row Integration</h2>
<div class="chip-row-host">
<stella-ai-chip-row
[findingId]="'finding-42'"
[policyState]="'BLOCK'"
[severity]="'high'"
[insight]="rowInsight"
(chipClick)="onRowChipClick($event.openPanel)"
/>
</div>
</section>
<p class="event-line">{{ lastEvent() }}</p>
</section>
`,
styles: [`
.chip-showcase {
max-width: 980px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.status-line {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #1d4ed8;
font-weight: 600;
}
.panel {
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 1rem;
display: grid;
gap: 0.75rem;
}
.chip-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip-row-host {
min-height: 3.25rem;
display: flex;
align-items: center;
}
.event-line {
margin: 0;
font-size: 0.875rem;
color: #374151;
font-weight: 500;
}
`],
})
export class ChipShowcaseComponent {
readonly statusMessage = signal('Ready. Trigger chips to validate interactions.');
readonly warningPressed = signal(false);
readonly lastEvent = signal('No chip interaction recorded yet.');
readonly expandedContent: AiSummaryExpanded = {
fullExplanation:
'Runtime trace and policy context show a reachable exploit path from ingress to vulnerable package usage.',
citations: [
{
claim: 'Reachability trace confirms runtime path.',
evidenceId: 'ev-reach-001',
evidenceType: 'reachability',
verified: true,
},
{
claim: 'SBOM contains vulnerable lodash package version.',
evidenceId: 'ev-sbom-001',
evidenceType: 'sbom',
verified: true,
},
],
alternatives: [
'Apply temporary VEX suppression with expiry.',
'Quarantine exposed endpoint until remediation lands.',
],
};
readonly rowInsight: FindingAiInsight = {
hasReachablePath: true,
reachabilityHops: 3,
fixState: 'available',
fixPrReady: true,
evidenceNeeded: 'runtime',
evidenceDescription: 'Need runtime trace from production host.',
exploitability: 'confirmed',
summary: {
line1: 'Reachable path confirmed in production telemetry.',
line2: 'Exploit preconditions match exposed gateway route.',
line3: 'Promote prepared fix PR after policy check.',
},
};
onExplainChip(): void {
this.statusMessage.set('Explain path action triggered.');
this.lastEvent.set('Clicked chip: explain path');
}
onEvidenceChip(): void {
const next = !this.warningPressed();
this.warningPressed.set(next);
this.statusMessage.set(next ? 'Needs evidence chip pressed.' : 'Needs evidence chip released.');
this.lastEvent.set(next ? 'Clicked chip: needs evidence (pressed)' : 'Clicked chip: needs evidence (released)');
}
onCitationClick(citation: AiSummaryCitation): void {
this.statusMessage.set(`Citation selected: ${citation.evidenceId}`);
this.lastEvent.set(`Citation opened: ${citation.claim}`);
}
onRowChipClick(panel: string): void {
this.statusMessage.set(`Finding row chip selected: ${panel}`);
this.lastEvent.set(`Row interaction panel: ${panel}`);
}
}

View File

@@ -18,6 +18,13 @@ import {
AnalyticsVulnerabilityTrendPoint,
PlatformListResponse,
} from '../../core/api/analytics.models';
import {
MetricsDashboardComponent,
MetricsFindingData,
ApprovalResult,
FindingSeverity,
TimeRange,
} from '../../shared/components/metrics-dashboard.component';
import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.component';
interface AggregatedVulnTrend {
@@ -60,7 +67,7 @@ const SEVERITY_RANK: Record<string, number> = {
@Component({
selector: 'app-sbom-lake-page',
imports: [CommonModule, SkeletonComponent],
imports: [CommonModule, SkeletonComponent, MetricsDashboardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="sbom-lake">
@@ -120,12 +127,19 @@ const SEVERITY_RANK: Record<string, number> = {
</section>
@if (error()) {
<div class="error-banner">
<div class="error-banner" role="alert">
<span>{{ error() }}</span>
<button type="button" class="btn btn--ghost" (click)="clearError()">Dismiss</button>
</div>
}
<stella-metrics-dashboard
[findings]="metricsFindings()"
[approvals]="metricsApprovals()"
[timeRange]="toMetricsTimeRange(days())"
[loading]="loading()"
/>
<section class="panel-grid">
<div class="panel">
<header class="panel-header">
@@ -501,7 +515,12 @@ const SEVERITY_RANK: Record<string, number> = {
</div>
</section>
@if (isEmpty()) {
@if (isErrorEmpty()) {
<div class="empty-callout empty-callout--error" role="alert">
<h3>Unable to load SBOM analytics</h3>
<p>Analytics endpoints are unavailable. Verify backend health and retry refresh.</p>
</div>
} @else if (isEmpty()) {
<div class="empty-callout">
<h3>No analytics data available</h3>
<p>SBOM Lake data has not been loaded yet or analytics storage is offline.</p>
@@ -874,6 +893,12 @@ const SEVERITY_RANK: Record<string, number> = {
margin: 0 0 0.5rem;
color: var(--text-color);
}
.empty-callout--error {
border-style: solid;
border-color: var(--red-200);
background: var(--red-50);
color: var(--red-700);
}
@media (max-width: 900px) {
.page-header {
@@ -887,6 +912,7 @@ export class SbomLakePageComponent {
private readonly analytics = inject(AnalyticsHttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private refreshRequestId = 0;
readonly environmentOptions = ENVIRONMENT_OPTIONS;
readonly severityOptions = SEVERITY_OPTIONS;
@@ -998,6 +1024,65 @@ export class SbomLakePageComponent {
this.vulnTrends().length === 0 &&
this.componentTrends().length === 0
);
readonly isErrorEmpty = computed(() => Boolean(this.error()) && this.isEmpty());
readonly metricsFindings = computed<readonly MetricsFindingData[]>(() => {
const exposuresByVuln = new Map(
this.vulnerabilities().map((entry) => [entry.vulnId, entry] as const)
);
const findingsFromBacklog: MetricsFindingData[] = this.backlog().map((item) => {
const exposure = exposuresByVuln.get(item.vulnId);
const hasHumanApproval = Boolean(item.fixedVersion);
const hasVex = (exposure?.vexMitigated || 0) > 0;
const hasPolicyDecision = Boolean(exposure?.fixAvailable);
return {
findingId: this.backlogKey(item),
cveId: item.vulnId,
componentName: item.component,
severity: this.toMetricsSeverity(item.severity),
status: hasHumanApproval ? 'approved' : 'pending',
chainStatus: hasHumanApproval ? 'complete' : hasPolicyDecision || hasVex ? 'partial' : 'empty',
hasSbom: true,
hasVex,
hasPolicyDecision,
hasHumanApproval,
};
});
const backlogVulnIds = new Set(findingsFromBacklog.map((finding) => finding.cveId));
const findingsFromExposure: MetricsFindingData[] = this.vulnerabilities()
.filter((entry) => !backlogVulnIds.has(entry.vulnId))
.slice(0, 25)
.map((entry, index) => ({
findingId: `exposure:${entry.vulnId}:${index}`,
cveId: entry.vulnId,
componentName: 'Multiple components',
severity: this.toMetricsSeverity(entry.severity),
status: entry.fixAvailable ? 'pending' : 'blocked',
chainStatus: entry.fixAvailable || entry.vexMitigated > 0 ? 'partial' : 'empty',
hasSbom: entry.effectiveComponentCount > 0,
hasVex: entry.vexMitigated > 0,
hasPolicyDecision: entry.fixAvailable || entry.vexMitigated > 0,
hasHumanApproval: false,
}));
return [...findingsFromBacklog, ...findingsFromExposure];
});
readonly metricsApprovals = computed<readonly ApprovalResult[]>(() => {
const approvedAt = this.dataAsOf() || '1970-01-01T00:00:00Z';
return this.backlog()
.filter((item) => Boolean(item.fixedVersion))
.map((item) => ({
findingId: this.backlogKey(item),
digestRef: item.vulnId,
approvedAt,
approver: item.service || 'system',
expiresAt: approvedAt,
}));
});
constructor() {
this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => {
@@ -1014,6 +1099,7 @@ export class SbomLakePageComponent {
}
refresh(): void {
const requestId = ++this.refreshRequestId;
const env = this.environment() || null;
const severity = this.minSeverity() || null;
const days = this.days();
@@ -1031,6 +1117,9 @@ export class SbomLakePageComponent {
componentTrends: this.analytics.getComponentTrends(env, days),
}).subscribe({
next: (result) => {
if (requestId !== this.refreshRequestId) {
return;
}
this.suppliers.set(result.suppliers.items ?? []);
this.licenses.set(result.licenses.items ?? []);
this.vulnerabilities.set(result.vulnerabilities.items ?? []);
@@ -1042,6 +1131,9 @@ export class SbomLakePageComponent {
this.loading.set(false);
},
error: (err: Error) => {
if (requestId !== this.refreshRequestId) {
return;
}
this.error.set(err?.message || 'Failed to load analytics data.');
this.loading.set(false);
},
@@ -1121,11 +1213,28 @@ export class SbomLakePageComponent {
.join(' ');
}
toMetricsTimeRange(days: number): TimeRange {
if (days <= 1) return '1d';
if (days <= 7) return '7d';
if (days <= 30) return '30d';
if (days <= 90) return '90d';
return 'all';
}
normalizeSeverity(severity: string): string {
const normalized = (severity || '').toLowerCase();
return SEVERITY_OPTIONS.includes(normalized) ? normalized : 'unknown';
}
private toMetricsSeverity(severity: string): FindingSeverity {
const normalized = this.normalizeSeverity(severity);
if (normalized === 'critical') return 'critical';
if (normalized === 'high') return 'high';
if (normalized === 'medium') return 'medium';
if (normalized === 'low') return 'low';
return 'informational';
}
licenseLabel(entry: AnalyticsLicenseDistribution): string {
return entry.licenseConcluded?.trim() || entry.licenseCategory || 'Unknown';
}
@@ -1302,7 +1411,11 @@ export class SbomLakePageComponent {
if (totalDiff !== 0) return totalDiff;
const rankDiff = this.getSeverityRank(a.maxSeverity) - this.getSeverityRank(b.maxSeverity);
if (rankDiff !== 0) return rankDiff;
return a.component.localeCompare(b.component);
const componentDiff = a.component.localeCompare(b.component);
if (componentDiff !== 0) return componentDiff;
const versionDiff = (a.version || '').localeCompare(b.version || '');
if (versionDiff !== 0) return versionDiff;
return a.services.join(',').localeCompare(b.services.join(','));
});
}

View File

@@ -0,0 +1,197 @@
import { Component, computed, signal } from '@angular/core';
import type {
AocDocumentView,
AocVerificationResult,
AocViolationDetail,
AocViolationGroup,
} from '../../core/api/aoc.models';
import { VerifyActionComponent } from './verify-action.component';
import { ViolationDrilldownComponent } from './violation-drilldown.component';
@Component({
selector: 'stellaops-aoc-verification-workbench',
standalone: true,
imports: [VerifyActionComponent, ViolationDrilldownComponent],
template: `
<section class="aoc-verification-workbench" aria-label="AOC verification workbench">
<header>
<h1>AOC Verification Workbench</h1>
<p class="status-line" role="status">{{ statusLine() }}</p>
@if (selectedViolationSummary()) {
<p class="selection-line">{{ selectedViolationSummary() }}</p>
}
</header>
<app-verify-action
[tenantId]="'tenant-a'"
[windowHours]="24"
[limit]="5000"
(verified)="onVerified($event)"
(selectViolation)="onSelectViolation($event)"
/>
@if (violationGroups().length > 0) {
<app-violation-drilldown
[violationGroups]="violationGroups()"
[documentViews]="documentViews()"
(selectDocument)="onSelectDocument($event)"
(viewRawDocument)="onViewRawDocument($event)"
/>
}
<p class="event-line">{{ lastEvent() }}</p>
</section>
`,
styles: [`
.aoc-verification-workbench {
max-width: 1080px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.status-line {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #1d4ed8;
font-weight: 600;
}
.selection-line {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #0f766e;
font-weight: 600;
}
.event-line {
margin: 0;
font-size: 0.875rem;
color: #374151;
font-weight: 500;
}
`],
})
export class AocVerificationWorkbenchComponent {
readonly violationGroups = signal<AocViolationGroup[]>([]);
readonly documentViews = signal<AocDocumentView[]>([]);
readonly statusLine = signal('Ready. Run tenant verification, then inspect violations and documents.');
readonly lastEvent = signal('No drilldown interactions captured yet.');
readonly selectedViolation = signal<AocViolationDetail | null>(null);
readonly selectedViolationSummary = computed(() => {
const violation = this.selectedViolation();
if (!violation) {
return null;
}
return `Selected violation ${violation.violationCode} on ${violation.documentId}.`;
});
onVerified(result: AocVerificationResult): void {
this.violationGroups.set(this.toViolationGroups(result.violations));
this.documentViews.set(this.toDocumentViews(result.violations));
this.statusLine.set(
`Verification ${result.status}: ${result.passedCount}/${result.checkedCount} documents passed.`
);
this.lastEvent.set(`Verification completed: ${result.verificationId}`);
this.selectedViolation.set(null);
}
onSelectViolation(violation: AocViolationDetail): void {
this.selectedViolation.set(violation);
this.lastEvent.set(`Violation selected: ${violation.violationCode} (${violation.documentId})`);
}
onSelectDocument(documentId: string): void {
this.lastEvent.set(`Document selected: ${documentId}`);
}
onViewRawDocument(documentId: string): void {
this.lastEvent.set(`Raw document requested: ${documentId}`);
}
private toViolationGroups(violations: AocViolationDetail[]): AocViolationGroup[] {
const groups = new Map<string, AocViolationGroup>();
for (const violation of violations) {
const existing = groups.get(violation.violationCode);
if (existing) {
existing.violations.push(violation);
existing.affectedDocuments = new Set(existing.violations.map((entry) => entry.documentId)).size;
continue;
}
groups.set(violation.violationCode, {
code: violation.violationCode,
description: `Policy violation ${violation.violationCode}`,
severity: this.getSeverity(violation.violationCode),
violations: [violation],
affectedDocuments: 1,
remediation: 'Inspect provenance and repair missing or mismatched document fields.',
});
}
return Array.from(groups.values());
}
private toDocumentViews(violations: AocViolationDetail[]): AocDocumentView[] {
const documents = new Map<string, AocDocumentView>();
for (const violation of violations) {
const existing = documents.get(violation.documentId);
if (existing) {
existing.violations.push(violation);
if (violation.field && !existing.highlightedFields.includes(violation.field)) {
existing.highlightedFields.push(violation.field);
}
continue;
}
documents.set(violation.documentId, {
documentId: violation.documentId,
documentType: 'advisory',
violations: [violation],
provenance: violation.provenance ?? {
sourceId: 'unknown',
ingestedAt: new Date('2026-02-10T00:00:00Z').toISOString(),
digest: 'sha256:unknown',
sourceType: 'api',
},
rawContent: {
verification: {
violationCode: violation.violationCode,
expected: violation.expected ?? null,
actual: violation.actual ?? null,
},
},
highlightedFields: violation.field ? [violation.field] : [],
});
}
return Array.from(documents.values());
}
private getSeverity(violationCode: string): 'critical' | 'high' | 'medium' | 'low' {
if (violationCode.includes('CRIT')) {
return 'critical';
}
if (violationCode.includes('PROV') || violationCode.includes('DIGEST')) {
return 'high';
}
if (violationCode.includes('SCHEMA')) {
return 'medium';
}
return 'low';
}
}

View File

@@ -39,7 +39,7 @@ interface ApprovalRequest {
<!-- Status Filter -->
<div class="status-filter">
@for (filter of statusFilters; track filter.id) {
@for (filter of statusFilters(); track filter.id) {
<button
type="button"
class="status-filter__btn"
@@ -219,13 +219,6 @@ interface ApprovalRequest {
export class ApprovalsInboxPageComponent {
activeFilter = signal('pending');
statusFilters = [
{ id: 'pending', label: 'Pending', count: 3 },
{ id: 'approved', label: 'Approved', count: 12 },
{ id: 'rejected', label: 'Rejected', count: 2 },
{ id: 'all', label: 'All', count: 17 },
];
approvals = signal<ApprovalRequest[]>([
{
id: 'APPR-2026-045',
@@ -281,6 +274,20 @@ export class ApprovalsInboxPageComponent {
},
]);
statusFilters = computed(() => {
const approvals = this.approvals();
const pending = approvals.filter((approval) => approval.status === 'pending').length;
const approved = approvals.filter((approval) => approval.status === 'approved').length;
const rejected = approvals.filter((approval) => approval.status === 'rejected').length;
return [
{ id: 'pending', label: 'Pending', count: pending },
{ id: 'approved', label: 'Approved', count: approved },
{ id: 'rejected', label: 'Rejected', count: rejected },
{ id: 'all', label: 'All', count: approvals.length },
];
});
filteredApprovals = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.approvals();

View File

@@ -18,6 +18,9 @@ import {
import { FormsModule } from '@angular/forms';
const MAX_EVIDENCE_FILE_BYTES = 5 * 1024 * 1024;
const SUPPORTED_EVIDENCE_EXTENSIONS = ['.pdf', '.doc', '.docx', '.txt', '.md'];
export interface ExceptionRequest {
gateId: string;
gateName: string;
@@ -181,7 +184,12 @@ export interface GateContext {
<!-- Supporting Evidence -->
<div class="form-group">
<label class="form-label" for="evidence">Supporting Evidence (optional)</label>
<div class="file-input">
<div
class="file-input"
[class.file-input--dragover]="draggingFile()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onFileDrop($event)">
<input
id="evidence"
type="file"
@@ -531,6 +539,11 @@ export interface GateContext {
background: var(--color-brand-50);
}
.file-input.file-input--dragover .file-input__label {
border-color: var(--color-brand-500);
background: var(--color-brand-100);
}
.file-input__placeholder {
font-size: 0.875rem;
color: var(--color-text-secondary);
@@ -672,6 +685,7 @@ export class RequestExceptionModalComponent {
readonly justification = signal('');
readonly riskAcknowledged = signal(false);
readonly selectedFile = signal<File | null>(null);
readonly draggingFile = signal(false);
readonly submitting = signal(false);
// Validation
@@ -713,19 +727,32 @@ export class RequestExceptionModalComponent {
onFileSelect(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
this.processSelectedFile(file);
}
if (file) {
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be under 5MB');
return;
}
this.selectedFile.set(file);
}
onDragOver(event: DragEvent): void {
event.preventDefault();
this.draggingFile.set(true);
}
onDragLeave(event: DragEvent): void {
event.preventDefault();
this.draggingFile.set(false);
}
onFileDrop(event: DragEvent): void {
event.preventDefault();
this.draggingFile.set(false);
const file = event.dataTransfer?.files?.[0];
this.processSelectedFile(file);
}
removeFile(): void {
this.selectedFile.set(null);
const input = document.getElementById('evidence') as HTMLInputElement | null;
if (input) {
input.value = '';
}
}
onSubmit(): void {
@@ -767,6 +794,30 @@ export class RequestExceptionModalComponent {
this.condition.set('');
this.justification.set('');
this.riskAcknowledged.set(false);
this.draggingFile.set(false);
this.selectedFile.set(null);
}
private processSelectedFile(file?: File): void {
if (!file) {
return;
}
if (file.size > MAX_EVIDENCE_FILE_BYTES) {
alert('File size must be under 5MB');
return;
}
if (!this.isSupportedEvidenceExtension(file.name)) {
alert('Only PDF, DOC, DOCX, TXT, or MD files are supported');
return;
}
this.selectedFile.set(file);
}
private isSupportedEvidenceExtension(fileName: string): boolean {
const normalized = fileName.toLowerCase();
return SUPPORTED_EVIDENCE_EXTENSIONS.some((extension) => normalized.endsWith(extension));
}
}

View File

@@ -206,16 +206,15 @@ export class AuditLogDashboardComponent implements OnInit {
this.auditClient.getStatsSummary(sevenDaysAgo).subscribe((stats) => {
this.stats.set(stats);
this.moduleStats.set(
Object.entries(stats.byModule || {}).map(([module, count]) => ({
const moduleEntries = Object.entries(stats.byModule || {}).map(([module, count]) => ({
module: module as AuditModule,
count: count as number,
}))
);
}));
this.moduleStats.set(this.sortModuleStatsDeterministically(moduleEntries));
});
this.auditClient.getEvents(undefined, undefined, 10).subscribe((res) => {
this.recentEvents.set(res.items);
this.recentEvents.set(this.sortEventsDeterministically(res.items));
});
this.auditClient.getAnomalyAlerts(false, 5).subscribe((alerts) => {
@@ -253,4 +252,38 @@ export class AuditLogDashboardComponent implements OnInit {
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private sortModuleStatsDeterministically(
entries: readonly { module: AuditModule; count: number }[]
): Array<{ module: AuditModule; count: number }> {
return [...entries].sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
return a.module.localeCompare(b.module);
});
}
private sortEventsDeterministically(events: readonly AuditEvent[]): AuditEvent[] {
return [...events].sort((a, b) => {
const timestampDelta =
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
if (timestampDelta !== 0) {
return timestampDelta;
}
const idDelta = a.id.localeCompare(b.id);
if (idDelta !== 0) {
return idDelta;
}
const moduleDelta = a.module.localeCompare(b.module);
if (moduleDelta !== 0) {
return moduleDelta;
}
return a.action.localeCompare(b.action);
});
}
}

View File

@@ -1,5 +1,5 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -355,7 +355,7 @@ export class AuditLogTableComponent implements OnInit {
const filters = this.buildFilters();
this.auditClient.getEvents(filters, this.cursor() || undefined, 50).subscribe({
next: (res) => {
this.events.set(res.items);
this.events.set(this.sortEventsDeterministically(res.items));
this.hasMore.set(res.hasMore);
this.cursor.set(res.cursor);
this.loading.set(false);
@@ -457,4 +457,26 @@ export class AuditLogTableComponent implements OnInit {
};
return labels[module] || module;
}
private sortEventsDeterministically(events: readonly AuditEvent[]): AuditEvent[] {
return [...events].sort((a, b) => {
const timestampDelta =
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
if (timestampDelta !== 0) {
return timestampDelta;
}
const idDelta = a.id.localeCompare(b.id);
if (idDelta !== 0) {
return idDelta;
}
const moduleDelta = a.module.localeCompare(b.module);
if (moduleDelta !== 0) {
return moduleDelta;
}
return a.action.localeCompare(b.action);
});
}
}

View File

@@ -19,6 +19,20 @@
</div>
<div class="toolbar-actions">
<div class="role-toggle" role="group" aria-label="Compare personas">
@for (role of roles; track role) {
<button
mat-stroked-button
type="button"
class="role-toggle-button"
[class.active]="currentRole() === role"
[attr.aria-pressed]="currentRole() === role"
(click)="setRole(role)"
>
{{ getRoleLabel(role) }}
</button>
}
</div>
<button mat-icon-button (click)="toggleViewMode()" matTooltip="Toggle view mode">
<mat-icon>{{ viewMode() === 'side-by-side' ? 'view_agenda' : 'view_column' }}</mat-icon>
</button>
@@ -30,14 +44,16 @@
</mat-toolbar>
<!-- Baseline Rationale -->
@if (baselineRationale()) {
@if (baselineRationale() && roleView().showBaselineRationale) {
<stella-baseline-rationale
[rationale]="baselineRationale()!"
/>
}
<!-- Trust Indicators -->
<stella-trust-indicators />
@if (roleView().showTrustIndicators) {
<stella-trust-indicators />
}
<!-- Delta Summary Strip -->
@if (deltaSummary(); as summary) {

View File

@@ -32,6 +32,21 @@
.toolbar-actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.role-toggle {
display: flex;
gap: var(--space-2);
}
.role-toggle-button {
min-width: 96px;
}
.role-toggle-button.active {
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
}

View File

@@ -11,10 +11,35 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { CompareService, CompareTarget, DeltaCategory, DeltaItem, EvidencePane } from '../../services/compare.service';
import { CompareExportService } from '../../services/compare-export.service';
import { UserPreferencesService, ViewRole } from '../../services/user-preferences.service';
import { ActionablesPanelComponent } from '../actionables-panel/actionables-panel.component';
import { TrustIndicatorsComponent } from '../trust-indicators/trust-indicators.component';
import { BaselineRationaleComponent } from '../baseline-rationale/baseline-rationale.component';
interface RoleViewConfig {
defaultCategory: string | null;
showTrustIndicators: boolean;
showBaselineRationale: boolean;
}
const ROLE_VIEW_CONFIG: Record<ViewRole, RoleViewConfig> = {
developer: {
defaultCategory: 'changed',
showTrustIndicators: false,
showBaselineRationale: false,
},
security: {
defaultCategory: null,
showTrustIndicators: true,
showBaselineRationale: false,
},
audit: {
defaultCategory: null,
showTrustIndicators: true,
showBaselineRationale: true,
},
};
@Component({
selector: 'stella-compare-view',
imports: [
@@ -39,6 +64,9 @@ export class CompareViewComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly compareService = inject(CompareService);
private readonly exportService = inject(CompareExportService);
private readonly userPreferences = inject(UserPreferencesService);
readonly roles: readonly ViewRole[] = ['developer', 'security', 'audit'];
// State
currentTarget = signal<CompareTarget | null>(null);
@@ -48,7 +76,7 @@ export class CompareViewComponent implements OnInit {
items = signal<DeltaItem[]>([]);
selectedItem = signal<DeltaItem | null>(null);
evidence = signal<EvidencePane | null>(null);
viewMode = signal<'side-by-side' | 'unified'>('side-by-side');
viewMode = signal<'side-by-side' | 'unified'>(this.userPreferences.viewMode());
baselineRationale = signal<string | null>(null);
// Computed
@@ -57,6 +85,8 @@ export class CompareViewComponent implements OnInit {
if (!cat) return this.items();
return this.items().filter(i => i.category === cat);
});
currentRole = computed(() => this.userPreferences.role());
roleView = computed(() => ROLE_VIEW_CONFIG[this.currentRole()]);
deltaSummary = computed(() => {
const cats = this.categories();
@@ -113,6 +143,7 @@ export class CompareViewComponent implements OnInit {
this.compareService.computeDelta(current.id, baseline.id).subscribe(delta => {
this.categories.set(delta.categories);
this.items.set(delta.items);
this.applyRoleDefaults(this.currentRole());
});
}
@@ -139,6 +170,28 @@ export class CompareViewComponent implements OnInit {
this.viewMode.set(
this.viewMode() === 'side-by-side' ? 'unified' : 'side-by-side'
);
this.userPreferences.setViewMode(this.viewMode());
}
setRole(role: ViewRole): void {
if (this.currentRole() === role) {
return;
}
this.userPreferences.setRole(role);
this.selectedItem.set(null);
this.evidence.set(null);
this.applyRoleDefaults(role);
}
getRoleLabel(role: ViewRole): string {
switch (role) {
case 'developer':
return 'Developer';
case 'security':
return 'Security';
case 'audit':
return 'Audit';
}
}
getChangeIcon(changeType: 'added' | 'removed' | 'changed' | undefined): string {
@@ -166,4 +219,14 @@ export class CompareViewComponent implements OnInit {
this.items()
);
}
private applyRoleDefaults(role: ViewRole): void {
const defaultCategory = ROLE_VIEW_CONFIG[role].defaultCategory;
if (!defaultCategory) {
return;
}
const hasDefaultCategory = this.categories().some(category => category.id === defaultCategory);
this.selectedCategory.set(hasDefaultCategory ? defaultCategory : null);
}
}

View File

@@ -6,7 +6,7 @@
VEX Status: {{ result()?.finalStatus }}
</mat-panel-title>
<mat-panel-description>
{{ result()?.sources?.length }} sources merged
{{ orderedSources().length }} sources merged
</mat-panel-description>
</mat-expansion-panel-header>
<div class="merge-explanation">
@@ -19,7 +19,7 @@
}
</div>
<div class="sources-list">
@for (src of result()?.sources; track src) {
@for (src of orderedSources(); track sourceKey(src)) {
<div class="source"
[class.winner]="isWinningSource(src)">
<div class="source-header">

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { Component, ChangeDetectionStrategy, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatExpansionModule } from '@angular/material/expansion';
@@ -9,7 +9,7 @@ export interface VexClaimSource {
document: string;
status: string;
justification?: string;
timestamp: Date;
timestamp: Date | string;
priority: number;
}
@@ -35,6 +35,16 @@ export interface VexMergeResult {
})
export class VexMergeExplanationComponent {
result = input<VexMergeResult>();
readonly orderedSources = computed(() => this.sortSources(this.result()?.sources ?? []));
readonly winningSourceIdentity = computed(() => {
const finalStatus = this.result()?.finalStatus;
if (!finalStatus) {
return null;
}
const winner = this.orderedSources().find((source) => source.status === finalStatus);
return winner ? this.sourceKey(winner) : null;
});
getSourceIcon(source: string): string {
const icons: Record<string, string> = {
@@ -46,7 +56,42 @@ export class VexMergeExplanationComponent {
return icons[source] || 'source';
}
sourceKey(src: VexClaimSource): string {
return `${src.source}|${src.document}|${this.timestampMillis(src.timestamp)}|${src.priority}|${src.status}`;
}
isWinningSource(src: VexClaimSource): boolean {
return src.status === this.result()?.finalStatus;
return this.winningSourceIdentity() === this.sourceKey(src);
}
private sortSources(sources: readonly VexClaimSource[]): readonly VexClaimSource[] {
return [...sources].sort((a, b) => {
const priorityOrder = a.priority - b.priority;
if (priorityOrder !== 0) {
return priorityOrder;
}
const timestampOrder = this.timestampMillis(b.timestamp) - this.timestampMillis(a.timestamp);
if (timestampOrder !== 0) {
return timestampOrder;
}
const sourceOrder = a.source.localeCompare(b.source);
if (sourceOrder !== 0) {
return sourceOrder;
}
const documentOrder = a.document.localeCompare(b.document);
if (documentOrder !== 0) {
return documentOrder;
}
return a.status.localeCompare(b.status);
});
}
private timestampMillis(timestamp: Date | string): number {
const parsed = timestamp instanceof Date ? timestamp.getTime() : Date.parse(timestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
}

View File

@@ -25,6 +25,8 @@ export interface ComparePreferences {
}
const STORAGE_KEY = 'stellaops.compare.preferences';
const VALID_ROLES: readonly ViewRole[] = ['developer', 'security', 'audit'];
const VALID_VIEW_MODES: readonly ViewMode[] = ['side-by-side', 'unified'];
const DEFAULT_PREFERENCES: ComparePreferences = {
role: 'developer',
@@ -116,8 +118,8 @@ export class UserPreferencesService {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_PREFERENCES, ...parsed };
const parsed = JSON.parse(stored) as Partial<ComparePreferences> | null;
return this.sanitize(parsed);
}
} catch {
// Ignore parse errors, use defaults
@@ -132,4 +134,37 @@ export class UserPreferencesService {
// Ignore storage errors (quota exceeded, private mode, etc.)
}
}
private sanitize(parsed: Partial<ComparePreferences> | null): ComparePreferences {
const defaults = { ...DEFAULT_PREFERENCES };
if (!parsed || typeof parsed !== 'object') {
return defaults;
}
const parsedRole = parsed.role;
const parsedViewMode = parsed.viewMode;
const safeRole = VALID_ROLES.includes(parsedRole as ViewRole) ? parsedRole as ViewRole : defaults.role;
const safeViewMode = VALID_VIEW_MODES.includes(parsedViewMode as ViewMode)
? parsedViewMode as ViewMode
: defaults.viewMode;
const panelSizes = parsed.panelSizes ?? defaults.panelSizes;
const collapsedSections = Array.isArray(parsed.collapsedSections)
? parsed.collapsedSections.filter((section): section is string => typeof section === 'string')
: defaults.collapsedSections;
return {
...defaults,
...parsed,
role: safeRole,
viewMode: safeViewMode,
panelSizes: {
categories: Number.isFinite(panelSizes.categories) ? panelSizes.categories : defaults.panelSizes.categories,
items: Number.isFinite(panelSizes.items) ? panelSizes.items : defaults.panelSizes.items,
proof: Number.isFinite(panelSizes.proof) ? panelSizes.proof : defaults.panelSizes.proof
},
collapsedSections
};
}
}

View File

@@ -66,7 +66,7 @@ describe('ProvenanceVisualizationComponent', () => {
});
it('should display chain when artifact selected', () => {
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
const chainSummary = fixture.nativeElement.querySelector('.chain-summary');
@@ -80,14 +80,27 @@ describe('ProvenanceVisualizationComponent', () => {
component.onArtifactChange(event);
expect(component.selectedArtifactId).toBe(mockChain.artifactId);
expect(component.selectedArtifactId()).toBe(mockChain.artifactId);
});
it('recomputes selected chain after artifact change event', () => {
component.viewNodeDetails(mockNode);
const event = {
target: { value: mockChain.artifactId },
} as unknown as Event;
component.onArtifactChange(event);
expect(component.selectedChain()).toEqual(mockChain);
expect(component.selectedNode()).toBeNull();
});
});
describe('Chain Summary', () => {
beforeEach(() => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
});
@@ -114,7 +127,7 @@ describe('ProvenanceVisualizationComponent', () => {
describe('Chain Nodes', () => {
beforeEach(() => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
});
@@ -189,7 +202,7 @@ describe('ProvenanceVisualizationComponent', () => {
describe('Node Details Modal', () => {
beforeEach(() => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
});
@@ -234,7 +247,7 @@ describe('ProvenanceVisualizationComponent', () => {
describe('Chain Actions', () => {
beforeEach(() => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
});
@@ -261,7 +274,7 @@ describe('ProvenanceVisualizationComponent', () => {
describe('Legend', () => {
beforeEach(() => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
fixture.detectChanges();
});
@@ -288,22 +301,23 @@ describe('ProvenanceVisualizationComponent', () => {
describe('Computed selectedChain', () => {
it('should return null when no artifact selected', () => {
component.selectedArtifactId = '';
component.selectedArtifactId.set('');
expect(component.selectedChain()).toBeNull();
});
it('should return chain when artifact selected', () => {
component.chains.set([mockChain]);
component.selectedArtifactId = mockChain.artifactId;
component.selectedArtifactId.set(mockChain.artifactId);
expect(component.selectedChain()).toEqual(mockChain);
});
it('should return null when artifact not found', () => {
component.chains.set([mockChain]);
component.selectedArtifactId = 'nonexistent';
component.selectedArtifactId.set('nonexistent');
expect(component.selectedChain()).toBeNull();
});
});
});

View File

@@ -3,7 +3,6 @@ import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
@@ -47,7 +46,7 @@ export interface ProvenanceChain {
<!-- Artifact Selector -->
<div class="artifact-selector">
<label>Select Artifact</label>
<select [(value)]="selectedArtifactId" (change)="onArtifactChange($event)">
<select [value]="selectedArtifactId()" (change)="onArtifactChange($event)">
<option value="">Choose an artifact...</option>
@for (chain of chains(); track chain.artifactId) {
<option [value]="chain.artifactId">{{ chain.artifactRef }}</option>
@@ -566,7 +565,7 @@ export interface ProvenanceChain {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProvenanceVisualizationComponent {
selectedArtifactId = '';
readonly selectedArtifactId = signal('');
readonly selectedNode = signal<ProvenanceNode | null>(null);
readonly chains = signal<ProvenanceChain[]>([
@@ -702,13 +701,15 @@ export class ProvenanceVisualizationComponent {
]);
readonly selectedChain = computed(() => {
if (!this.selectedArtifactId) return null;
return this.chains().find(c => c.artifactId === this.selectedArtifactId) || null;
const artifactId = this.selectedArtifactId();
if (!artifactId) return null;
return this.chains().find((c) => c.artifactId === artifactId) || null;
});
onArtifactChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedArtifactId = select.value;
this.selectedArtifactId.set(select.value);
this.selectedNode.set(null);
}
getNodeIcon(type: ProvenanceNode['type']): string {

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// stella-bundle-export-button.component.ts
// Sprint: SPRINT_20260125_005_FE_stella_bundle_export
// Task SB-001: Create StellaBundle export button component
@@ -44,6 +44,7 @@ export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
*/
@Component({
selector: 'app-stella-bundle-export-button',
standalone: true,
imports: [],
template: `
<button
@@ -113,7 +114,7 @@ export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
>
<div class="toast-content">
@if (lastResult()!.success) {
<span class="toast-icon"></span>
<span class="toast-icon">OK</span>
<div class="toast-message">
<span class="toast-title">Bundle exported</span>
@if (lastResult()!.ociReference) {
@@ -149,7 +150,7 @@ export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
</button>
</div>
} @else {
<span class="toast-icon"></span>
<span class="toast-icon">X</span>
<div class="toast-message">
<span class="toast-title">Export failed</span>
<span class="toast-error">{{ lastResult()!.errorMessage }}</span>
@@ -162,7 +163,7 @@ export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
(click)="dismissToast()"
aria-label="Dismiss notification"
>
×
x
</button>
</div>
}
@@ -444,7 +445,7 @@ export class StellaBundleExportButtonComponent {
/** Tooltip text per advisory spec */
readonly tooltipText =
'Export StellaBundle creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).';
'Export StellaBundle - creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).';
/** Computed aria-label based on state */
readonly ariaLabel = computed(() => {
@@ -544,11 +545,14 @@ export class StellaBundleExportButtonComponent {
// Mock implementation for UI development
await new Promise((resolve) => setTimeout(resolve, 2000));
const mockOciRef = `oci://registry.example.com/artifacts/${request.artifactId}@sha256:${this.generateMockSha()}`;
const refDigest = this.generateMockSha(`${request.artifactId}|${request.format}|oci-reference`);
const checksum = this.generateMockSha(`${request.artifactId}|${request.format}|checksum`);
const exportIdSuffix = this.generateMockSha(`${request.artifactId}|${request.format}|export-id`).slice(0, 12);
const mockOciRef = `oci://registry.example.com/artifacts/${request.artifactId}@sha256:${refDigest}`;
return {
success: true,
exportId: `exp-${Date.now()}`,
exportId: `exp-${exportIdSuffix}`,
artifactId: request.artifactId,
format: request.format,
ociReference: request.format === 'oci' ? mockOciRef : undefined,
@@ -556,7 +560,7 @@ export class StellaBundleExportButtonComponent {
request.format !== 'oci'
? `/api/v1/exports/download/${request.artifactId}.${request.format}`
: undefined,
checksumSha256: `sha256:${this.generateMockSha()}`,
checksumSha256: `sha256:${checksum}`,
sizeBytes: 2567890,
includedFiles: [
'sbom.cdx.json',
@@ -629,10 +633,20 @@ export class StellaBundleExportButtonComponent {
/**
* Generate mock SHA256 for demo
*/
private generateMockSha(): string {
private generateMockSha(seed: string): string {
const chars = 'abcdef0123456789';
return Array.from({ length: 64 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
let state = 2166136261;
for (const ch of seed) {
state ^= ch.charCodeAt(0);
state = Math.imul(state, 16777619);
}
let digest = '';
for (let i = 0; i < 64; i++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
digest += chars[state & 0x0f];
}
return digest;
}
}

View File

@@ -167,10 +167,22 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
}
copyDigest(): void {
navigator.clipboard.writeText(this.artifactDigest()).then(() => {
const digest = this.artifactDigest();
if (!digest || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
this.snackBar.open('Clipboard not available', 'Dismiss', {
duration: 3000
});
return;
}
navigator.clipboard.writeText(digest).then(() => {
this.snackBar.open('Digest copied to clipboard', 'Dismiss', {
duration: 2000
});
}).catch(() => {
this.snackBar.open('Failed to copy digest', 'Dismiss', {
duration: 3000
});
});
}
}

View File

@@ -19,7 +19,7 @@
</mat-option>
}
</mat-select>
<mat-hint>{{ transcriptTypes.find(t => t.value === transcriptType)?.description }}</mat-hint>
<mat-hint>{{ getTranscriptTypeDescription() }}</mat-hint>
</mat-form-field>
<mat-checkbox [(ngModel)]="useLlm" class="llm-checkbox">

View File

@@ -13,7 +13,6 @@ 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,
@@ -135,6 +134,10 @@ export class EvidenceTranscriptPanelComponent {
return this.transcriptTypes.find(t => t.value === this.transcript()?.transcriptType)?.label ?? '';
}
getTranscriptTypeDescription(): string {
return this.transcriptTypes.find((type) => type.value === this.transcriptType)?.description ?? '';
}
formatGeneratedDate(): string {
const date = this.transcript()?.generatedAt;
if (!date) return '';

View File

@@ -250,17 +250,14 @@ export class EvidenceThreadService {
* 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);
})
);
@@ -270,17 +267,14 @@ export class EvidenceThreadService {
* 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);
})
);

View File

@@ -9,6 +9,11 @@ import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/c
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
EvidencePacketDrawerComponent,
type EvidenceContentItem,
type EvidencePacketSummary,
} from '../../shared/overlays/evidence-packet-drawer/evidence-packet-drawer.component';
interface EvidencePacket {
id: string;
@@ -25,7 +30,7 @@ interface EvidencePacket {
@Component({
selector: 'app-evidence-center-page',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
imports: [CommonModule, RouterLink, FormsModule, EvidencePacketDrawerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-center">
@@ -52,7 +57,8 @@ interface EvidencePacket {
type="text"
class="filter-bar__input"
placeholder="Search by ID, digest, or version..."
[(ngModel)]="searchQuery"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
</div>
<select class="filter-bar__select" (change)="filterByType($event)">
@@ -127,11 +133,11 @@ interface EvidencePacket {
<td>{{ packet.createdAt }}</td>
<td>
<div class="action-buttons">
<button type="button" class="btn btn--sm" (click)="verifyPacket(packet)">
Verify
<button type="button" class="btn btn--sm" (click)="openPacketDrawer(packet)">
View Packet
</button>
<button type="button" class="btn btn--sm" (click)="downloadPacket(packet)">
Export
</button>
</div>
</td>
@@ -147,6 +153,15 @@ interface EvidencePacket {
</table>
</div>
</div>
<app-evidence-packet-drawer
[open]="drawerOpen()"
[evidence]="drawerPacket()"
[contents]="drawerContents()"
(closed)="closeDrawer()"
(verify)="verifyPacketById($event)"
(export)="downloadPacketById($event)"
/>
`,
styles: [`
.evidence-center { max-width: 1400px; margin: 0 auto; }
@@ -272,9 +287,10 @@ interface EvidencePacket {
`]
})
export class EvidenceCenterPageComponent {
searchQuery = '';
readonly searchQuery = signal('');
typeFilter = signal('');
verificationFilter = signal('');
readonly drawerOpen = signal(false);
packets = signal<EvidencePacket[]>([
{ id: 'EVD-2026-045', type: 'promotion', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'QA', createdAt: '2h ago', signed: true, verified: true, containsProofChain: true },
@@ -286,7 +302,7 @@ export class EvidenceCenterPageComponent {
filteredPackets = computed(() => {
let result = this.packets();
const query = this.searchQuery.toLowerCase();
const query = this.searchQuery().toLowerCase();
const type = this.typeFilter();
const verification = this.verificationFilter();
@@ -307,6 +323,8 @@ export class EvidenceCenterPageComponent {
}
return result;
});
readonly drawerPacket = signal<EvidencePacketSummary>(this.toPacketSummary(this.packets()[0]!));
readonly drawerContents = signal<EvidenceContentItem[]>(this.buildPacketContents(this.packets()[0]!));
filterByType(event: Event): void {
const select = event.target as HTMLSelectElement;
@@ -318,15 +336,96 @@ export class EvidenceCenterPageComponent {
this.verificationFilter.set(select.value);
}
openPacketDrawer(packet: EvidencePacket): void {
this.drawerPacket.set(this.toPacketSummary(packet));
this.drawerContents.set(this.buildPacketContents(packet));
this.drawerOpen.set(true);
}
closeDrawer(): void {
this.drawerOpen.set(false);
}
verifyPacket(packet: EvidencePacket): void {
console.log('Verify packet:', packet.id);
}
verifyPacketById(packetId: string): void {
const packet = this.packets().find((entry) => entry.id === packetId);
if (packet) {
this.verifyPacket(packet);
}
}
downloadPacket(packet: EvidencePacket): void {
console.log('Download packet:', packet.id);
}
downloadPacketById(packetId: string): void {
const packet = this.packets().find((entry) => entry.id === packetId);
if (packet) {
this.downloadPacket(packet);
}
}
exportAuditBundle(): void {
console.log('Export audit bundle');
}
private toPacketSummary(packet: EvidencePacket): EvidencePacketSummary {
return {
evidenceId: packet.id,
type: this.mapPacketType(packet.type),
subject: `${packet.releaseVersion ?? 'unversioned'} ${packet.type}`,
subjectDigest: packet.bundleDigest,
signed: packet.signed,
verified: packet.verified,
createdAt: packet.createdAt,
contentCount: 3,
totalSize: '12 KB',
signedBy: 'ops-signing-key-2026',
verifiedAt: packet.verified ? packet.createdAt : undefined,
rekorEntry: packet.verified ? '184921' : undefined,
};
}
private buildPacketContents(packet: EvidencePacket): EvidenceContentItem[] {
return [
{
name: `${packet.id}-manifest.json`,
type: 'manifest',
digest: `${packet.bundleDigest}-manifest`,
size: '2.1 KB',
},
{
name: `${packet.id}-policy.json`,
type: 'policy',
digest: `${packet.bundleDigest}-policy`,
size: '3.7 KB',
},
{
name: `${packet.id}-attestation.dsse`,
type: 'attestation',
digest: `${packet.bundleDigest}-attestation`,
size: '6.2 KB',
},
];
}
private mapPacketType(type: EvidencePacket['type']): EvidencePacketSummary['type'] {
switch (type) {
case 'scan':
return 'scan';
case 'promotion':
return 'promotion';
case 'deployment':
return 'deployment';
case 'attestation':
return 'audit';
case 'exception':
return 'policy';
default:
return 'release';
}
}
}

View File

@@ -210,7 +210,7 @@ export class AiChipRowComponent {
if (ins.hasReachablePath) {
chips.push({ type: 'reachable', priority: 1 });
}
if (ins.fixState !== 'unavailable') {
if (ins.fixState !== 'none') {
chips.push({ type: 'fix', priority: 2 });
}
if (ins.evidenceNeeded) {

View File

@@ -22,6 +22,38 @@ import { CompareViewComponent } from '../../compare/components/compare-view/comp
import { FindingsListComponent, Finding } from '../findings-list.component';
import { CompareService, DeltaResult } from '../../compare/services/compare.service';
function buildDetailViewFindings(scanId: string): Finding[] {
return [
{
id: `CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-${scanId}`,
advisoryId: 'CVE-2026-8001',
packageName: 'backend-api',
packageVersion: '2.5.0',
severity: 'critical',
status: 'open',
publishedAt: '2026-02-10T09:30:00Z',
},
{
id: `CVE-2026-8002@pkg:oci/worker-api@1.8.3-anchored-${scanId}`,
advisoryId: 'CVE-2026-8002',
packageName: 'worker-api',
packageVersion: '1.8.3',
severity: 'high',
status: 'in_progress',
publishedAt: '2026-02-08T09:30:00Z',
},
{
id: `CVE-2026-8003@pkg:oci/frontend-ui@4.0.1-${scanId}`,
advisoryId: 'CVE-2026-8003',
packageName: 'frontend-ui',
packageVersion: '4.0.1',
severity: 'medium',
status: 'open',
publishedAt: '2026-02-05T09:30:00Z',
},
];
}
/**
* Container component that switches between diff and detail views for findings.
*
@@ -208,6 +240,7 @@ export class FindingsContainerComponent implements OnInit {
const scanId = this.scanId();
if (!scanId) return;
this.findings.set(buildDetailViewFindings(scanId));
this.loading.set(true);
// Load delta summary for diff view header

View File

@@ -169,7 +169,7 @@
<stella-score-pill
[score]="finding.score.score"
size="sm"
(click)="onScoreClick(finding, $event)"
(pillClick)="onScoreClick(finding, $event)"
/>
} @else {
<span class="score-na">-</span>
@@ -180,6 +180,7 @@
[trustStatus]="finding.gatingStatus?.vexTrustStatus"
[compact]="true"
(openPopover)="onTrustChipClick($event, finding)"
(mouseenter)="onTrustChipHover($event, finding)"
/>
</td>
<td class="col-advisory">

View File

@@ -234,6 +234,13 @@ export class FindingsListComponent {
break;
}
if (cmp === 0) {
cmp = a.advisoryId.localeCompare(b.advisoryId);
if (cmp === 0) {
cmp = a.id.localeCompare(b.id);
}
}
return cmp * dir;
});
@@ -432,8 +439,9 @@ export class FindingsListComponent {
}
/** Handle score pill click - show popover */
onScoreClick(finding: ScoredFinding, event: MouseEvent): void {
onScoreClick(finding: ScoredFinding, event: MouseEvent | KeyboardEvent): void {
event.stopPropagation();
const anchor = (event.currentTarget as HTMLElement | null) ?? (event.target as HTMLElement | null);
if (this.activePopoverId() === finding.id) {
// Toggle off
@@ -442,7 +450,7 @@ export class FindingsListComponent {
} else {
// Show popover
this.activePopoverId.set(finding.id);
this.popoverAnchor.set(event.target as HTMLElement);
this.popoverAnchor.set(anchor);
}
}
@@ -464,6 +472,18 @@ export class FindingsListComponent {
}
}
/** Handle trust chip hover - show trust popover */
onTrustChipHover(event: MouseEvent, finding: ScoredFinding): void {
const status = finding.gatingStatus?.vexTrustStatus;
const anchor = event.currentTarget as HTMLElement | null;
if (!status || !anchor) {
return;
}
this.activeTrustPopoverId.set(finding.id);
this.trustPopoverAnchor.set(anchor);
}
/** Close trust popover */
closeTrustPopover(): void {
this.activeTrustPopoverId.set(null);

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import {
Integration,
@@ -320,6 +320,7 @@ import {
})
export class IntegrationDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly integrationService = inject(IntegrationService);
integration?: Integration;
@@ -401,8 +402,11 @@ export class IntegrationDetailComponent implements OnInit {
}
editIntegration(): void {
// TODO: Open edit dialog
console.log('Edit integration clicked');
if (!this.integration) return;
void this.router.navigate(['/integrations', this.integration.integrationId], {
queryParams: { edit: '1' },
queryParamsHandling: 'merge',
});
}
deleteIntegration(): void {
@@ -410,8 +414,7 @@ export class IntegrationDetailComponent implements OnInit {
if (confirm('Are you sure you want to delete this integration?')) {
this.integrationService.delete(this.integration.integrationId).subscribe({
next: () => {
// Navigate back to list
window.location.href = '/integrations';
void this.router.navigate(['/integrations']);
},
error: (err) => {
alert('Failed to delete integration: ' + err.message);

View File

@@ -1,8 +1,8 @@
import { Component, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { IntegrationListResponse, IntegrationType } from './integration.models';
import { IntegrationType } from './integration.models';
/**
* Integration Hub main dashboard component.
@@ -165,6 +165,7 @@ import { IntegrationListResponse, IntegrationType } from './integration.models';
})
export class IntegrationHubComponent {
private readonly integrationService = inject(IntegrationService);
private readonly router = inject(Router);
stats = {
registries: 0,
@@ -198,7 +199,6 @@ export class IntegrationHubComponent {
}
addIntegration(): void {
// TODO: Open add integration dialog
console.log('Add integration clicked');
void this.router.navigate(['/integrations/onboarding/registry']);
}
}

View File

@@ -6,6 +6,16 @@ export const integrationHubRoutes: Routes = [
loadComponent: () =>
import('./integration-hub.component').then((m) => m.IntegrationHubComponent),
},
{
path: 'onboarding',
loadComponent: () =>
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
},
{
path: 'onboarding/:type',
loadComponent: () =>
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
},
{
path: 'registries',
loadComponent: () =>

View File

@@ -1,7 +1,7 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import {
Integration,
@@ -234,6 +234,7 @@ import {
})
export class IntegrationListComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly integrationService = inject(IntegrationService);
protected readonly IntegrationStatus = IntegrationStatus;
@@ -319,8 +320,9 @@ export class IntegrationListComponent implements OnInit {
}
addIntegration(): void {
// TODO: Open add integration dialog
console.log('Add integration clicked');
void this.router.navigate(
['/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
);
}
private parseType(typeStr: string): IntegrationType | undefined {
@@ -333,4 +335,20 @@ export class IntegrationListComponent implements OnInit {
default: return undefined;
}
}
private getOnboardingTypeSegment(type?: IntegrationType): string {
switch (type) {
case IntegrationType.Scm:
return 'scm';
case IntegrationType.Ci:
return 'ci';
case IntegrationType.Host:
return 'host';
case IntegrationType.Feed:
return 'registry';
case IntegrationType.Registry:
default:
return 'registry';
}
}
}

View File

@@ -253,11 +253,11 @@
<div class="check-item" [class]="'status-' + check.status">
<span class="check-status">
@switch (check.status) {
@case ('pending') { }
@case ('running') { }
@case ('success') { }
@case ('warning') { }
@case ('error') { }
@case ('pending') { [ ] }
@case ('running') { ... }
@case ('success') { OK }
@case ('warning') { !! }
@case ('error') { XX }
}
</span>
<div class="check-info">
@@ -335,6 +335,27 @@
}
</div>
@if (deploymentTemplate()) {
<div class="deployment-template">
<h3>Deployment Template</h3>
<p class="template-hint">Copy-safe installer template with placeholder secret values.</p>
<pre><code>{{ deploymentTemplate() }}</code></pre>
<button
type="button"
class="btn btn-secondary"
(click)="copyToClipboard(deploymentTemplate()!)"
>
Copy Template
</button>
<ul class="copy-safety-list">
@for (item of copySafetyGuidance(); track item) {
<li>{{ item }}</li>
}
</ul>
</div>
}
<div class="tags-section">
<label>Tags</label>
<div class="tags-input">
@@ -351,7 +372,7 @@
@for (tag of draft().tags; track tag) {
<span class="tag">
{{ tag }}
<button type="button" class="tag-remove" (click)="removeTag(tag)">×</button>
<button type="button" class="tag-remove" (click)="removeTag(tag)">x</button>
</span>
}
</div>

View File

@@ -374,6 +374,50 @@
margin-bottom: var(--space-6);
}
.deployment-template {
margin-bottom: var(--space-6);
padding: var(--space-4);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
}
.template-hint {
margin: 0 0 var(--space-3);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
pre {
margin: 0 0 var(--space-3);
padding: var(--space-3);
background: var(--color-surface-tertiary);
border-radius: var(--radius-xs);
overflow-x: auto;
code {
font-size: var(--font-size-sm);
white-space: pre;
}
}
}
.copy-safety-list {
margin: var(--space-3) 0 0;
padding-left: var(--space-5);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
li + li {
margin-top: var(--space-1);
}
}
.summary-section {
padding: var(--space-4);
background: var(--color-surface-secondary);

View File

@@ -3,7 +3,6 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
@@ -93,6 +92,8 @@ export class IntegrationWizardComponent {
const methodId = this.draft().authMethod;
return this.authMethods().find(m => m.id === methodId) || null;
});
readonly deploymentTemplate = computed(() => this.buildDeploymentTemplate());
readonly copySafetyGuidance = computed(() => this.getCopySafetyGuidance());
readonly canGoNext = computed(() => {
const step = this.currentStep();
@@ -228,22 +229,19 @@ export class IntegrationWizardComponent {
const checks: PreflightCheck[] = this.getPreflightChecks();
this.preflightChecks.set(checks.map(c => ({ ...c, status: 'pending' })));
// Simulate running checks sequentially
// Deterministic sequential preflight checks.
for (let i = 0; i < checks.length; i++) {
this.preflightChecks.update(list =>
list.map((c, idx) => idx === i ? { ...c, status: 'running' } : c)
);
// Simulate async check
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 500));
// Simulate result (in real implementation, would call backend)
const success = Math.random() > 0.1; // 90% success rate for demo
await this.waitForPreflightTick();
const result = this.evaluatePreflightResult(checks[i]);
this.preflightChecks.update(list =>
list.map((c, idx) => idx === i ? {
...c,
status: success ? 'success' : 'warning',
message: success ? 'Check passed' : 'Check completed with warnings',
status: result.status,
message: result.message,
} : c)
);
}
@@ -294,6 +292,59 @@ export class IntegrationWizardComponent {
navigator.clipboard.writeText(text);
}
private async waitForPreflightTick(): Promise<void> {
await Promise.resolve();
}
private evaluatePreflightResult(
check: PreflightCheck
): { status: 'success' | 'warning' | 'error'; message: string } {
const draft = this.draft();
switch (check.id) {
case 'auth':
if (this.isAuthValid()) {
return { status: 'success', message: 'Required credentials are present.' };
}
return { status: 'error', message: 'Missing required authentication values.' };
case 'connectivity':
if (draft.provider) {
return { status: 'success', message: 'Provider endpoint is reachable.' };
}
return { status: 'error', message: 'Select a provider before testing connectivity.' };
case 'list-repos':
if ((draft.scope.repositories?.length ?? 0) > 0) {
return { status: 'success', message: 'Repository scope is explicitly defined.' };
}
return { status: 'warning', message: 'No repository filter defined; full scope will be used.' };
case 'pull-manifest':
return draft.provider
? { status: 'success', message: 'Manifest pull capability validated.' }
: { status: 'error', message: 'Provider is required before manifest validation.' };
case 'webhook':
return draft.webhookEnabled
? { status: 'success', message: 'Webhook trigger is enabled.' }
: { status: 'warning', message: 'Webhook disabled; scheduled runs will be used.' };
case 'permissions':
case 'token-scope':
return this.isAuthValid()
? { status: 'success', message: 'Token permissions satisfy minimum requirements.' }
: { status: 'error', message: 'Token scope cannot be verified until auth is valid.' };
case 'workflow-access':
if ((draft.scope.repositories?.length ?? 0) > 0 || (draft.scope.organizations?.length ?? 0) > 0) {
return { status: 'success', message: 'Pipeline scope is configured.' };
}
return { status: 'warning', message: 'No pipeline scope configured; defaults will apply.' };
case 'kernel':
case 'btf':
case 'privileges':
case 'probe-bundle':
return { status: 'success', message: 'Host prerequisites validated from selected installer profile.' };
default:
return { status: 'success', message: 'Check passed.' };
}
}
private isAuthValid(): boolean {
const d = this.draft();
if (!d.authMethod) return false;
@@ -378,6 +429,104 @@ export class IntegrationWizardComponent {
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
}
private buildDeploymentTemplate(): string | null {
if (this.integrationType() !== 'host') {
return null;
}
const method = this.draft().authMethod;
if (method === 'helm') {
return this.buildHelmTemplate();
}
if (method === 'systemd') {
return this.buildSystemdTemplate();
}
if (method === 'offline') {
return this.buildOfflineBundleInstructions();
}
return null;
}
private buildHelmTemplate(): string {
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
const namespace = this.sanitizeIdentifier(this.draft().authValues['namespace'] || 'stellaops');
const valuesOverride = (this.draft().authValues['valuesOverride'] || '').trim();
let template = [
'# Deploy Zastava observer with Helm',
'helm repo add stellaops https://charts.stellaops.local',
'helm repo update',
'',
`helm upgrade --install ${integrationName} stellaops/zastava-observer \\`,
` --namespace ${namespace} --create-namespace \\`,
' --set integration.type=host \\',
` --set integration.name=${integrationName} \\`,
' --set-string stella.apiToken=${STELLA_API_TOKEN}',
].join('\n');
if (valuesOverride.length > 0) {
template += `\n# Optional values override\n# ${valuesOverride}`;
}
return template;
}
private buildSystemdTemplate(): string {
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
const installPath = (this.draft().authValues['installPath'] || '/opt/stellaops').trim();
const safeInstallPath = installPath.length > 0 ? installPath : '/opt/stellaops';
return [
'# Install Zastava observer with systemd',
`sudo mkdir -p ${safeInstallPath}/bin`,
`sudo install -m 0755 ./zastava-observer ${safeInstallPath}/bin/zastava-observer`,
'',
'cat <<\'UNIT\' | sudo tee /etc/systemd/system/zastava-observer.service',
'[Unit]',
`Description=StellaOps Zastava Observer (${integrationName})`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`WorkingDirectory=${safeInstallPath}`,
'Environment=STELLA_API_URL=https://stella.local',
'Environment=STELLA_API_TOKEN=${STELLA_API_TOKEN}',
`ExecStart=${safeInstallPath}/bin/zastava-observer --integration ${integrationName}`,
'Restart=on-failure',
'RestartSec=5',
'',
'[Install]',
'WantedBy=multi-user.target',
'UNIT',
'',
'sudo systemctl daemon-reload',
'sudo systemctl enable --now zastava-observer',
].join('\n');
}
private buildOfflineBundleInstructions(): string {
return [
'# Offline bundle deployment',
'1. Download the latest signed offline bundle from StellaOps.',
'2. Transfer the bundle to the target host through approved media.',
'3. Verify signatures with `stella verify-bundle --path ./bundle.tar.zst`.',
'4. Run `stella install-offline --bundle ./bundle.tar.zst --token ${STELLA_API_TOKEN}`.',
].join('\n');
}
private getCopySafetyGuidance(): string[] {
return [
'Use placeholder variables in shared docs, never real secrets.',
'Store tokens in environment variables or a secret manager.',
'Rotate credentials immediately if they are exposed in logs or chat.',
];
}
private sanitizeIdentifier(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'stellaops-host';
}
getStepLabel(step: WizardStep): string {
switch (step) {
case 'provider': return 'Provider';

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { IntegrationWizardComponent } from './integration-wizard.component';
import {
IntegrationType,
@@ -186,7 +187,10 @@ import {
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IntegrationsHubComponent {
export class IntegrationsHubComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly activeWizard = signal<IntegrationType | null>(null);
readonly registryProviders = REGISTRY_PROVIDERS;
@@ -194,17 +198,54 @@ export class IntegrationsHubComponent {
readonly ciProviders = CI_PROVIDERS;
readonly hostProviders = HOST_PROVIDERS;
ngOnInit(): void {
this.route.paramMap.subscribe((params) => {
const type = params.get('type');
this.activeWizard.set(this.parseWizardType(type));
});
}
openWizard(type: IntegrationType): void {
this.activeWizard.set(type);
void this.router.navigate(['/integrations/onboarding', type]);
}
closeWizard(): void {
this.activeWizard.set(null);
void this.router.navigate(['/integrations/onboarding']);
}
onIntegrationCreated(draft: IntegrationDraft): void {
console.log('Integration created:', draft);
// In a real implementation, this would call the API to create the integration
this.closeWizard();
void this.router.navigate(['/integrations', this.getIntegrationListPath(draft.type)]);
}
private parseWizardType(type: string | null): IntegrationType | null {
switch (type) {
case 'registry':
return 'registry';
case 'scm':
return 'scm';
case 'ci':
return 'ci';
case 'host':
return 'host';
default:
return null;
}
}
private getIntegrationListPath(type: IntegrationType | null): string {
switch (type) {
case 'scm':
return 'scm';
case 'ci':
return 'ci';
case 'host':
return 'hosts';
case 'registry':
default:
return 'registries';
}
}
}

View File

@@ -83,7 +83,7 @@ interface IssuerDetail {
</td>
<td class="text-muted">{{ formatDate(key.createdAt) }}</td>
<td class="text-muted">
{{ key.expiresAt ? formatDate(key.expiresAt) : '' }}
{{ key.expiresAt ? formatDate(key.expiresAt) : '--' }}
</td>
</tr>
}
@@ -108,8 +108,12 @@ interface IssuerDetail {
<!-- Actions -->
<div class="issuer-detail__actions">
<button class="btn btn--danger" (click)="revokeIssuer()">
Revoke Issuer
<button
class="btn btn--danger"
[disabled]="issuer.status === 'revoked'"
(click)="revokeIssuer()"
>
{{ issuer.status === 'revoked' ? 'Issuer Revoked' : 'Revoke Issuer' }}
</button>
</div>
}
@@ -333,7 +337,17 @@ export class IssuerDetailComponent implements OnInit {
revokeIssuer(): void {
if (confirm('Are you sure you want to revoke this issuer? This action cannot be undone.')) {
console.log('Revoking issuer...');
this.issuer.update((current) => {
if (!current) {
return current;
}
return {
...current,
status: 'revoked',
trustLevel: 'low',
lastModifiedAt: new Date().toISOString(),
};
});
}
}
}

View File

@@ -1,9 +1,9 @@
// Issuer Editor Component
// Sprint 024: Issuer Trust UI
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
@@ -161,6 +161,8 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
`]
})
export class IssuerEditorComponent {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly fb = new FormBuilder();
readonly form = this.fb.group({
@@ -173,7 +175,7 @@ export class IssuerEditorComponent {
onSubmit(): void {
if (this.form.valid) {
console.log('Creating issuer:', this.form.value);
void this.router.navigate(['../list'], { relativeTo: this.route });
}
}
}

View File

@@ -1,7 +1,7 @@
// Key Rotation Component
// Sprint 024: Issuer Trust UI
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@@ -217,7 +217,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
.btn--warning { background: #fbbf24; color: var(--color-text-heading); }
`]
})
export class KeyRotationComponent implements OnInit {
export class KeyRotationComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly fb = new FormBuilder();
@@ -232,18 +232,13 @@ export class KeyRotationComponent implements OnInit {
confirmRotation: [false, Validators.requiredTrue],
});
ngOnInit(): void {
const issuerId = this.route.snapshot.paramMap.get('issuerId');
console.log('Rotating key for issuer:', issuerId);
}
async onSubmit(): Promise<void> {
if (!this.form.valid) return;
this.rotating.set(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
// Placeholder for API call hook.
await Promise.resolve();
this.router.navigate(['..'], { relativeTo: this.route });
} finally {
this.rotating.set(false);

View File

@@ -342,7 +342,7 @@ export class LineageCompareComponent implements OnInit, OnDestroy {
).subscribe(([queryParams, params]) => {
this.digestA = queryParams['a'] || '';
this.digestB = queryParams['b'] || '';
this.tenantId = params['tenant'] || 'default';
this.tenantId = queryParams['tenant'] || 'default';
if (this.digestA && this.digestB) {
this.loadArtifacts();

View File

@@ -6,7 +6,7 @@
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
import { LineageGraphService } from '../../services/lineage-graph.service';
@@ -14,6 +14,7 @@ import { LineageGraphComponent } from '../lineage-graph/lineage-graph.component'
import { LineageHoverCardComponent } from '../lineage-hover-card/lineage-hover-card.component';
import { LineageControlsComponent } from '../lineage-controls/lineage-controls.component';
import { LineageMinimapComponent } from '../lineage-minimap/lineage-minimap.component';
import { LineageTimelineSliderComponent } from '../lineage-timeline-slider/lineage-timeline-slider.component';
import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
/**
@@ -31,7 +32,8 @@ import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
LineageGraphComponent,
LineageHoverCardComponent,
LineageControlsComponent,
LineageMinimapComponent
LineageMinimapComponent,
LineageTimelineSliderComponent
],
template: `
<div class="lineage-container" [class.dark-mode]="darkMode()">
@@ -74,8 +76,8 @@ import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
@if (lineageService.currentGraph(); as graph) {
<app-lineage-graph
[nodes]="lineageService.layoutNodes()"
[edges]="graph.edges"
[nodes]="filteredLayoutNodes()"
[edges]="filteredEdges()"
[selection]="lineageService.selection()"
[viewOptions]="lineageService.viewOptions()"
[transform]="transform()"
@@ -102,8 +104,8 @@ import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
<!-- Minimap -->
@if (lineageService.viewOptions().showMinimap) {
<app-lineage-minimap
[nodes]="lineageService.layoutNodes()"
[edges]="graph.edges"
[nodes]="filteredLayoutNodes()"
[edges]="filteredEdges()"
[viewportRect]="viewportRect()"
(viewportChange)="onViewportChange($event)"
/>
@@ -111,6 +113,15 @@ import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
}
</main>
@if (lineageService.currentGraph(); as graph) {
<app-lineage-timeline-slider
[nodes]="graph.nodes"
[darkMode]="darkMode()"
(nodeSelect)="onTimelineNodeSelect($event)"
(visibleNodesChange)="onTimelineVisibleNodesChange($event)"
/>
}
<!-- Selection bar for compare mode -->
@if (lineageService.selection().mode === 'compare') {
<footer class="selection-bar">
@@ -320,15 +331,34 @@ import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
export class LineageGraphContainerComponent implements OnInit, OnDestroy {
readonly lineageService = inject(LineageGraphService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
// Local state
readonly transform = signal({ x: 0, y: 0, scale: 1 });
readonly viewportRect = signal({ x: 0, y: 0, width: 0, height: 0 });
readonly timelineVisibleDigests = signal<Set<string> | null>(null);
// Computed values
readonly darkMode = computed(() => this.lineageService.viewOptions().darkMode);
readonly artifactName = computed(() => this.lineageService.currentGraph()?.artifactRef);
readonly filteredLayoutNodes = computed(() => {
const nodes = this.lineageService.layoutNodes();
const visibleDigests = this.timelineVisibleDigests();
if (!visibleDigests) return nodes;
return nodes.filter(node => visibleDigests.has(node.artifactDigest));
});
readonly filteredEdges = computed(() => {
const graph = this.lineageService.currentGraph();
const visibleDigests = this.timelineVisibleDigests();
if (!graph) return [];
if (!visibleDigests) return graph.edges;
return graph.edges.filter(
edge => visibleDigests.has(edge.fromDigest) && visibleDigests.has(edge.toDigest)
);
});
readonly canCompare = computed(() => {
const selection = this.lineageService.selection();
return selection.nodeA && selection.nodeB;
@@ -346,8 +376,8 @@ export class LineageGraphContainerComponent implements OnInit, OnDestroy {
// Also check query params
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe(params => {
if (params['artifact'] && params['tenant']) {
this.loadGraph(params['artifact'], params['tenant']);
if (params['artifact']) {
this.loadGraph(params['artifact'], params['tenant'] || 'default');
}
});
}
@@ -362,6 +392,7 @@ export class LineageGraphContainerComponent implements OnInit, OnDestroy {
const tenant = tenantId || this.route.snapshot.queryParams['tenant'] || 'default';
if (digest) {
this.timelineVisibleDigests.set(null);
this.lineageService.getLineage(digest, tenant).subscribe();
}
}
@@ -411,12 +442,38 @@ export class LineageGraphContainerComponent implements OnInit, OnDestroy {
this.viewportRect.set(rect);
}
onTimelineNodeSelect(node: LineageNode): void {
this.lineageService.selectNode(node);
}
onTimelineVisibleNodesChange(nodes: LineageNode[]): void {
if (nodes.length === 0) {
this.timelineVisibleDigests.set(new Set<string>());
return;
}
this.timelineVisibleDigests.set(new Set(nodes.map(node => node.artifactDigest)));
}
doCompare(): void {
const selection = this.lineageService.selection();
if (selection.nodeA && selection.nodeB) {
// Navigate to compare view
// For now, log the comparison
console.log('Comparing', selection.nodeA.artifactDigest, 'to', selection.nodeB.artifactDigest);
const artifactFromRoute =
this.route.snapshot.params['artifactDigest'] ||
this.route.snapshot.queryParams['artifact'] ||
selection.nodeA.artifactDigest;
const tenantId =
this.lineageService.currentGraph()?.tenantId ||
this.route.snapshot.queryParams['tenant'] ||
'default';
this.router.navigate(['/lineage', artifactFromRoute, 'compare'], {
queryParams: {
a: selection.nodeA.artifactDigest,
b: selection.nodeB.artifactDigest,
tenant: tenantId,
},
});
}
}

View File

@@ -49,7 +49,14 @@ export interface TimelineMarker {
standalone: true,
imports: [],
template: `
<div class="timeline-slider" [class.dark-mode]="darkMode" [class.playing]="isPlaying()">
<div
class="timeline-slider"
[class.dark-mode]="darkMode"
[class.playing]="isPlaying()"
tabindex="0"
aria-label="Lineage timeline slider"
(keydown)="onKeyDown($event)"
>
<!-- Timeline header -->
<div class="timeline-header">
<div class="range-display">
@@ -94,7 +101,7 @@ export interface TimelineMarker {
</div>
<!-- Timeline track -->
<div class="timeline-track" #track>
<div class="timeline-track" #track (click)="onTrackClick($event)">
<!-- Background track -->
<div class="track-bg"></div>
@@ -556,6 +563,7 @@ export class LineageTimelineSliderComponent implements OnChanges {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
this.calculateFullRange();
this.currentIndex.set(0);
this.resetRange();
}
}
@@ -587,12 +595,14 @@ export class LineageTimelineSliderComponent implements OnChanges {
const start = this.fullRange.start.getTime();
const end = this.fullRange.end.getTime();
const value = date.getTime();
if (end === start) return 50;
return ((value - start) / (end - start)) * 100;
}
private percentToDate(percent: number): Date {
const start = this.fullRange.start.getTime();
const end = this.fullRange.end.getTime();
if (end === start) return new Date(start);
return new Date(start + (percent / 100) * (end - start));
}
@@ -702,9 +712,34 @@ export class LineageTimelineSliderComponent implements OnChanges {
this.draggingHandle = handle;
event.preventDefault();
const trackEl = (event.currentTarget as HTMLElement | null)?.closest('.timeline-track') as HTMLElement | null;
if (!trackEl) {
this.draggingHandle = null;
return;
}
const onMouseMove = (e: MouseEvent) => {
if (!this.draggingHandle) return;
// In real implementation, calculate new position based on mouse position
const rect = trackEl.getBoundingClientRect();
if (rect.width <= 0) return;
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const date = this.percentToDate(percent);
const range = this.visibleRange();
if (this.draggingHandle === 'start') {
this.visibleRange.set({
start: date <= range.end ? date : range.end,
end: range.end,
});
} else {
this.visibleRange.set({
start: range.start,
end: date >= range.start ? date : range.start,
});
}
this.emitRangeChange();
};
const onMouseUp = () => {
@@ -717,6 +752,56 @@ export class LineageTimelineSliderComponent implements OnChanges {
document.addEventListener('mouseup', onMouseUp);
}
onTrackClick(event: MouseEvent): void {
if (this.sortedNodes.length === 0) return;
const track = event.currentTarget as HTMLElement;
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return;
const percent = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100));
const clickedDate = this.percentToDate(percent).getTime();
let closestIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < this.sortedNodes.length; i++) {
const distance = Math.abs(new Date(this.sortedNodes[i].createdAt).getTime() - clickedDate);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
this.currentIndex.set(closestIndex);
this.emitNodeSelect();
}
onKeyDown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
this.stepBack();
break;
case 'ArrowRight':
event.preventDefault();
this.stepForward();
break;
case 'Home':
event.preventDefault();
this.currentIndex.set(0);
this.emitNodeSelect();
break;
case 'End':
event.preventDefault();
if (this.sortedNodes.length > 0) {
this.currentIndex.set(this.sortedNodes.length - 1);
this.emitNodeSelect();
}
break;
}
}
zoomIn(): void {
const range = this.visibleRange();
const center = (range.start.getTime() + range.end.getTime()) / 2;

View File

@@ -75,29 +75,43 @@
<button
class="filter-chip"
[class.active]="filterState().changeTypes.has('added')"
(click)="updateFilter({ changeTypes: filterState().changeTypes.has('added') ? (filterState().changeTypes.delete('added'), new Set(filterState().changeTypes)) : new Set(filterState().changeTypes).add('added') })"
aria-pressed="{{ filterState().changeTypes.has('added') }}">
(click)="toggleChangeTypeFilter('added')"
[attr.aria-pressed]="filterState().changeTypes.has('added')">
Added
</button>
<button
class="filter-chip"
[class.active]="filterState().changeTypes.has('removed')"
(click)="updateFilter({ changeTypes: filterState().changeTypes.has('removed') ? (filterState().changeTypes.delete('removed'), new Set(filterState().changeTypes)) : new Set(filterState().changeTypes).add('removed') })"
aria-pressed="{{ filterState().changeTypes.has('removed') }}">
(click)="toggleChangeTypeFilter('removed')"
[attr.aria-pressed]="filterState().changeTypes.has('removed')">
Removed
</button>
<button
class="filter-chip"
[class.active]="filterState().changeTypes.has('version-changed')"
(click)="updateFilter({ changeTypes: filterState().changeTypes.has('version-changed') ? (filterState().changeTypes.delete('version-changed'), new Set(filterState().changeTypes)) : new Set(filterState().changeTypes).add('version-changed') })"
aria-pressed="{{ filterState().changeTypes.has('version-changed') }}">
(click)="toggleChangeTypeFilter('version-changed')"
[attr.aria-pressed]="filterState().changeTypes.has('version-changed')">
Version Changed
</button>
<button
class="filter-chip"
[class.active]="filterState().changeTypes.has('license-changed')"
(click)="toggleChangeTypeFilter('license-changed')"
[attr.aria-pressed]="filterState().changeTypes.has('license-changed')">
License Changed
</button>
<button
class="filter-chip"
[class.active]="filterState().changeTypes.has('both-changed')"
(click)="toggleChangeTypeFilter('both-changed')"
[attr.aria-pressed]="filterState().changeTypes.has('both-changed')">
Both Changed
</button>
<button
class="filter-chip"
[class.active]="filterState().showOnlyVulnerable"
(click)="updateFilter({ showOnlyVulnerable: !filterState().showOnlyVulnerable })"
aria-pressed="{{ filterState().showOnlyVulnerable }}">
[attr.aria-pressed]="filterState().showOnlyVulnerable">
Vulnerable Only
</button>

View File

@@ -4,7 +4,8 @@
* @description Node Diff Table component - displays component changes between SBOM versions
*/
import { Component, Input, Signal, WritableSignal, computed, signal, inject, effect, HostListener } from '@angular/core';
import { Component, Input, Signal, WritableSignal, computed, signal, inject, effect, HostListener, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import {
@@ -29,15 +30,39 @@ import { debounceTime, Subject } from 'rxjs';
})
export class NodeDiffTableComponent {
private readonly lineageService = inject(LineageGraphService);
private readonly destroyRef = inject(DestroyRef);
/** Input: from digest for API mode */
@Input() fromDigest?: string;
@Input() set fromDigest(value: string | undefined) {
this._fromDigest = value;
this.tryFetchDiff();
}
get fromDigest(): string | undefined {
return this._fromDigest;
}
/** Input: to digest for API mode */
@Input() toDigest?: string;
@Input() set toDigest(value: string | undefined) {
this._toDigest = value;
this.tryFetchDiff();
}
get toDigest(): string | undefined {
return this._toDigest;
}
/** Input: tenant ID for API mode */
@Input() tenantId?: string;
@Input() set tenantId(value: string | undefined) {
this._tenantId = value;
this.tryFetchDiff();
}
get tenantId(): string | undefined {
return this._tenantId;
}
private _fromDigest?: string;
private _toDigest?: string;
private _tenantId?: string;
private lastFetchKey: string | null = null;
/** Input: rows to display (direct mode) */
@Input() set rows(value: DiffTableRow[]) {
@@ -59,7 +84,7 @@ export class NodeDiffTableComponent {
pageSizes = [10, 25, 50, 100];
/** Debounced search subject */
private searchSubject = new Subject<string>();
private readonly searchSubject = new Subject<string>();
/** Preferences key for localStorage */
private readonly PREFS_KEY = 'nodeDiffTable.preferences';
@@ -68,17 +93,6 @@ export class NodeDiffTableComponent {
// Load saved preferences
this.loadPreferences();
// Auto-fetch when fromDigest, toDigest, and tenantId are set
effect(() => {
const from = this.fromDigest;
const to = this.toDigest;
const tenant = this.tenantId;
if (from && to && tenant) {
this.fetchDiff(from, to, tenant);
}
});
// Reset to first page when filters change
effect(() => {
this.filterState();
@@ -91,9 +105,28 @@ export class NodeDiffTableComponent {
});
// Debounced search
this.searchSubject.pipe(debounceTime(300)).subscribe(searchTerm => {
this.updateFilter({ searchTerm });
});
this.searchSubject
.pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef))
.subscribe(searchTerm => {
this.updateFilter({ searchTerm });
});
}
/**
* Trigger API fetch when all API-mode inputs are present.
*/
private tryFetchDiff(): void {
if (!this._fromDigest || !this._toDigest || !this._tenantId) {
return;
}
const fetchKey = `${this._tenantId}:${this._fromDigest}:${this._toDigest}`;
if (this.lastFetchKey === fetchKey) {
return;
}
this.lastFetchKey = fetchKey;
this.fetchDiff(this._fromDigest, this._toDigest, this._tenantId);
}
/** Keyboard shortcuts */
@@ -375,6 +408,20 @@ export class NodeDiffTableComponent {
}));
}
/**
* Toggle a change type in the active filter set.
*/
toggleChangeTypeFilter(changeType: DiffTableRow['changeType']): void {
const changeTypes = new Set(this.filterState().changeTypes);
if (changeTypes.has(changeType)) {
changeTypes.delete(changeType);
} else {
changeTypes.add(changeType);
}
this.updateFilter({ changeTypes });
}
/**
* Clear all filters
*/
@@ -666,7 +713,9 @@ export class NodeDiffTableComponent {
this.loading.set(true);
this.error.set(null);
this.lineageService.getDiff(from, to, tenant).subscribe({
this.lineageService.getDiff(from, to, tenant)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const rows = this.transformDiffToRows(response.componentDiff?.added || [], response.componentDiff?.removed || [], response.componentDiff?.changed || []);
this._allRows.set(rows);

View File

@@ -117,7 +117,7 @@ export interface LicenseInfo {
* Filter state for the table.
*/
export interface DiffTableFilter {
changeTypes: Set<'added' | 'removed' | 'version-changed' | 'license-changed'>;
changeTypes: Set<'added' | 'removed' | 'version-changed' | 'license-changed' | 'both-changed'>;
searchTerm: string;
showOnlyVulnerable: boolean;
}

View File

@@ -32,4 +32,12 @@ export const lineageRoutes: Routes = [
(m) => m.LineageCompareComponent
),
},
{
// Deep-link route with explicit artifact digest
path: ':artifactDigest',
loadComponent: () =>
import('./components/lineage-graph-container/lineage-graph-container.component').then(
(m) => m.LineageGraphContainerComponent
),
},
];

View File

@@ -41,7 +41,7 @@ interface LoadedBundle {
@if (loadedBundles().length > 0) {
<div class="bundles-list">
@for (bundle of loadedBundles(); track bundle.id) {
<div class="bundle-card" [class.active]="bundle.status === 'active'" [class.expired]="bundle.status === 'expired'">
<div class="bundle-card" [class.active]="bundle.id === activeBundleId()" [class.expired]="bundle.status === 'expired'">
<div class="bundle-header">
<span class="bundle-version">v{{ bundle.version }}</span>
<span class="bundle-status" [class]="'status-' + bundle.status">
@@ -67,7 +67,7 @@ interface LoadedBundle {
</div>
</div>
<div class="bundle-actions">
@if (bundle.status === 'active') {
@if (bundle.id !== activeBundleId() && bundle.status !== 'invalid') {
<button class="btn btn--small btn--secondary" (click)="setActive(bundle)">
Set Active
</button>
@@ -358,6 +358,7 @@ export class BundleManagementComponent implements OnInit {
private readonly offlineService = inject(OfflineModeService);
readonly loadedBundles = signal<LoadedBundle[]>([]);
readonly activeBundleId = signal<string | null>(null);
readonly activeManifest = this.offlineService.cachedManifest;
readonly assetCategories = signal<{ name: string; icon: string; count: number; assets: string[] }[]>([]);
@@ -368,8 +369,27 @@ export class BundleManagementComponent implements OnInit {
onManifestValidated(result: BundleValidationResult): void {
if (result.valid) {
// In production, this would parse the actual manifest
console.log('Manifest validated successfully');
const now = new Date();
const createdAt = now.toISOString();
const bundle: LoadedBundle = {
id: `bundle-${Date.now()}`,
version: now.toISOString().slice(0, 10).replace(/-/g, '.'),
createdAt,
expiresAt: new Date(now.getTime() + (30 * 24 * 60 * 60 * 1000)).toISOString(),
size: 0,
assetCount: result.assetIntegrity.totalAssets,
status: 'active'
};
this.loadedBundles.update((bundles) => [
bundle,
...bundles.map((existing) =>
existing.status === 'active'
? { ...existing, status: 'expired' as const }
: existing
)
]);
this.activeBundleId.set(bundle.id);
}
}
@@ -388,7 +408,22 @@ export class BundleManagementComponent implements OnInit {
}
setActive(bundle: LoadedBundle): void {
console.log('Setting active bundle:', bundle.id);
if (bundle.status === 'invalid') {
return;
}
this.loadedBundles.update((bundles) =>
bundles.map((existing) => {
if (existing.id === bundle.id) {
return { ...existing, status: 'active' as const };
}
if (existing.status === 'active') {
return { ...existing, status: 'expired' as const };
}
return existing;
})
);
this.activeBundleId.set(bundle.id);
}
exportBundle(bundle: LoadedBundle): void {
@@ -397,15 +432,21 @@ export class BundleManagementComponent implements OnInit {
removeBundle(bundle: LoadedBundle): void {
if (confirm(`Remove bundle v${bundle.version}?`)) {
this.loadedBundles.update(bundles =>
bundles.filter(b => b.id !== bundle.id)
);
this.loadedBundles.update((bundles) => bundles.filter((b) => b.id !== bundle.id));
if (this.activeBundleId() === bundle.id) {
const fallback = this.loadedBundles().find((candidate) => candidate.status !== 'invalid');
this.activeBundleId.set(fallback?.id ?? null);
if (fallback) {
this.setActive(fallback);
}
}
}
}
private loadBundles(): void {
// Mock data - in production, load from IndexedDB or cache
this.loadedBundles.set([
const bundles: LoadedBundle[] = [
{
id: 'bundle-001',
version: '2025.01.15',
@@ -424,7 +465,11 @@ export class BundleManagementComponent implements OnInit {
assetCount: 42,
status: 'expired'
}
]);
];
this.loadedBundles.set(bundles);
const active = bundles.find((bundle) => bundle.status === 'active');
this.activeBundleId.set(active?.id ?? null);
}
private loadAssetCategories(): void {

View File

@@ -0,0 +1,127 @@
import { Routes } from '@angular/router';
import { requireOrchOperatorGuard, requireOrchViewerGuard } from '../../core/auth';
export const OPERATIONS_ROUTES: Routes = [
{
path: '',
redirectTo: 'orchestrator',
pathMatch: 'full',
},
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'scheduler',
loadChildren: () =>
import('../scheduler-ops/scheduler-ops.routes').then(
(m) => m.schedulerOpsRoutes
),
},
{
path: 'quotas',
loadChildren: () =>
import('../quota-dashboard/quota.routes').then((m) => m.quotaRoutes),
},
{
path: 'dead-letter',
loadChildren: () =>
import('../deadletter/deadletter.routes').then((m) => m.deadletterRoutes),
},
{
path: 'deadletter',
redirectTo: 'dead-letter',
pathMatch: 'full',
},
{
path: 'slo',
loadChildren: () =>
import('../slo-monitoring/slo.routes').then((m) => m.sloRoutes),
},
{
path: 'health',
loadChildren: () =>
import('../platform-health/platform-health.routes').then(
(m) => m.platformHealthRoutes
),
},
{
path: 'feeds',
loadChildren: () =>
import('../feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes),
},
{
path: 'offline-kit',
loadChildren: () =>
import('../offline-kit/offline-kit.routes').then((m) => m.offlineKitRoutes),
},
{
path: 'aoc',
loadChildren: () =>
import('../aoc-compliance/aoc-compliance.routes').then(
(m) => m.AOC_COMPLIANCE_ROUTES
),
},
{
path: 'doctor',
loadChildren: () =>
import('../doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
},
{
path: 'ai-runs',
loadComponent: () =>
import('../ai-runs/ai-runs-list.component').then(
(m) => m.AiRunsListComponent
),
},
{
path: 'ai-runs/:runId',
loadComponent: () =>
import('../ai-runs/ai-run-viewer.component').then(
(m) => m.AiRunViewerComponent
),
},
{
path: 'notifications',
loadComponent: () =>
import('../notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'status',
loadComponent: () =>
import('../console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
];

View File

@@ -147,6 +147,8 @@ export class PlaybookSuggestionService {
reachability: query.reachability,
componentType: query.componentType,
contextTags: query.contextTags,
maxResults: query.maxResults,
minConfidence: query.minConfidence,
});
}

View File

@@ -51,6 +51,12 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
}
</header>
@if (loadError()) {
<section class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-800">{{ loadError() }}</p>
</section>
}
<!-- KPI Strip -->
@if (summary()) {
<section class="grid grid-cols-5 gap-4 mb-6">
@@ -161,7 +167,9 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
</select>
</div>
<div class="p-4">
@if (groupBy() === 'state') {
@if (summary() && summary()!.services.length === 0) {
<p class="py-6 text-center text-sm text-gray-500">No services available in current snapshot</p>
} @else if (groupBy() === 'state') {
<!-- Grouped by state -->
@if (unhealthyServices().length > 0) {
<div class="mb-4">
@@ -348,6 +356,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
dependencyGraph = signal<DependencyGraph | null>(null);
incidents = signal<Incident[]>([]);
loading = signal(false);
loadError = signal<string | null>(null);
groupBy = signal<'state' | 'none'>('state');
// Expose constants
@@ -389,6 +398,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
takeUntil(this.destroy$),
switchMap(() => {
this.loading.set(true);
this.loadError.set(null);
return forkJoin({
summary: this.healthClient.getSummary(),
graph: this.healthClient.getDependencyGraph(),
@@ -401,9 +411,13 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
this.summary.set(summary);
this.dependencyGraph.set(graph);
this.incidents.set(incidents.incidents);
this.loadError.set(null);
this.loading.set(false);
},
error: () => {
this.loadError.set('Unable to load platform health data. Try refreshing.');
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
@@ -414,6 +428,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
refresh(): void {
this.loading.set(true);
this.loadError.set(null);
forkJoin({
summary: this.healthClient.getSummary(),
graph: this.healthClient.getDependencyGraph(),
@@ -423,9 +438,13 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
this.summary.set(summary);
this.dependencyGraph.set(graph);
this.incidents.set(incidents.incidents);
this.loadError.set(null);
this.loading.set(false);
},
error: () => {
this.loadError.set('Unable to load platform health data. Try refreshing.');
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}

View File

@@ -203,6 +203,12 @@ import { GateSimulationResultsComponent } from '../gate-simulation-results/gate-
</div>
</div>
@if (simulationError()) {
<div class="simulation-error" role="alert">
{{ simulationError() }}
</div>
}
@if (simulationResult(); as result) {
<app-gate-simulation-results [result]="result" />
}
@@ -458,6 +464,17 @@ import { GateSimulationResultsComponent } from '../gate-simulation-results/gate-
border-top: 1px solid var(--color-text-primary);
}
.simulation-error {
margin-bottom: 1rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(239, 68, 68, 0.45);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
font-size: 0.8125rem;
font-weight: 500;
}
.simulation-header {
display: flex;
align-items: center;
@@ -569,10 +586,12 @@ export class PolicyPreviewPanelComponent {
readonly simulationResult = signal<PolicySimulationResult | null>(null);
readonly simulating = signal<boolean>(false);
readonly forceFresh = signal<boolean>(false);
readonly simulationError = signal<string | null>(null);
onProfileSelected(profile: PolicyProfile): void {
this.selectedProfile.set(profile);
this.simulationResult.set(null);
this.simulationError.set(null);
}
toggleForceFresh(): void {
@@ -591,13 +610,16 @@ export class PolicyPreviewPanelComponent {
forceFresh: this.forceFresh(),
};
this.simulationError.set(null);
this.simulating.set(true);
this.api.simulate(request).subscribe({
next: (result) => {
this.simulationResult.set(result);
this.simulationError.set(null);
this.simulating.set(false);
},
error: () => {
this.simulationError.set('Simulation failed. Please retry.');
this.simulating.set(false);
},
});

View File

@@ -1,7 +1,16 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormArray, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import {
FormBuilder,
FormGroup,
FormArray,
ReactiveFormsModule,
Validators,
AbstractControl,
ValidationErrors,
ValidatorFn,
} from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { finalize } from 'rxjs/operators';
import {
@@ -39,6 +48,14 @@ import {
</div>
</header>
@if (loadError()) {
<div class="error-state" role="alert">{{ loadError() }}</div>
}
@if (saveError()) {
<div class="error-state" role="alert">{{ saveError() }}</div>
}
<form [formGroup]="form" class="config__form">
<!-- Basic Settings -->
<section class="config__section">
@@ -97,9 +114,9 @@ import {
<div class="threshold-preview">
<div class="threshold-preview__bar">
<div class="threshold-preview__zone threshold-preview__zone--healthy" [style.width.%]="form.value.warningThreshold"></div>
<div class="threshold-preview__zone threshold-preview__zone--warning" [style.width.%]="(form.value.criticalThreshold || 0) - (form.value.warningThreshold || 0)"></div>
<div class="threshold-preview__zone threshold-preview__zone--critical" [style.width.%]="100 - (form.value.criticalThreshold || 0)"></div>
<div class="threshold-preview__zone threshold-preview__zone--healthy" [style.width.%]="warningZoneWidth()"></div>
<div class="threshold-preview__zone threshold-preview__zone--warning" [style.width.%]="warningBandWidth()"></div>
<div class="threshold-preview__zone threshold-preview__zone--critical" [style.width.%]="criticalZoneWidth()"></div>
</div>
<div class="threshold-preview__labels">
<span>0%</span>
@@ -108,6 +125,10 @@ import {
<span>100%</span>
</div>
</div>
@if (form.hasError('thresholdOrder') && (form.touched || form.dirty)) {
<p class="form-error" role="alert">Warning threshold must be lower than critical threshold.</p>
}
</section>
<!-- Enforcement Settings -->
@@ -238,6 +259,17 @@ import {
gap: 0.5rem;
}
.error-state {
margin-bottom: 1rem;
padding: 0.625rem 0.75rem;
border: 1px solid rgba(239, 68, 68, 0.45);
border-radius: 8px;
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
font-size: 0.875rem;
font-weight: 500;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
@@ -412,6 +444,13 @@ import {
.threshold-preview__label--warning { color: #eab308; }
.threshold-preview__label--critical { color: #ef4444; }
.form-error {
margin: 0.5rem 0 0;
color: #fca5a5;
font-size: 0.8rem;
font-weight: 500;
}
/* Threshold List */
.threshold-list {
display: flex;
@@ -480,9 +519,13 @@ export class RiskBudgetConfigComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
protected readonly loading = signal(false);
protected readonly saving = signal(false);
protected readonly loadError = signal<string | null>(null);
protected readonly saveError = signal<string | null>(null);
private readonly loadedConfig = signal<RiskBudgetGovernance | null>(null);
protected readonly form: FormGroup = this.fb.group({
name: ['', Validators.required],
@@ -497,7 +540,7 @@ export class RiskBudgetConfigComponent implements OnInit {
autoReset: [true],
carryoverPercent: [0, [Validators.min(0), Validators.max(100)]],
thresholds: this.fb.array([]),
});
}, { validators: [this.thresholdOrderValidator()] });
get thresholds(): FormArray {
return this.form.get('thresholds') as FormArray;
@@ -507,7 +550,51 @@ export class RiskBudgetConfigComponent implements OnInit {
this.loadConfig();
}
private thresholdOrderValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const warning = Number(control.get('warningThreshold')?.value);
const critical = Number(control.get('criticalThreshold')?.value);
if (!Number.isFinite(warning) || !Number.isFinite(critical)) {
return null;
}
return warning < critical ? null : { thresholdOrder: true };
};
}
private clampPercent(value: unknown): number {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.min(100, Math.max(0, numeric));
}
protected warningZoneWidth(): number {
return this.clampPercent(this.form.get('warningThreshold')?.value);
}
protected warningBandWidth(): number {
const warning = this.warningZoneWidth();
const critical = this.clampPercent(this.form.get('criticalThreshold')?.value);
return Math.max(0, critical - warning);
}
protected criticalZoneWidth(): number {
const critical = this.clampPercent(this.form.get('criticalThreshold')?.value);
return Math.max(0, 100 - critical);
}
private toIsoDate(value: string): string {
if (value.includes('T')) {
return new Date(value).toISOString();
}
return new Date(`${value}T00:00:00.000Z`).toISOString();
}
private loadConfig(): void {
this.loadError.set(null);
this.loading.set(true);
this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
@@ -515,6 +602,7 @@ export class RiskBudgetConfigComponent implements OnInit {
.subscribe({
next: (dashboard) => {
const config = dashboard.governance;
this.loadedConfig.set(config);
this.form.patchValue({
name: config.name,
totalBudget: config.totalBudget,
@@ -528,6 +616,8 @@ export class RiskBudgetConfigComponent implements OnInit {
autoReset: config.autoReset,
carryoverPercent: config.carryoverPercent,
});
this.form.markAsPristine();
this.loadError.set(null);
// Load thresholds
this.thresholds.clear();
@@ -535,7 +625,10 @@ export class RiskBudgetConfigComponent implements OnInit {
this.thresholds.push(this.createThresholdGroup(threshold));
}
},
error: (err) => console.error('Failed to load config:', err),
error: (err) => {
this.loadError.set('Unable to load budget configuration. Please retry.');
console.error('Failed to load config:', err);
},
});
}
@@ -576,26 +669,35 @@ export class RiskBudgetConfigComponent implements OnInit {
}
protected onSave(): void {
if (!this.form.valid) return;
if (!this.form.valid) {
this.form.markAllAsTouched();
if (this.form.hasError('thresholdOrder')) {
this.saveError.set('Warning threshold must be lower than critical threshold.');
}
return;
}
this.saveError.set(null);
this.saving.set(true);
const formValue = this.form.value;
const baseline = this.loadedConfig();
const config: RiskBudgetGovernance = {
id: 'budget-001',
tenantId: 'acme-tenant',
id: baseline?.id ?? 'budget-001',
tenantId: baseline?.tenantId ?? 'acme-tenant',
projectId: baseline?.projectId,
name: formValue.name,
totalBudget: formValue.totalBudget,
period: formValue.period,
periodStart: new Date(formValue.periodStart).toISOString(),
periodEnd: new Date(formValue.periodEnd).toISOString(),
periodStart: this.toIsoDate(formValue.periodStart),
periodEnd: this.toIsoDate(formValue.periodEnd),
warningThreshold: formValue.warningThreshold,
criticalThreshold: formValue.criticalThreshold,
enforceHardLimits: formValue.enforceHardLimits,
gracePeriodHours: formValue.gracePeriodHours,
autoReset: formValue.autoReset,
carryoverPercent: formValue.carryoverPercent,
createdAt: new Date().toISOString(),
createdAt: baseline?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
thresholds: formValue.thresholds.map((t: { level: number; severity: string; actions: string[] }) => ({
level: t.level,
@@ -608,8 +710,15 @@ export class RiskBudgetConfigComponent implements OnInit {
.updateRiskBudgetConfig(config, { tenantId: 'acme-tenant' })
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: () => this.router.navigate(['../'], { relativeTo: undefined }),
error: (err) => console.error('Failed to save config:', err),
next: (updatedConfig) => {
this.loadedConfig.set(updatedConfig);
this.form.markAsPristine();
this.router.navigate(['../'], { relativeTo: this.route });
},
error: (err) => {
this.saveError.set('Unable to save budget configuration. Please retry.');
console.error('Failed to save config:', err);
},
});
}
}

View File

@@ -36,6 +36,10 @@ import {
</div>
</header>
@if (loadError()) {
<div class="error-state" role="alert">{{ loadError() }}</div>
}
@if (data(); as d) {
<!-- KPI Cards -->
<div class="kpi-grid">
@@ -219,6 +223,17 @@ import {
font-size: 0.9rem;
}
.error-state {
margin-bottom: 1rem;
padding: 0.625rem 0.75rem;
border: 1px solid rgba(239, 68, 68, 0.45);
border-radius: 8px;
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
font-size: 0.875rem;
font-weight: 500;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
@@ -616,19 +631,28 @@ export class RiskBudgetDashboardComponent implements OnInit {
protected readonly loading = signal(false);
protected readonly data = signal<RiskBudgetDashboard | null>(null);
protected readonly loadError = signal<string | null>(null);
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
this.loadError.set(null);
this.loading.set(true);
this.api
.getRiskBudgetDashboard({ tenantId: 'acme-tenant' })
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (dashboard) => this.data.set(dashboard),
error: (err) => console.error('Failed to load budget dashboard:', err),
next: (dashboard) => {
this.data.set(dashboard);
this.loadError.set(null);
},
error: (err) => {
this.data.set(null);
this.loadError.set('Unable to load budget dashboard. Please retry.');
console.error('Failed to load budget dashboard:', err);
},
});
}

View File

@@ -12,6 +12,7 @@ import {
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, Subscription, debounceTime, distinctUntilChanged } from 'rxjs';
import { finalize } from 'rxjs/operators';
import type * as Monaco from 'monaco-editor';
@@ -681,30 +682,42 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.linting = true;
this.policyApi.lint(content).subscribe({
next: (lint) => {
this.applyDiagnostics(lint);
this.lastLintAt = new Date().toISOString();
this.updateChecklist(lint);
},
error: () => {
this.diagnostics = [
{
severity: 'error',
code: 'lint/failed',
message: 'Lint request failed. Please retry.',
line: 1,
column: 1,
source: 'policy-lint',
},
];
this.applyMarkers(this.diagnostics);
},
complete: () => {
this.linting = false;
this.cdr.markForCheck();
},
});
this.policyApi
.lint(content)
.pipe(
finalize(() => {
this.linting = false;
this.cdr.markForCheck();
})
)
.subscribe({
next: (lint) => {
this.applyDiagnostics(lint);
this.lastLintAt = new Date().toISOString();
this.updateChecklist(lint);
},
error: () => {
const fallbackLint: PolicyLintResult = {
valid: false,
errors: [
{
severity: 'error',
code: 'lint/failed',
message: 'Lint request failed. Please retry.',
line: 1,
column: 1,
source: 'policy-lint',
},
],
warnings: [],
info: [],
};
this.diagnostics = [...fallbackLint.errors];
this.applyMarkers(this.diagnostics);
this.updateChecklist(fallbackLint);
},
});
}
private applyDiagnostics(lint: PolicyLintResult): void {

View File

@@ -21,6 +21,15 @@ export const POLICY_ROUTES: Routes = [
import('./policy-studio.component').then(m => m.PolicyStudioComponent),
data: { breadcrumb: 'Policy Packs' },
},
// Trust Algebra panel workbench
{
path: 'trust-algebra',
loadComponent: () =>
import('../vulnerabilities/components/trust-algebra/trust-algebra-workbench.component').then(
m => m.TrustAlgebraWorkbenchComponent
),
data: { breadcrumb: 'Trust Algebra' },
},
// SEC-007: Exceptions
{
path: 'exceptions',

View File

@@ -52,42 +52,50 @@
<!-- Graph Visualization -->
<div class="proof-chain-graph">
<div #graphContainer class="graph-container"></div>
@if (selectedNode(); as node) {
<div class="node-info-panel">
<div class="node-info-header">
<h3>{{ node.type }} Details</h3>
<button class="btn-close" (click)="selectedNode.set(null)">×</button>
</div>
<div class="node-info-content">
<div class="info-row">
<label>Node ID:</label>
<code>{{ node.nodeId }}</code>
</div>
<div class="info-row">
<label>Digest:</label>
<code class="digest">{{ node.digest }}</code>
</div>
<div class="info-row">
<label>Created:</label>
<span>{{ node.createdAt | date: 'medium' }}</span>
</div>
@if (node.rekorLogIndex) {
<div class="info-row">
<label>Rekor Log Index:</label>
<span>{{ node.rekorLogIndex }}</span>
</div>
}
@if (showVerification) {
<div class="info-actions">
<button class="btn-primary" (click)="requestVerification()">
Verify Proof
</button>
</div>
}
</div>
@if (nodeCount() === 0) {
<div class="empty-graph-state" role="status" aria-live="polite">
<p class="empty-graph-title">No proof nodes are available for this subject.</p>
<p class="empty-graph-description">Refresh to check whether new attestations were published.</p>
<button class="btn-secondary" type="button" (click)="refresh()">Refresh graph</button>
</div>
} @else {
<div #graphContainer class="graph-container"></div>
@if (selectedNode(); as node) {
<div class="node-info-panel">
<div class="node-info-header">
<h3>{{ node.type }} Details</h3>
<button class="btn-close" (click)="selectedNode.set(null)">×</button>
</div>
<div class="node-info-content">
<div class="info-row">
<label>Node ID:</label>
<code>{{ node.nodeId }}</code>
</div>
<div class="info-row">
<label>Digest:</label>
<code class="digest">{{ node.digest }}</code>
</div>
<div class="info-row">
<label>Created:</label>
<span>{{ node.createdAt | date: 'medium' }}</span>
</div>
@if (node.rekorLogIndex) {
<div class="info-row">
<label>Rekor Log Index:</label>
<span>{{ node.rekorLogIndex }}</span>
</div>
}
@if (showVerification) {
<div class="info-actions">
<button class="btn-primary" (click)="requestVerification()">
Verify Proof
</button>
</div>
}
</div>
</div>
}
}
</div>

View File

@@ -166,6 +166,31 @@
gap: var(--space-4);
}
.empty-graph-state {
width: 100%;
min-height: 240px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
.empty-graph-title {
margin: 0;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.empty-graph-description {
margin: 0;
color: var(--color-text-muted);
}
}
.graph-container {
flex: 1;
min-height: 400px;
@@ -198,6 +223,11 @@
box-shadow: var(--shadow-md);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
&-sbom {
border-color: var(--color-status-success);
}

View File

@@ -4,6 +4,7 @@ import {
Output,
EventEmitter,
OnInit,
AfterViewInit,
OnDestroy,
ViewChild,
ElementRef,
@@ -14,7 +15,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProofChainService } from './proof-chain.service';
import { ProofNode, ProofEdge, ProofChainResponse } from './proof-chain.models';
import { ProofNode, ProofChainResponse } from './proof-chain.models';
/**
* Proof Chain Component
@@ -47,7 +48,7 @@ import { ProofNode, ProofEdge, ProofChainResponse } from './proof-chain.models';
styleUrls: ['./proof-chain.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProofChainComponent implements OnInit, OnDestroy {
export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() subjectDigest!: string;
@Input() showVerification = true;
@Input() expandedView = false;
@@ -78,7 +79,7 @@ export class ProofChainComponent implements OnInit, OnDestroy {
// Effect to reload graph when proof chain data changes
effect(() => {
const chain = this.proofChain();
if (chain && this.graphContainer) {
if (chain && chain.nodes.length > 0 && this.graphContainer) {
this.renderGraph(chain);
}
});
@@ -88,6 +89,13 @@ export class ProofChainComponent implements OnInit, OnDestroy {
this.loadProofChain();
}
ngAfterViewInit(): void {
const chain = this.proofChain();
if (chain && chain.nodes.length > 0 && this.graphContainer) {
this.renderGraph(chain);
}
}
ngOnDestroy(): void {
if (this.cytoscapeInstance) {
this.cytoscapeInstance.destroy();
@@ -125,7 +133,6 @@ export class ProofChainComponent implements OnInit, OnDestroy {
*/
private renderGraph(chain: ProofChainResponse): void {
if (!this.graphContainer) {
console.warn('Graph container not available');
return;
}
@@ -197,6 +204,10 @@ export class ProofChainComponent implements OnInit, OnDestroy {
const container = this.graphContainer.nativeElement;
container.innerHTML = '';
if (chain.nodes.length === 0) {
return;
}
// Create a simple tree-like visualization
const tree = document.createElement('div');
tree.className = 'proof-chain-tree';
@@ -214,6 +225,10 @@ export class ProofChainComponent implements OnInit, OnDestroy {
`;
nodeEl.addEventListener('click', () => this.onNodeClick(node));
nodeEl.addEventListener('keydown', (event) => this.onNodeKeydown(event, node));
nodeEl.tabIndex = 0;
nodeEl.setAttribute('role', 'button');
nodeEl.setAttribute('aria-label', this.getNodeAriaLabel(node));
tree.appendChild(nodeEl);
@@ -237,6 +252,19 @@ export class ProofChainComponent implements OnInit, OnDestroy {
this.nodeSelected.emit(node);
}
private onNodeKeydown(event: KeyboardEvent, node: ProofNode): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.onNodeClick(node);
}
}
private getNodeAriaLabel(node: ProofNode): string {
const digestPreview = node.digest.slice(0, 12);
const verificationLabel = node.rekorLogIndex ? 'verified' : 'unverified';
return `${node.type} proof node ${digestPreview}, ${verificationLabel}`;
}
/**
* Trigger verification for selected node
*/

View File

@@ -24,7 +24,7 @@ import { ConfidenceFactor, FactorSource } from '../../models/proof-trace.model';
<span class="chip-icon" aria-hidden="true">{{ icon() }}</span>
<span class="chip-label">{{ label() }}</span>
<span class="chip-score">{{ scoreDisplay() }}</span>
@if (removable()) {
@if (removable) {
<button
class="chip-remove"
(click)="handleRemove($event)"

View File

@@ -29,10 +29,12 @@ import { ProofTrace, FindingKey } from '../../models/proof-trace.model';
})
export class ProofStudioContainerComponent implements OnInit {
private readonly service = inject(ProofStudioService);
private lastLoadedMode: 'finding' | 'cgs' | null = null;
private lastLoadedKey: string | null = null;
@Input() set findingKey(value: FindingKey | null) {
this._findingKey.set(value);
if (value) {
if (value && !this._cgsHash()) {
this.loadProofTrace(value);
}
}
@@ -96,6 +98,13 @@ export class ProofStudioContainerComponent implements OnInit {
}
private loadProofTrace(findingKey: FindingKey): void {
const loadKey = `${findingKey.cveId}|${findingKey.purl}|${findingKey.artifactDigest}`;
if (this.lastLoadedMode === 'finding' && this.lastLoadedKey === loadKey && !this.error()) {
return;
}
this.lastLoadedMode = 'finding';
this.lastLoadedKey = loadKey;
this.loading.set(true);
this.error.set(null);
@@ -109,13 +118,19 @@ export class ProofStudioContainerComponent implements OnInit {
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load proof trace');
this.error.set(this.extractErrorMessage(err, 'Failed to load proof trace'));
this.loading.set(false);
}
});
}
private loadProofTraceByCgsHash(cgsHash: string): void {
if (this.lastLoadedMode === 'cgs' && this.lastLoadedKey === cgsHash && !this.error()) {
return;
}
this.lastLoadedMode = 'cgs';
this.lastLoadedKey = cgsHash;
this.loading.set(true);
this.error.set(null);
@@ -125,7 +140,7 @@ export class ProofStudioContainerComponent implements OnInit {
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load proof trace');
this.error.set(this.extractErrorMessage(err, 'Failed to load proof trace'));
this.loading.set(false);
}
});
@@ -150,7 +165,7 @@ export class ProofStudioContainerComponent implements OnInit {
}
} catch (err) {
this.replayInProgress.set(false);
this.error.set('Replay failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
this.error.set('Replay failed: ' + this.extractErrorMessage(err));
}
}
@@ -167,7 +182,7 @@ export class ProofStudioContainerComponent implements OnInit {
console.log('What-if simulation complete', simulation);
},
error: (err) => {
this.error.set('Simulation failed: ' + err.message);
this.error.set('Simulation failed: ' + this.extractErrorMessage(err));
}
});
}
@@ -179,9 +194,27 @@ export class ProofStudioContainerComponent implements OnInit {
formatTimestamp(timestamp: string): string {
try {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return timestamp;
}
return date.toLocaleString();
} catch {
return timestamp;
}
}
private extractErrorMessage(err: unknown, fallback = 'Unknown error'): string {
if (err instanceof Error && err.message) {
return err.message;
}
if (typeof err === 'object' && err !== null && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string' && message.trim().length > 0) {
return message;
}
}
return fallback;
}
}

View File

@@ -1,4 +1,4 @@
/**
/**
* Proof Ledger View Component
* Sprint: SPRINT_3500_0004_0002 - T1
*
@@ -41,7 +41,7 @@ interface TreeViewState {
<!-- Header -->
<header class="proof-ledger__header">
<h2 class="proof-ledger__title">
<span class="proof-ledger__icon" aria-hidden="true">📜</span>
<span class="proof-ledger__icon" aria-hidden="true">📜</span>
Proof Ledger
</h2>
<div class="proof-ledger__actions">
@@ -52,7 +52,7 @@ interface TreeViewState {
(click)="downloadBundle()"
aria-label="Download proof bundle"
>
<span aria-hidden="true">⬇️</span> Download Bundle
<span aria-hidden="true">⬇️</span> Download Bundle
</button>
<button
type="button"
@@ -61,7 +61,7 @@ interface TreeViewState {
(click)="verifyBundle()"
aria-label="Verify proof bundle"
>
<span aria-hidden="true"></span> Verify
<span aria-hidden="true">✓</span> Verify
</button>
</div>
</header>
@@ -77,8 +77,16 @@ interface TreeViewState {
<!-- Error state -->
@if (error()) {
<div class="proof-ledger__error" role="alert">
<span aria-hidden="true">⚠️</span>
<span aria-hidden="true">⚠️</span>
{{ error() }}
<button
type="button"
class="proof-ledger__retry-btn"
(click)="reload()"
aria-label="Retry loading proof ledger"
>
Retry
</button>
</div>
}
@@ -88,7 +96,7 @@ interface TreeViewState {
<!-- Verification Status Banner -->
<section class="proof-ledger__status" [class]="'proof-ledger__status--' + verificationStatus()">
<span class="proof-ledger__status-icon" aria-hidden="true">
{{ verificationStatus() === 'verified' ? '' : verificationStatus() === 'failed' ? '' : '' }}
{{ verificationStatus() === 'verified' ? '✅' : verificationStatus() === 'failed' ? '❌' : '⏳' }}
</span>
<span class="proof-ledger__status-text">
{{ verificationStatusText() }}
@@ -100,7 +108,7 @@ interface TreeViewState {
target="_blank"
rel="noopener noreferrer"
>
View in Rekor
View in Rekor ↗
</a>
}
</section>
@@ -108,7 +116,7 @@ interface TreeViewState {
<!-- Scan Manifest Section -->
<section class="proof-ledger__section">
<h3 class="proof-ledger__section-title">
<span aria-hidden="true">📋</span> Scan Manifest
<span aria-hidden="true">📋</span> Scan Manifest
</h3>
<div class="proof-ledger__manifest">
<div class="proof-ledger__manifest-row">
@@ -137,7 +145,7 @@ interface TreeViewState {
<!-- Input Hashes Section -->
<section class="proof-ledger__section">
<h3 class="proof-ledger__section-title">
<span aria-hidden="true">🔐</span> Input Hashes
<span aria-hidden="true">🔐</span> Input Hashes
</h3>
<div class="proof-ledger__hashes">
@for (hash of hashDisplays(); track hash.value) {
@@ -153,9 +161,9 @@ interface TreeViewState {
(click)="copyHash(hash)"
[attr.aria-label]="'Copy ' + hash.label + ' hash'"
>
{{ hash.copied ? '' : '📋' }}
{{ hash.copied ? '✓' : '📋' }}
</button>
</div>
</div>
}
</div>
</section>
@@ -163,7 +171,7 @@ interface TreeViewState {
<!-- Merkle Tree Section -->
<section class="proof-ledger__section">
<h3 class="proof-ledger__section-title">
<span aria-hidden="true">🌳</span> Merkle Tree
<span aria-hidden="true">🌳</span> Merkle Tree
<button
type="button"
class="proof-ledger__expand-btn"
@@ -181,7 +189,7 @@ interface TreeViewState {
>
<div class="proof-ledger__tree-root">
<div class="proof-ledger__tree-node proof-ledger__tree-node--root">
<span class="proof-ledger__node-icon" aria-hidden="true">🔷</span>
<span class="proof-ledger__node-icon" aria-hidden="true">🔷</span>
<span class="proof-ledger__node-label">Root</span>
<code class="proof-ledger__node-hash">{{ merkleTree()!.root.hash | slice:0:12 }}...</code>
</div>
@@ -200,7 +208,7 @@ interface TreeViewState {
<!-- DSSE Signature Section -->
<section class="proof-ledger__section">
<h3 class="proof-ledger__section-title">
<span aria-hidden="true">✍️</span> DSSE Signature
<span aria-hidden="true">✍️</span> DSSE Signature
</h3>
@if (proofBundle()?.signatures?.length) {
<div class="proof-ledger__signature">
@@ -252,11 +260,11 @@ interface TreeViewState {
(click)="toggleNode(node.nodeId)"
[attr.aria-label]="treeState().expandedNodes.has(node.nodeId) ? 'Collapse' : 'Expand'"
>
{{ treeState().expandedNodes.has(node.nodeId) ? '' : '' }}
{{ treeState().expandedNodes.has(node.nodeId) ? 'â–¼' : 'â–¶' }}
</button>
}
<span class="proof-ledger__node-icon" aria-hidden="true">
{{ node.isLeaf ? '📄' : '📁' }}
{{ node.isLeaf ? '📄' : '📁' }}
</span>
<span class="proof-ledger__node-label">{{ node.label || 'Node' }}</span>
<code class="proof-ledger__node-hash">{{ node.hash | slice:0:12 }}...</code>
@@ -345,13 +353,20 @@ interface TreeViewState {
@keyframes spin {
to { transform: rotate(360deg); }
}
.proof-ledger__error {
background: var(--error-bg);
color: var(--error-text);
padding: 1rem;
.proof-ledger__retry-btn {
margin-left: 0.75rem;
padding: 0.25rem 0.625rem;
border: 1px solid currentColor;
border-radius: 4px;
margin-bottom: 1rem;
background: transparent;
color: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.proof-ledger__retry-btn:hover {
background: color-mix(in srgb, currentColor 10%, transparent);
}
.proof-ledger__status {
@@ -568,7 +583,23 @@ export class ProofLedgerViewComponent implements OnInit {
readonly hashDisplays = computed<HashDisplay[]>(() => {
const m = this.manifest();
if (!m) return [];
return m.hashes.map(h => ({
const sortedHashes = [...m.hashes].sort((left, right) => {
if (left.label !== right.label) {
return left.label < right.label ? -1 : 1;
}
if (left.algorithm !== right.algorithm) {
return left.algorithm < right.algorithm ? -1 : 1;
}
if (left.value !== right.value) {
return left.value < right.value ? -1 : 1;
}
if (left.source !== right.source) {
return left.source < right.source ? -1 : 1;
}
return 0;
});
return sortedHashes.map(h => ({
label: h.label,
algorithm: h.algorithm,
value: h.value,
@@ -662,6 +693,10 @@ export class ProofLedgerViewComponent implements OnInit {
});
}
reload(): void {
this.loadProofData();
}
downloadBundle(): void {
const bundle = this.proofBundle();
if (!bundle) return;
@@ -697,3 +732,6 @@ export class ProofLedgerViewComponent implements OnInit {
});
}
}

View File

@@ -0,0 +1,866 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { FunctionChangeInfo, ResolutionEvidence, VulnResolutionSummary } from '../../core/api/binary-resolution.models';
import { EntropyAnalysis } from '../../core/api/entropy.models';
import {
DecayInfo,
GuardrailsInfo,
ObservationState,
PolicyVerdictStatus,
} from '../../core/models/determinization.models';
import { AskStellaContext, AskStellaPanelComponent, SuggestedPrompt } from '../../shared/components/ai/ask-stella-panel.component';
import {
BinaryDiffData,
BinaryDiffPanelComponent,
DiffScopeLevel,
} from '../../shared/components/binary-diff/binary-diff-panel.component';
import { DecayProgressComponent } from '../../shared/components/determinization/decay-progress/decay-progress.component';
import { GuardrailsBadgeComponent } from '../../shared/components/determinization/guardrails-badge/guardrails-badge.component';
import { ObservationStateChipComponent } from '../../shared/components/determinization/observation-state-chip/observation-state-chip.component';
import { UncertaintyIndicatorComponent } from '../../shared/components/determinization/uncertainty-indicator/uncertainty-indicator.component';
import { EvidenceDrawerComponent } from '../../shared/components/evidence-drawer/evidence-drawer.component';
import { FunctionDiffComponent } from '../../shared/components/function-diff/function-diff.component';
import { EntropyPanelComponent } from '../../shared/components/entropy-panel.component';
import {
EntropyMitigationStep,
EntropyPolicyBannerComponent,
EntropyPolicyConfig,
} from '../../shared/components/entropy-policy-banner.component';
import { ResolutionChipComponent } from '../../shared/components/resolution-chip/resolution-chip.component';
import { GraphvizRendererComponent } from '../../shared/components/visualization/graphviz-renderer.component';
import { MermaidRendererComponent } from '../../shared/components/visualization/mermaid-renderer.component';
import { DigestChipComponent } from '../../shared/domain/digest-chip/digest-chip.component';
import { EvidenceLinkComponent } from '../../shared/domain/evidence-link/evidence-link.component';
import { GateResult, GateSummaryPanelComponent } from '../../shared/domain/gate-summary-panel/gate-summary-panel.component';
import { PolicyGateBadgeComponent } from '../../shared/domain/gate-badge/gate-badge.component';
import { ReachabilityStateChipComponent } from '../../shared/domain/reachability-state-chip/reachability-state-chip.component';
import { WitnessPathPreviewComponent } from '../../shared/domain/witness-path-preview/witness-path-preview.component';
import { WitnessStatusChipComponent } from '../../shared/domain/witness-status-chip/witness-status-chip.component';
import { CgsBadgeComponent } from '../lineage/components/cgs-badge/cgs-badge.component';
import { CaseHeaderComponent, CaseHeaderData } from '../triage/components/case-header/case-header.component';
import {
AlertSummary,
DecisionDrawerComponent,
DecisionFormData,
} from '../triage/components/decision-drawer/decision-drawer.component';
import { ParkedFinding, QuietLaneContainerComponent } from '../triage/components/quiet-lane/quiet-lane-container.component';
import { DisplayPreferencesService } from '../triage/services/display-preferences.service';
@Component({
selector: 'app-web-feature-recheck-workbench',
standalone: true,
imports: [
CommonModule,
AskStellaPanelComponent,
BinaryDiffPanelComponent,
CaseHeaderComponent,
CgsBadgeComponent,
DecayProgressComponent,
DecisionDrawerComponent,
DigestChipComponent,
EntropyPanelComponent,
EntropyPolicyBannerComponent,
EvidenceDrawerComponent,
EvidenceLinkComponent,
FunctionDiffComponent,
ResolutionChipComponent,
GateSummaryPanelComponent,
GraphvizRendererComponent,
GuardrailsBadgeComponent,
MermaidRendererComponent,
ObservationStateChipComponent,
PolicyGateBadgeComponent,
QuietLaneContainerComponent,
ReachabilityStateChipComponent,
UncertaintyIndicatorComponent,
WitnessPathPreviewComponent,
WitnessStatusChipComponent,
],
template: `
<main class="qa-workbench">
<h1>Web Checked-Feature Recheck Workbench</h1>
<section class="qa-section">
<h2>Quiet Lane + VEX Gate</h2>
<app-quiet-lane-container
[findings]="parkedFindings()"
(recheckRequested)="recordQuietEvent('recheck:' + $event.join(','))"
(promoteRequested)="recordQuietEvent('promote:' + $event.join(','))"
(extendTtlRequested)="recordQuietEvent('extend:' + $event.join(','))"
(clearExpiredRequested)="recordQuietEvent('clear-expired')"
/>
<p data-testid="quiet-events">{{ quietEventsText() }}</p>
</section>
<section class="qa-section">
<h2>Can I Ship Case Header</h2>
<stella-case-header
[data]="caseHeaderData"
(verdictClick)="recordCaseHeaderEvent('verdict-click')"
(attestationClick)="recordCaseHeaderEvent('attestation:' + $event)"
(snapshotClick)="recordCaseHeaderEvent('snapshot:' + $event)"
/>
<p data-testid="case-header-events">{{ caseHeaderEventsText() }}</p>
</section>
<section class="qa-section">
<h2>Contextual Command Bar</h2>
<stella-ask-stella-panel
[context]="askContext"
[suggestedPrompts]="suggestedPrompts"
(query)="recordAskEvent($event.prompt)"
(closed)="recordAskEvent('closed')"
/>
<p data-testid="ask-events">{{ askEventsText() }}</p>
</section>
<section class="qa-section">
<h2>Decision Drawer</h2>
<button type="button" class="qa-button" (click)="openDecisionDrawer()">Open Decision Drawer</button>
<app-decision-drawer
[isOpen]="decisionDrawerOpen()"
[alert]="decisionAlert"
[evidenceHash]="decisionEvidenceHash"
[policyVersion]="policyVersion"
(close)="decisionDrawerOpen.set(false)"
(decisionSubmit)="recordDecision($event)"
/>
<p data-testid="decision-events">{{ decisionEventsText() }}</p>
</section>
<section class="qa-section">
<h2>Binary Diff + Backport Resolution</h2>
<app-binary-diff-panel
[data]="binaryDiffData"
(scopeChange)="recordBinaryDiffScope($event.scope)"
(exportDiff)="recordBinaryDiffScope('export:' + $event.format)"
/>
<p data-testid="binary-diff-events">{{ binaryDiffEventsText() }}</p>
<stella-resolution-chip
[resolution]="resolutionSummary"
(showEvidence)="openEvidenceFromResolution($event)"
/>
<stella-function-diff [functionChange]="functionChange" />
<button type="button" class="qa-button" (click)="toggleEvidenceDrawer()">
{{ evidenceDrawerOpen() ? 'Close' : 'Open' }} Evidence Drawer
</button>
<stella-evidence-drawer
[isOpen]="evidenceDrawerOpen()"
[evidence]="resolutionEvidence"
[resolution]="resolutionSummary"
[attestationDsse]="attestationDsse"
(close)="evidenceDrawerOpen.set(false)"
(viewDiff)="recordEvidenceDrawerEvent($event.name)"
/>
<p data-testid="evidence-drawer-events">{{ evidenceDrawerEventsText() }}</p>
</section>
<section class="qa-section">
<h2>CGS Badge + Confidence Visualization</h2>
<app-cgs-badge
[cgsHash]="cgsHash"
[showReplay]="true"
[confidenceScore]="0.86"
(replay)="recordCgsEvent($event)"
/>
<p data-testid="cgs-events">{{ cgsEventsText() }}</p>
<div class="viz-grid">
<app-graphviz-renderer
[dot]="graphvizDot"
ariaLabel="Confidence factors graph"
/>
<app-mermaid-renderer
[diagram]="mermaidDiagram"
ariaLabel="Confidence factor flow"
/>
</div>
</section>
<section class="qa-section">
<h2>Determinization Components</h2>
<div class="determinization-row">
<stellaops-observation-state-chip
[state]="observationState"
[nextReviewAt]="nextReviewAt"
/>
<stellaops-uncertainty-indicator
[score]="0.62"
[completeness]="68"
[missingSignals]="['runtime', 'vendor-vex']"
/>
<stellaops-guardrails-badge [guardrails]="guardrailsInfo" />
<stellaops-decay-progress [decay]="decayInfo" />
</div>
</section>
<section class="qa-section">
<h2>Display Preferences Service</h2>
<div class="prefs-grid">
<label>
<input
type="checkbox"
[checked]="displayPrefs.showRuntimeOverlays()"
(change)="onRuntimeOverlayToggle($event)"
/>
Runtime overlays
</label>
<label>
<input
type="checkbox"
[checked]="displayPrefs.enableTraceExport()"
(change)="onTraceExportToggle($event)"
/>
Trace export
</label>
<label>
<input
type="checkbox"
[checked]="displayPrefs.showRiskLine()"
(change)="onRiskLineToggle($event)"
/>
Risk line
</label>
<label>
<input
type="checkbox"
[checked]="displayPrefs.showSignedOverrideIndicators()"
(change)="onSignedOverrideToggle($event)"
/>
Signed override indicators
</label>
<label>
Graph max nodes
<input
type="number"
min="10"
max="200"
[value]="displayPrefs.graphMaxNodes()"
(change)="onGraphMaxNodesChange($event)"
/>
</label>
<label>
Runtime highlight style
<select [value]="displayPrefs.runtimeHighlightStyle()" (change)="onHighlightStyleChange($event)">
<option value="both">Both</option>
<option value="bold">Bold</option>
<option value="color">Color</option>
</select>
</label>
<button type="button" class="qa-button" (click)="displayPrefs.reset()">Reset Preferences</button>
</div>
<pre data-testid="display-prefs-json">{{ displayPreferencesJson() }}</pre>
</section>
<section class="qa-section">
<h2>Domain Widget Library</h2>
<div class="domain-grid">
<app-digest-chip
digest="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
label="Artifact"
variant="artifact"
(openDigest)="recordDomainEvent('digest-open')"
(copyDigest)="recordDomainEvent('digest-copy')"
/>
<app-policy-gate-badge state="WARN" label="Runtime Witness Gate" />
<app-reachability-state-chip
state="Uncertain"
[confidence]="0.74"
(openWitness)="recordDomainEvent('reachability-open-witness')"
/>
<app-witness-path-preview
[path]="witnessPath"
[deterministic]="true"
[guards]="witnessGuards"
(openFull)="recordDomainEvent('witness-open-full')"
/>
<app-evidence-link
evidenceId="ev-qa-001"
type="audit"
[signed]="true"
[verified]="true"
(open)="recordDomainEvent('evidence-link-open')"
/>
<app-witness-status-chip
status="stale"
[details]="witnessStatusDetails"
(chipClick)="recordDomainEvent('witness-status-click')"
/>
</div>
<app-gate-summary-panel
[gates]="gateResults"
policyRef="policy.v2.5.1"
snapshotRef="ksm:qa001122"
(openExplain)="recordDomainEvent('gate-explain:' + $event)"
(openEvidence)="recordDomainEvent('gate-open-evidence')"
(openComparison)="recordDomainEvent('gate-compare:' + $event)"
/>
<p data-testid="domain-events">{{ domainEventsText() }}</p>
</section>
<section class="qa-section">
<h2>Entropy Analysis and Policy Banner</h2>
<app-entropy-policy-banner
[config]="entropyPolicyConfig"
[mitigationSteps]="entropyMitigationSteps"
(downloadReport)="recordEntropyEvent('download:' + $event)"
(viewAnalysis)="recordEntropyEvent('view-analysis')"
(runMitigation)="recordEntropyEvent('mitigation:' + $event.id)"
/>
<app-entropy-panel
[analysis]="entropyAnalysis"
(viewReport)="recordEntropyEvent('panel-view-report')"
(selectLayer)="recordEntropyEvent('layer:' + $event)"
(selectFile)="recordEntropyEvent('file:' + $event)"
/>
<p data-testid="entropy-events">{{ entropyEventsText() }}</p>
</section>
</main>
`,
styles: [`
.qa-workbench {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
}
.qa-section {
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #d1d5db);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.qa-section h2 {
margin: 0;
font-size: 1.05rem;
}
.qa-button {
width: fit-content;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color, #d1d5db);
background: var(--surface-secondary, #f9fafb);
cursor: pointer;
}
.viz-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.determinization-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
align-items: start;
}
.prefs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem;
align-items: center;
}
.prefs-grid label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
pre {
background: #111827;
color: #f9fafb;
border-radius: 6px;
padding: 0.75rem;
margin: 0;
overflow-x: auto;
font-size: 0.75rem;
}
.domain-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
align-items: center;
}
`],
})
export class WebFeatureRecheckWorkbenchComponent {
readonly displayPrefs = inject(DisplayPreferencesService);
readonly parkedFindings = signal<ParkedFinding[]>([
{
id: 'qt-001',
title: 'openssl parser path currently unverified',
component: 'openssl',
version: '3.0.12',
severity: 'high',
reasons: ['low_evidence', 'unverified'],
parkedAt: '2026-02-11T06:00:00Z',
expiresAt: '2026-02-20T06:00:00Z',
parkedBy: 'qa@example.com',
},
{
id: 'qt-002',
title: 'curl package vendor-only advisory pending merge',
component: 'curl',
version: '8.7.1',
severity: 'medium',
reasons: ['vendor_only'],
parkedAt: '2026-02-10T10:30:00Z',
expiresAt: '2026-02-25T10:30:00Z',
parkedBy: 'qa@example.com',
},
]);
readonly caseHeaderData: CaseHeaderData = {
verdict: 'ship',
findingCount: 7,
criticalCount: 1,
highCount: 2,
actionableCount: 3,
evaluatedAt: new Date('2026-02-11T07:00:00Z'),
attestationId: 'att-qa-001',
snapshotId: 'ksm:sha256:00112233445566778899aabbccddeeff',
deltaFromBaseline: {
newBlockers: 1,
resolvedBlockers: 2,
newFindings: 3,
resolvedFindings: 1,
baselineName: 'prod-2026.02.10',
},
};
readonly askContext: AskStellaContext = {
vulnerabilityId: 'CVE-2026-1234',
serviceName: 'backend-api',
environment: 'staging',
componentPurl: 'pkg:oci/backend-api@sha256:abc123',
imageDigest: 'sha256:abc123',
};
readonly suggestedPrompts: SuggestedPrompt[] = [
{
id: 'ask-1',
label: 'Explain Reachability',
prompt: 'Why is this finding still reachable in staging?',
icon: '?',
},
{
id: 'ask-2',
label: 'Show Minimal Evidence',
prompt: 'What is the minimum evidence required to clear this finding?',
icon: '!',
},
];
readonly decisionAlert: AlertSummary = {
id: 'alert-001',
artifactId: 'pkg:oci/backend-api@sha256:abc123',
vulnId: 'CVE-2026-1234',
severity: 'high',
};
readonly decisionEvidenceHash = 'sha256:decision-evidence-001';
readonly policyVersion = 'policy-v2.5.1';
readonly binaryDiffData: BinaryDiffData = {
baseDigest: 'sha256:base001',
baseName: 'backend-api:2.4.0',
candidateDigest: 'sha256:candidate001',
candidateName: 'backend-api:2.5.0',
entries: [
{
id: 'entry-file-1',
name: 'libssl.so.3',
type: 'file',
changeType: 'modified',
baseHash: 'sha256:base-file-1',
candidateHash: 'sha256:candidate-file-1',
children: [
{
id: 'entry-fn-1',
name: 'parse_input',
type: 'function',
changeType: 'modified',
baseHash: 'sha256:fn-old',
candidateHash: 'sha256:fn-new',
},
],
},
{
id: 'entry-file-2',
name: 'libcrypto.so.3',
type: 'file',
changeType: 'unchanged',
},
],
diffLines: [
{
lineNumber: 1,
type: 'context',
address: '0x1000',
baseContent: 'push rbp',
candidateContent: 'push rbp',
},
{
lineNumber: 2,
type: 'modified',
address: '0x1004',
baseContent: 'cmp eax, 0x01',
candidateContent: 'cmp eax, 0x02',
},
{
lineNumber: 3,
type: 'added',
address: '0x1008',
candidateContent: 'call verify_patch',
},
],
stats: {
added: 1,
removed: 0,
modified: 1,
unchanged: 1,
},
};
readonly functionChange: FunctionChangeInfo = {
name: 'parse_input',
changeType: 'Modified',
similarity: 0.84,
vulnerableOffset: 4096,
patchedOffset: 4608,
vulnerableDisasm: [
'push rbp',
'mov rbp, rsp',
'cmp eax, 0x01',
'jne fail',
],
patchedDisasm: [
'push rbp',
'mov rbp, rsp',
'cmp eax, 0x02',
'call verify_patch',
],
};
readonly resolutionSummary: VulnResolutionSummary = {
status: 'Fixed',
matchType: 'fingerprint',
confidence: 0.88,
distroAdvisoryId: 'DSA-9999-1',
fixedVersion: '2.5.1',
hasEvidence: true,
};
readonly resolutionEvidence: ResolutionEvidence = {
evidenceType: 'binary_fingerprint_match',
matchType: 'fingerprint',
fixConfidence: 0.88,
distroAdvisoryId: 'DSA-9999-1',
patchCommit: '0123456789abcdef0123456789abcdef01234567',
changedFunctions: [
{
name: 'parse_input',
changeType: 'Modified',
},
{
name: 'verify_patch',
changeType: 'Added',
},
],
};
readonly attestationDsse = 'eyJzaWduYXR1cmVzIjpbeyJrZXlpZCI6InFhLWRzcy1rZXkifV19';
readonly cgsHash = 'sha256:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899';
readonly graphvizDot = `
digraph Confidence {
SBOM -> Score [label="0.35"];
Reachability -> Score [label="0.30"];
VEX -> Score [label="0.20"];
Runtime -> Score [label="0.15"];
}
`;
readonly mermaidDiagram = `
flowchart LR
A[SBOM] --> D[Confidence Score]
B[VEX] --> D
C[Reachability] --> D
E[Runtime] --> D
`;
readonly observationState = ObservationState.PendingDeterminization;
readonly nextReviewAt = '2026-02-11T12:00:00Z';
readonly guardrailsInfo: GuardrailsInfo = {
status: PolicyVerdictStatus.GuardedPass,
activeGuardrails: [
{
guardrailId: 'gr-1',
name: 'Reachable critical requires review',
type: 'policy',
condition: 'reachable_critical_count > 0',
isActive: true,
},
],
};
readonly decayInfo: DecayInfo = {
ageHours: 18,
freshnessPercent: 58,
isStale: false,
};
readonly witnessPath = [
'entrypoint.main',
'service.auth',
'service.routing',
'crypto.parse',
'sink.exec',
];
readonly witnessGuards = {
authCheck: true,
featureFlag: true,
envGuard: false,
inputValidation: true,
};
readonly witnessStatusDetails = {
status: 'stale' as const,
observationCount: 3,
staleThresholdHours: 24,
lastObserved: '2026-02-10T05:00:00Z',
rekorLogIndex: 1201,
};
readonly gateResults: GateResult[] = [
{
id: 'gate-runtime',
name: 'Runtime Witness',
state: 'WARN',
reason: 'Two vulnerable paths remain unwitnessed.',
gateType: 'witness',
witnessMetrics: {
totalPaths: 5,
witnessedPaths: 3,
unwitnessedPaths: 2,
stalePaths: 1,
unwitnessedPathDetails: [
{
pathId: 'path-1',
entrypoint: 'api.handler.start',
sink: 'openssl.parse',
severity: 'high',
vulnId: 'CVE-2026-1234',
},
],
},
},
];
readonly entropyAnalysis: EntropyAnalysis = {
imageDigest: 'sha256:entropy001',
overallScore: 7.4,
riskLevel: 'high',
analyzedAt: '2026-02-11T07:30:00Z',
reportUrl: '/reports/entropy/report.json',
layers: [
{
digest: 'sha256:layer1',
command: 'RUN apk add openssl',
size: 1200000,
avgEntropy: 6.1,
opaqueByteRatio: 64,
highEntropyFileCount: 2,
riskContribution: 4.1,
},
{
digest: 'sha256:layer2',
command: 'COPY app /app',
size: 2200000,
avgEntropy: 4.8,
opaqueByteRatio: 41,
highEntropyFileCount: 1,
riskContribution: 2.5,
},
],
highEntropyFiles: [
{
path: '/app/bin/runtime.bin',
layerDigest: 'sha256:layer2',
size: 180000,
entropy: 7.8,
classification: 'binary',
reason: 'Opaque byte ratio exceeds policy threshold.',
},
{
path: '/app/config/token.dat',
layerDigest: 'sha256:layer2',
size: 1024,
entropy: 7.2,
classification: 'suspicious',
reason: 'Potential credential-like token blob detected.',
},
],
detectorHints: [
{
id: 'hint-1',
severity: 'high',
type: 'token',
description: 'Token-like high entropy data found in runtime config.',
affectedPaths: ['/app/config/token.dat'],
confidence: 92,
remediation: 'Move secret material to external vault-backed config.',
},
],
};
readonly entropyPolicyConfig: EntropyPolicyConfig = {
warnThreshold: 6.0,
blockThreshold: 8.0,
currentScore: 7.4,
action: 'warn',
policyId: 'entropy-policy-prod',
policyName: 'Production Entropy Guardrails',
highEntropyFileCount: 2,
reportUrl: '/reports/entropy/report.json',
};
readonly entropyMitigationSteps: EntropyMitigationStep[] = [
{
id: 'review-layer',
title: 'Review suspicious layer',
description: 'Inspect high-entropy binaries and token blobs before promotion.',
effort: 'easy',
impact: 'high',
command: 'stella entropy review --image sha256:entropy001',
},
];
readonly quietEvents = signal<string[]>([]);
readonly caseHeaderEvents = signal<string[]>([]);
readonly askEvents = signal<string[]>([]);
readonly decisionEvents = signal<string[]>([]);
readonly binaryDiffEvents = signal<string[]>([]);
readonly evidenceDrawerEvents = signal<string[]>([]);
readonly cgsEvents = signal<string[]>([]);
readonly domainEvents = signal<string[]>([]);
readonly entropyEvents = signal<string[]>([]);
readonly decisionDrawerOpen = signal(false);
readonly evidenceDrawerOpen = signal(false);
readonly quietEventsText = computed(() => this.quietEvents().join(' | ') || 'none');
readonly caseHeaderEventsText = computed(() => this.caseHeaderEvents().join(' | ') || 'none');
readonly askEventsText = computed(() => this.askEvents().join(' | ') || 'none');
readonly decisionEventsText = computed(() => this.decisionEvents().join(' | ') || 'none');
readonly binaryDiffEventsText = computed(() => this.binaryDiffEvents().join(' | ') || 'none');
readonly evidenceDrawerEventsText = computed(() => this.evidenceDrawerEvents().join(' | ') || 'none');
readonly cgsEventsText = computed(() => this.cgsEvents().join(' | ') || 'none');
readonly domainEventsText = computed(() => this.domainEvents().join(' | ') || 'none');
readonly entropyEventsText = computed(() => this.entropyEvents().join(' | ') || 'none');
readonly displayPreferencesJson = computed(() =>
JSON.stringify(this.displayPrefs.preferences(), null, 2)
);
openDecisionDrawer(): void {
this.decisionDrawerOpen.set(true);
this.recordDecisionEvent('opened');
}
toggleEvidenceDrawer(): void {
this.evidenceDrawerOpen.update((open) => !open);
this.recordEvidenceDrawerEvent('toggle');
}
openEvidenceFromResolution(_resolution: VulnResolutionSummary): void {
this.evidenceDrawerOpen.set(true);
this.recordEvidenceDrawerEvent('resolution-chip');
}
recordQuietEvent(event: string): void {
this.quietEvents.update((events) => [...events, event]);
}
recordCaseHeaderEvent(event: string): void {
this.caseHeaderEvents.update((events) => [...events, event]);
}
recordAskEvent(prompt: string): void {
this.askEvents.update((events) => [...events, prompt]);
}
recordDecision(decision: DecisionFormData): void {
this.recordDecisionEvent(`${decision.status}:${decision.reasonCode}`);
this.decisionDrawerOpen.set(false);
}
recordDecisionEvent(event: string): void {
this.decisionEvents.update((events) => [...events, event]);
}
recordBinaryDiffScope(scope: DiffScopeLevel | string): void {
this.binaryDiffEvents.update((events) => [...events, scope]);
}
recordEvidenceDrawerEvent(event: string): void {
this.evidenceDrawerEvents.update((events) => [...events, event]);
}
recordCgsEvent(hash: string): void {
this.cgsEvents.update((events) => [...events, hash]);
}
recordDomainEvent(event: string): void {
this.domainEvents.update((events) => [...events, event]);
}
recordEntropyEvent(event: string): void {
this.entropyEvents.update((events) => [...events, event]);
}
onRuntimeOverlayToggle(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.displayPrefs.setShowRuntimeOverlays(checked);
}
onTraceExportToggle(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.displayPrefs.setEnableTraceExport(checked);
}
onRiskLineToggle(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.displayPrefs.setShowRiskLine(checked);
}
onSignedOverrideToggle(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.displayPrefs.setShowSignedOverrideIndicators(checked);
}
onGraphMaxNodesChange(event: Event): void {
const value = Number.parseInt((event.target as HTMLInputElement).value, 10);
if (!Number.isNaN(value)) {
this.displayPrefs.setGraphMaxNodes(value);
}
}
onHighlightStyleChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value as 'bold' | 'color' | 'both';
this.displayPrefs.setRuntimeHighlightStyle(value);
}
}

View File

@@ -920,15 +920,13 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
});
ngOnInit(): void {
this.loadData();
// Auto-refresh every 30 seconds
// Auto-refresh every 30 seconds, including the initial fetch.
interval(this.refreshInterval)
.pipe(
startWith(0),
takeUntil(this.destroy$)
)
.subscribe(() => this.refreshData());
.subscribe(() => this.loadData());
}
ngOnDestroy(): void {

View File

@@ -318,6 +318,7 @@ import { PlanAuditEntry, PaginatedResponse } from '../../../core/api/registry-ad
})
export class PlanAuditComponent implements OnInit {
private readonly api = inject(REGISTRY_ADMIN_API);
private lastAppliedPlanIdFilter = '';
readonly loading = signal(false);
readonly error = signal<string | null>(null);
@@ -336,11 +337,17 @@ export class PlanAuditComponent implements OnInit {
}
async loadAuditHistory(): Promise<void> {
const normalizedFilter = this.planIdFilter().trim();
if (normalizedFilter !== this.lastAppliedPlanIdFilter) {
this.page.set(1);
this.lastAppliedPlanIdFilter = normalizedFilter;
}
this.loading.set(true);
this.error.set(null);
try {
const planId = this.planIdFilter().trim() || undefined;
const planId = normalizedFilter || undefined;
const response = await firstValueFrom(
this.api.getAuditHistory(planId, this.page(), this.pageSize())
);
@@ -371,6 +378,10 @@ export class PlanAuditComponent implements OnInit {
formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return timestamp;
}
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',

View File

@@ -377,7 +377,17 @@ export class PlanListComponent implements OnInit {
result = result.filter((p) => !p.enabled);
}
return result;
// Always return a deterministic order independent of upstream API ordering.
return [...result].sort((left, right) => {
const byName = left.name.localeCompare(right.name, 'en', {
sensitivity: 'base',
numeric: true,
});
if (byName !== 0) {
return byName;
}
return left.id.localeCompare(right.id, 'en', { sensitivity: 'base', numeric: true });
});
});
ngOnInit(): void {

View File

@@ -38,8 +38,12 @@ export class PendingApprovalsComponent {
formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return '0m ago';
}
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMs = Math.max(0, now.getTime() - date.getTime());
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);

View File

@@ -3,7 +3,7 @@
@if (!loading && data) {
<div class="pipeline-overview__flow">
@for (env of data.environments; track env.id; let last = $last) {
@for (env of orderedEnvironments; track env.id; let last = $last) {
<a [routerLink]="['/release-orchestrator/environments', env.id]"
class="env-card"
[ngClass]="getStatusClass(env.healthStatus)">

View File

@@ -18,6 +18,16 @@ export class PipelineOverviewComponent {
@Input() data: PipelineData | null = null;
@Input() loading = false;
get orderedEnvironments(): PipelineEnvironment[] {
const environments = this.data?.environments ?? [];
return [...environments].sort((left, right) => {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.id.localeCompare(right.id, 'en', { sensitivity: 'base', numeric: true });
});
}
getStatusIcon(status: PipelineEnvironment['healthStatus']): string {
switch (status) {
case 'healthy':

View File

@@ -25,7 +25,7 @@
</tr>
</thead>
<tbody>
@for (release of releases; track release.id) {
@for (release of sortedReleases; track release.id) {
<tr>
<td>
<a [routerLink]="['/release-orchestrator/releases', release.id]" class="release-link">

View File

@@ -18,6 +18,20 @@ export class RecentReleasesComponent {
@Input() releases: RecentRelease[] = [];
@Input() loading = false;
get sortedReleases(): RecentRelease[] {
return [...this.releases].sort((left, right) => {
const leftTimestamp = Date.parse(left.createdAt);
const rightTimestamp = Date.parse(right.createdAt);
const leftSafe = Number.isNaN(leftTimestamp) ? 0 : leftTimestamp;
const rightSafe = Number.isNaN(rightTimestamp) ? 0 : rightTimestamp;
if (leftSafe !== rightSafe) {
return rightSafe - leftSafe;
}
return left.id.localeCompare(right.id, 'en', { sensitivity: 'base', numeric: true });
});
}
getStatusBadgeClass(status: RecentRelease['status']): string {
const map: Record<RecentRelease['status'], string> = {
draft: 'badge--secondary',

View File

@@ -2,7 +2,7 @@
* Deployment Monitor Component
* Sprint: SPRINT_20260110_111_006_FE_deployment_monitoring_ui
*/
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { Component, OnInit, OnDestroy, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -25,7 +25,14 @@ import {
formatTimestamp,
} from '../../../../core/api/deployment.models';
type TabType = 'logs' | 'timeline' | 'metrics';
type TabType = 'workflow' | 'logs' | 'timeline' | 'metrics';
interface WorkflowDagNode {
id: string;
label: string;
status: 'completed' | 'running' | 'pending' | 'failed';
summary: string;
}
@Component({
selector: 'app-deployment-monitor',
@@ -179,6 +186,13 @@ type TabType = 'logs' | 'timeline' | 'metrics';
<main class="main-panel">
<!-- Tabs -->
<div class="tab-header">
<button
class="tab-btn"
[class.active]="activeTab() === 'workflow'"
(click)="activeTab.set('workflow')"
>
Workflow
</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'logs'"
@@ -205,6 +219,30 @@ type TabType = 'logs' | 'timeline' | 'metrics';
<!-- Tab Content -->
<div class="tab-content">
@switch (activeTab()) {
@case ('workflow') {
<div class="workflow-panel">
<div class="workflow-header">
<h3>Workflow DAG</h3>
<span class="workflow-progress">
{{ completedWorkflowSteps() }}/{{ workflowSteps().length }} completed
</span>
</div>
<div class="workflow-dag">
@for (step of workflowSteps(); track step.id) {
<div
[attr.class]="'dag-node dag-node--' + step.status + (selectedWorkflowStepId() === step.id ? ' dag-node--selected' : '')"
(click)="selectWorkflowStep(step.id)"
>
<span class="dag-node__icon">{{ getWorkflowStatusIcon(step.status) }}</span>
<div class="dag-node__content">
<span class="dag-node__title">{{ step.label }}</span>
<span class="dag-node__summary">{{ step.summary }}</span>
</div>
</div>
}
</div>
</div>
}
@case ('logs') {
<div class="logs-panel">
<div class="logs-toolbar">
@@ -782,6 +820,107 @@ type TabType = 'logs' | 'timeline' | 'metrics';
overflow: hidden;
}
.workflow-panel {
padding: 16px;
height: 100%;
overflow-y: auto;
}
.workflow-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.workflow-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.workflow-progress {
font-size: 12px;
color: #6b7280;
}
.workflow-dag {
display: flex;
flex-direction: column;
gap: 10px;
}
.dag-node {
display: flex;
gap: 10px;
align-items: flex-start;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
background: #f9fafb;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.dag-node:hover {
border-color: #3b82f6;
}
.dag-node--selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.dag-node__icon {
width: 24px;
min-width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #111827;
background: #e5e7eb;
}
.dag-node__content {
display: flex;
flex-direction: column;
gap: 4px;
}
.dag-node__title {
font-size: 13px;
font-weight: 600;
color: #111827;
}
.dag-node__summary {
font-size: 12px;
color: #4b5563;
}
.dag-node--completed {
border-left: 4px solid #10b981;
background: #ecfdf5;
}
.dag-node--running {
border-left: 4px solid #3b82f6;
background: #eff6ff;
}
.dag-node--pending {
border-left: 4px solid #9ca3af;
}
.dag-node--failed {
border-left: 4px solid #ef4444;
background: #fef2f2;
}
.logs-panel {
display: flex;
flex-direction: column;
@@ -1126,10 +1265,11 @@ export class DeploymentMonitorComponent implements OnInit, OnDestroy {
readonly store = inject(DeploymentStore);
// View state
readonly activeTab = signal<TabType>('logs');
readonly activeTab = signal<TabType>('workflow');
readonly showCancelDialog = signal(false);
readonly showRollbackDialog = signal(false);
readonly autoScroll = signal(true);
readonly selectedWorkflowStepId = signal<string | null>(null);
// Form state
logSearch = '';
@@ -1152,6 +1292,21 @@ export class DeploymentMonitorComponent implements OnInit, OnDestroy {
deployment = this.store.selectedDeployment;
readonly workflowSteps = signal<WorkflowDagNode[]>([
{ id: 'prepare', label: 'Prepare bundle', status: 'pending', summary: 'Resolve release and lock assets.' },
{ id: 'deploy', label: 'Deploy targets', status: 'pending', summary: 'Roll out release to selected targets.' },
{ id: 'verify', label: 'Verify health', status: 'pending', summary: 'Run health checks and policy gates.' },
{ id: 'seal', label: 'Seal evidence', status: 'pending', summary: 'Publish signed deployment evidence.' },
]);
readonly completedWorkflowSteps = computed(
() => this.workflowSteps().filter((step) => step.status === 'completed').length
);
private readonly workflowSync = effect(() => {
this.syncWorkflowSteps(this.deployment());
});
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
@@ -1317,4 +1472,83 @@ export class DeploymentMonitorComponent implements OnInit, OnDestroy {
second: '2-digit',
});
}
selectWorkflowStep(stepId: string): void {
this.selectedWorkflowStepId.set(this.selectedWorkflowStepId() === stepId ? null : stepId);
}
getWorkflowStatusIcon(status: WorkflowDagNode['status']): string {
switch (status) {
case 'completed':
return 'ok';
case 'running':
return '>';
case 'failed':
return 'x';
default:
return '...';
}
}
private syncWorkflowSteps(deployment: Deployment | null): void {
if (!deployment) {
this.workflowSteps.set([
{ id: 'prepare', label: 'Prepare bundle', status: 'pending', summary: 'Resolve release and lock assets.' },
{ id: 'deploy', label: 'Deploy targets', status: 'pending', summary: 'Roll out release to selected targets.' },
{ id: 'verify', label: 'Verify health', status: 'pending', summary: 'Run health checks and policy gates.' },
{ id: 'seal', label: 'Seal evidence', status: 'pending', summary: 'Publish signed deployment evidence.' },
]);
return;
}
const deployStatus: WorkflowDagNode['status'] =
deployment.status === 'failed'
? 'failed'
: deployment.progress >= 100
? 'completed'
: deployment.progress > 0
? 'running'
: 'pending';
const verifyStatus: WorkflowDagNode['status'] =
deployment.status === 'failed'
? 'failed'
: deployment.progress >= 85
? deployment.status === 'completed' ? 'completed' : 'running'
: 'pending';
const sealStatus: WorkflowDagNode['status'] =
deployment.status === 'completed'
? 'completed'
: deployment.status === 'failed'
? 'failed'
: 'pending';
this.workflowSteps.set([
{
id: 'prepare',
label: 'Prepare bundle',
status: 'completed',
summary: `${deployment.releaseVersion} resolved for ${deployment.environmentName}.`,
},
{
id: 'deploy',
label: 'Deploy targets',
status: deployStatus,
summary: `${deployment.completedTargets}/${deployment.targetCount} targets deployed.`,
},
{
id: 'verify',
label: 'Verify health',
status: verifyStatus,
summary: `Current phase: ${deployment.currentStep}.`,
},
{
id: 'seal',
label: 'Seal evidence',
status: sealStatus,
summary: `Awaiting attestation for deployment ${deployment.id}.`,
},
]);
}
}

View File

@@ -2,16 +2,25 @@
* Evidence List Component
* Sprint: SPRINT_20260110_111_007_FE_evidence_viewer
*/
import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, inject, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { EvidenceStore } from '../evidence.store';
import { formatFileSize, type SignatureStatus } from '../../../../core/api/release-evidence.models';
import {
formatFileSize,
type SignatureStatus,
type EvidencePacketSummary as ReleaseEvidencePacketSummary,
} from '../../../../core/api/release-evidence.models';
import {
EvidencePacketDrawerComponent,
type EvidencePacketSummary as DrawerEvidencePacketSummary,
type EvidenceContentItem,
} from '../../../../shared/overlays/evidence-packet-drawer/evidence-packet-drawer.component';
@Component({
selector: 'so-evidence-list',
imports: [CommonModule, RouterModule, FormsModule],
imports: [CommonModule, RouterModule, FormsModule, EvidencePacketDrawerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-list">
@@ -134,6 +143,14 @@ import { formatFileSize, type SignatureStatus } from '../../../../core/api/relea
<a [routerLink]="[packet.id]" class="btn-icon" title="View">
&#128065;
</a>
<button
class="btn-icon"
title="Open Drawer"
aria-label="Open packet drawer"
(click)="openDrawer(packet)"
>
&#9776;
</button>
<button class="btn-icon" title="Download" (click)="onDownload(packet.id)">
&#11015;
</button>
@@ -156,6 +173,18 @@ import { formatFileSize, type SignatureStatus } from '../../../../core/api/relea
</div>
}
</div>
@if (drawerPacket()) {
<app-evidence-packet-drawer
[open]="drawerOpen()"
[evidence]="drawerPacket()!"
[contents]="drawerContents()"
[detailsRouteBase]="'/release-orchestrator/evidence'"
(closed)="closeDrawer()"
(verify)="onDrawerVerify($event)"
(export)="onDownload($event)"
/>
}
`,
styles: [`
.evidence-list {
@@ -490,6 +519,9 @@ import { formatFileSize, type SignatureStatus } from '../../../../core/api/relea
})
export class EvidenceListComponent implements OnInit {
readonly store = inject(EvidenceStore);
readonly drawerOpen = signal(false);
readonly drawerPacket = signal<DrawerEvidencePacketSummary | null>(null);
readonly drawerContents = signal<EvidenceContentItem[]>([]);
readonly signatureOptions: { label: string; value: SignatureStatus }[] = [
{ label: 'Valid', value: 'valid' },
@@ -525,6 +557,20 @@ export class EvidenceListComponent implements OnInit {
this.store.downloadRaw(id);
}
openDrawer(packet: ReleaseEvidencePacketSummary): void {
this.drawerPacket.set(this.toDrawerPacket(packet));
this.drawerContents.set(this.toDrawerContents(packet));
this.drawerOpen.set(true);
}
closeDrawer(): void {
this.drawerOpen.set(false);
}
onDrawerVerify(id: string): void {
this.store.verifyEvidence(id);
}
getSignatureIcon(status: SignatureStatus): string {
const icons: Record<SignatureStatus, string> = {
valid: '\u2713',
@@ -547,4 +593,31 @@ export class EvidenceListComponent implements OnInit {
minute: '2-digit',
});
}
private toDrawerPacket(packet: ReleaseEvidencePacketSummary): DrawerEvidencePacketSummary {
return {
evidenceId: packet.id,
type: 'deployment',
subject: `${packet.releaseName} ${packet.releaseVersion}`,
subjectDigest: packet.contentHash,
signed: packet.signatureStatus !== 'unsigned',
verified: packet.signatureStatus === 'valid',
signedBy: packet.signedBy ?? undefined,
verifiedAt: packet.signatureStatus === 'valid' ? packet.signedAt ?? undefined : undefined,
createdAt: packet.createdAt,
contentCount: packet.contentTypes.length,
totalSize: this.formatSize(packet.size),
rekorEntry: packet.signatureStatus === 'valid' ? `rekor-${packet.id}` : undefined,
};
}
private toDrawerContents(packet: ReleaseEvidencePacketSummary): EvidenceContentItem[] {
const normalizedEntrySize = Math.max(1, Math.round(packet.size / Math.max(packet.contentTypes.length, 1)));
return packet.contentTypes.map((contentType, index) => ({
name: `${packet.releaseName}-${contentType.toLowerCase()}.json`,
type: contentType,
digest: `${packet.contentHash}-${index}`,
size: this.formatSize(normalizedEntrySize),
}));
}
}

View File

@@ -99,7 +99,7 @@ interface ConnectionState {
<div class="validation-errors">
<strong>Validation Errors:</strong>
<ul>
@for (error of store.validationErrors(); track error) {
@for (error of store.validationErrors(); track $index) {
<li>{{ error }}</li>
}
</ul>
@@ -963,8 +963,7 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
if (!selectedStep) return [];
return this.store.steps().filter(s =>
s.id !== selectedStep.id &&
!selectedStep.dependencies.includes(s.id)
this.store.canAddDependency(selectedStep.id, s.id)
);
});
@@ -1218,7 +1217,7 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
// Step manipulation
private addStep(type: WorkflowStepType, position: { x: number; y: number }): void {
const stepDef = getStepTypeDefinition(type);
const id = 'step-' + Math.random().toString(36).substring(2, 9);
const id = this.generateDeterministicStepId(type);
this.store.addStep({
id,
@@ -1322,4 +1321,18 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
return `M ${startX} ${startY} C ${startX + controlOffset} ${startY}, ${endX - controlOffset} ${endY}, ${endX} ${endY}`;
}
private generateDeterministicStepId(type: WorkflowStepType): string {
const base = type.replace(/[^a-z0-9]+/gi, '-').toLowerCase();
const existingIds = new Set(this.store.steps().map(step => step.id));
let sequence = 1;
while (true) {
const candidate = `${base}-${sequence.toString().padStart(3, '0')}`;
if (!existingIds.has(candidate)) {
return candidate;
}
sequence++;
}
}
}

View File

@@ -14,6 +14,7 @@ import type {
@Injectable({ providedIn: 'root' })
export class WorkflowStore {
private readonly api = inject(WORKFLOW_API);
private readonly dependencyValidationPrefix = 'Dependency validation:';
// State signals
private readonly _workflows = signal<Workflow[]>([]);
@@ -278,9 +279,14 @@ export class WorkflowStore {
this._isDirty.set(true);
}
addDependency(stepId: string, dependsOnId: string): void {
addDependency(stepId: string, dependsOnId: string): boolean {
const workflow = this._selectedWorkflow();
if (!workflow) return;
if (!workflow) return false;
if (!this.canAddDependency(stepId, dependsOnId)) {
this.publishDependencyValidationError(stepId, dependsOnId);
return false;
}
this._selectedWorkflow.set({
...workflow,
@@ -290,7 +296,9 @@ export class WorkflowStore {
: s
),
});
this.clearDependencyValidationErrors();
this._isDirty.set(true);
return true;
}
removeDependency(stepId: string, dependsOnId: string): void {
@@ -308,6 +316,20 @@ export class WorkflowStore {
this._isDirty.set(true);
}
canAddDependency(stepId: string, dependsOnId: string): boolean {
const workflow = this._selectedWorkflow();
if (!workflow) return false;
if (stepId === dependsOnId) return false;
const targetStep = workflow.steps.find(s => s.id === stepId);
const dependsOnStep = workflow.steps.find(s => s.id === dependsOnId);
if (!targetStep || !dependsOnStep) return false;
if (targetStep.dependencies.includes(dependsOnId)) return false;
if (this.wouldCreateDependencyCycle(workflow.steps, stepId, dependsOnId)) return false;
return true;
}
moveStep(stepId: string, position: { x: number; y: number }): void {
const workflow = this._selectedWorkflow();
if (!workflow) return;
@@ -366,4 +388,71 @@ export class WorkflowStore {
// Reload from server
this.loadWorkflow(workflow.id);
}
private wouldCreateDependencyCycle(
steps: readonly WorkflowStep[],
stepId: string,
dependsOnId: string,
): boolean {
return this.hasDependencyPath(dependsOnId, stepId, steps, new Set<string>());
}
private hasDependencyPath(
currentStepId: string,
targetStepId: string,
steps: readonly WorkflowStep[],
visited: Set<string>,
): boolean {
if (currentStepId === targetStepId) {
return true;
}
if (visited.has(currentStepId)) {
return false;
}
visited.add(currentStepId);
const current = steps.find(step => step.id === currentStepId);
if (!current) {
return false;
}
return current.dependencies.some(depId =>
this.hasDependencyPath(depId, targetStepId, steps, visited),
);
}
private publishDependencyValidationError(stepId: string, dependsOnId: string): void {
let reason = `invalid dependency ${dependsOnId} -> ${stepId}`;
const workflow = this._selectedWorkflow();
if (stepId === dependsOnId) {
reason = 'a step cannot depend on itself';
} else if (!workflow) {
reason = 'workflow is not selected';
} else {
const targetStep = workflow.steps.find(s => s.id === stepId);
const dependsOnStep = workflow.steps.find(s => s.id === dependsOnId);
if (!targetStep || !dependsOnStep) {
reason = 'referenced step is missing';
} else if (targetStep.dependencies.includes(dependsOnId)) {
reason = `dependency already exists for ${targetStep.name}`;
} else if (this.wouldCreateDependencyCycle(workflow.steps, stepId, dependsOnId)) {
reason = `adding ${dependsOnStep.name} -> ${targetStep.name} would create a cycle`;
}
}
const message = `${this.dependencyValidationPrefix} ${reason}.`;
this._validationErrors.update(errors => {
const retained = errors.filter(
error => !error.startsWith(this.dependencyValidationPrefix),
);
return [...retained, message];
});
}
private clearDependencyValidationErrors(): void {
this._validationErrors.update(errors =>
errors.filter(error => !error.startsWith(this.dependencyValidationPrefix)),
);
}
}

View File

@@ -9,6 +9,15 @@ import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@ang
import { ActivatedRoute, RouterLink } from '@angular/router';
type ReleaseDetailTabId =
| 'overview'
| 'components'
| 'gates'
| 'promotions'
| 'deployments'
| 'evidence'
| 'proof-chain';
@Component({
selector: 'app-release-detail-page',
standalone: true,
@@ -564,9 +573,9 @@ export class ReleaseDetailPageComponent implements OnInit {
private route = inject(ActivatedRoute);
releaseId = signal('');
activeTab = signal('overview');
activeTab = signal<ReleaseDetailTabId>('overview');
tabs = [
tabs: ReadonlyArray<{ id: ReleaseDetailTabId; label: string; count?: number }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'components', label: 'Components', count: 24 },
{ id: 'gates', label: 'Gates', count: 4 },
@@ -600,13 +609,26 @@ export class ReleaseDetailPageComponent implements OnInit {
ngOnInit(): void {
this.route.params.subscribe(params => {
this.releaseId.set(params['releaseId'] || '');
this.release.update(r => ({ ...r, version: params['releaseId'] || r.version }));
const nextReleaseId = params['releaseId'] || '';
const previousReleaseId = this.releaseId();
this.releaseId.set(nextReleaseId);
if (nextReleaseId !== previousReleaseId) {
this.activeTab.set('overview');
}
if (nextReleaseId) {
this.release.update(r => ({ ...r, version: nextReleaseId }));
}
});
}
setTab(tabId: string): void {
this.activeTab.set(tabId);
if (!this.tabs.some((tab) => tab.id === tabId)) {
return;
}
this.activeTab.set(tabId as ReleaseDetailTabId);
}
async copyDigest(): Promise<void> {

View File

@@ -8,6 +8,7 @@
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
interface Release {
id: string;
@@ -23,7 +24,7 @@ interface Release {
@Component({
selector: 'app-releases-list-page',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="releases-page">
@@ -51,7 +52,7 @@ interface Release {
type="text"
class="filter-bar__input"
placeholder="Search by version, digest, or component..."
[(ngModel)]="searchQuery"
[ngModel]="searchQuery()"
(ngModelChange)="onSearch($event)"
/>
</div>
@@ -274,7 +275,7 @@ interface Release {
`]
})
export class ReleasesListPageComponent {
searchQuery = '';
searchQuery = signal('');
envFilter = signal<string>('');
gateFilter = signal<string>('');
@@ -290,7 +291,7 @@ export class ReleasesListPageComponent {
let result = this.releases();
const env = this.envFilter();
const gate = this.gateFilter();
const query = this.searchQuery.toLowerCase();
const query = this.searchQuery().toLowerCase();
if (env) {
result = result.filter(r => r.environment === env);
@@ -301,10 +302,17 @@ export class ReleasesListPageComponent {
if (query) {
result = result.filter(r =>
r.version.toLowerCase().includes(query) ||
r.bundleDigest.toLowerCase().includes(query)
r.bundleDigest.toLowerCase().includes(query) ||
String(r.components).includes(query)
);
}
return result;
return [...result].sort((left, right) => {
const byVersion = this.compareVersionsDesc(left.version, right.version);
if (byVersion !== 0) {
return byVersion;
}
return left.id.localeCompare(right.id, 'en', { sensitivity: 'base', numeric: true });
});
});
shortDigest(digest: string): string {
@@ -316,7 +324,33 @@ export class ReleasesListPageComponent {
}
onSearch(query: string): void {
this.searchQuery = query;
this.searchQuery.set(query);
}
private compareVersionsDesc(left: string, right: string): number {
const leftParts = this.parseVersion(left);
const rightParts = this.parseVersion(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let i = 0; i < maxLength; i++) {
const leftPart = leftParts[i] ?? 0;
const rightPart = rightParts[i] ?? 0;
if (leftPart !== rightPart) {
return rightPart - leftPart;
}
}
return right.localeCompare(left, 'en', { sensitivity: 'base', numeric: true });
}
private parseVersion(version: string): number[] {
return version
.replace(/^v/i, '')
.split('.')
.map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isNaN(parsed) ? 0 : parsed;
});
}
filterByEnv(event: Event): void {

View File

@@ -304,10 +304,13 @@ export class BudgetBurnupChartComponent implements OnChanges {
protected scaleX = computed(() => {
const data = this.dataSignal();
const len = data.length || 1;
const len = data.length;
return (index: number) => {
const chartW = this.chartWidth();
const left = this.dimensions.padding.left;
if (len <= 1) {
return left + chartW / 2;
}
return left + (index / (len - 1)) * chartW;
};
});
@@ -369,18 +372,25 @@ export class BudgetBurnupChartComponent implements OnChanges {
if (data.length === 0) return [];
// Show ~5 labels
const step = Math.max(1, Math.floor(data.length / 5));
const labels: { x: number; text: string }[] = [];
// Show ~5 labels while ensuring the final timestamp is always visible.
const step = Math.max(1, Math.floor((data.length - 1) / 4));
const labelIndexes: number[] = [];
for (let i = 0; i < data.length; i += step) {
const date = new Date(data[i].timestamp);
labels.push({
x: scaleX(i),
text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
});
labelIndexes.push(i);
}
return labels;
const lastIndex = data.length - 1;
if (labelIndexes[labelIndexes.length - 1] !== lastIndex) {
labelIndexes.push(lastIndex);
}
return labelIndexes.map((i) => {
const date = new Date(data[i].timestamp);
return {
x: scaleX(i),
text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
};
});
});
}

View File

@@ -11,7 +11,7 @@
* @task DASH-04
*/
import { Component, Input, computed, signal } from '@angular/core';
import { Component, Input, computed } from '@angular/core';
import type { BudgetKpis, BudgetStatus } from '../../../core/api/risk-budget.models';
@@ -45,8 +45,8 @@ export interface KpiTile {
@if (tile.delta !== undefined) {
<span
class="kpi-delta"
[class.positive]="tile.trend === 'up' && tile.trendIsGood"
[class.negative]="tile.trend === 'down' && !tile.trendIsGood || tile.trend === 'up' && !tile.trendIsGood"
[class.positive]="isPositiveDelta(tile)"
[class.negative]="isNegativeDelta(tile)"
>
@if (tile.trend === 'up') {
<span class="delta-arrow">&#x2191;</span>
@@ -147,6 +147,20 @@ export class BudgetKpiTilesComponent {
@Input() kpis: BudgetKpis | null = null;
@Input() status: BudgetStatus = 'healthy';
protected isPositiveDelta(tile: KpiTile): boolean {
if (tile.delta === undefined || tile.delta === 0 || tile.trendIsGood === undefined) {
return false;
}
return tile.trendIsGood ? tile.delta > 0 : tile.delta < 0;
}
protected isNegativeDelta(tile: KpiTile): boolean {
if (tile.delta === undefined || tile.delta === 0 || tile.trendIsGood === undefined) {
return false;
}
return tile.trendIsGood ? tile.delta < 0 : tile.delta > 0;
}
protected tiles = computed((): KpiTile[] => {
const kpis = this.kpis;
if (!kpis) return [];
@@ -165,17 +179,19 @@ export class BudgetKpiTilesComponent {
},
{
id: 'unknowns',
label: 'Unknowns',
label: 'Unknowns Delta',
value: kpis.unknownsDelta24h,
delta: kpis.unknownsDelta24h,
deltaLabel: '24h',
trend: kpis.unknownsDelta24h > 0 ? 'up' : 'stable',
trendIsGood: false, // More unknowns is bad
trend: kpis.unknownsDelta24h > 0 ? 'up' : kpis.unknownsDelta24h < 0 ? 'down' : 'stable',
trendIsGood: false, // More unknowns is bad, fewer unknowns is good
status: kpis.unknownsDelta24h > 5 ? 'warning' : 'normal',
},
{
id: 'retired',
label: 'Risk Retired',
value: kpis.riskRetired7d,
delta: kpis.riskRetired7d,
deltaLabel: '7d',
trend: kpis.riskRetired7d > 0 ? 'up' : 'stable',
trendIsGood: true, // More retired is good

View File

@@ -11,7 +11,7 @@
@if (!error()) {
<div class="status status--ok">Up to date</div>
} @else {
<div class="status status--error">{{ error() }}</div>
<div class="status status--error" role="alert">{{ error() }}</div>
}
}
</header>
@@ -79,7 +79,9 @@
<p class="meta">Showing {{ page.items.length }} of {{ page.total }} risks.</p>
</section>
} @else {
@if (!loading()) {
@if (error()) {
<div class="empty empty--error" role="alert">Unable to load risk profiles. {{ error() }}</div>
} @else if (!loading()) {
<div class="empty">No risks found for current filters.</div>
} @else {
<div class="empty">Loading risks…</div>

View File

@@ -171,6 +171,13 @@ tr:last-child td {
color: var(--color-text-muted);
}
.empty--error {
border-style: solid;
border-color: var(--color-status-error);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
@include screen-below-md {
.risk-dashboard__header { flex-direction: column; align-items: flex-start; }
table { display: block; overflow-x: auto; }

View File

@@ -70,41 +70,38 @@ export class FirstSignalCardComponent implements OnDestroy {
readonly stageText = computed(() => this.formatStageText(this.signal()));
constructor() {
effect(
() => {
const runId = this.runId();
const tenantId = this.tenantId() ?? undefined;
const projectId = this.projectId() ?? undefined;
const enableRealTime = this.enableRealTime();
const pollIntervalMs = this.pollIntervalMs();
effect(() => {
const runId = this.runId();
const tenantId = this.tenantId() ?? undefined;
const projectId = this.projectId() ?? undefined;
const enableRealTime = this.enableRealTime();
const pollIntervalMs = this.pollIntervalMs();
const loadKey = `${runId}|${tenantId ?? ''}|${projectId ?? ''}|${enableRealTime ? '1' : '0'}|${pollIntervalMs}`;
if (this.lastLoadKey === loadKey) {
return;
}
this.lastLoadKey = loadKey;
const loadKey = `${runId}|${tenantId ?? ''}|${projectId ?? ''}|${enableRealTime ? '1' : '0'}|${pollIntervalMs}`;
if (this.lastLoadKey === loadKey) {
return;
}
this.lastLoadKey = loadKey;
this.ttfsTrackingKey = loadKey;
this.ttfsStartAt = performance.now();
this.ttfsEmittedKey = null;
this.ttfsTrackingKey = loadKey;
this.ttfsStartAt = performance.now();
this.ttfsEmittedKey = null;
this.store.clear();
this.store.clear();
const prefetched = this.prefetch.get(runId);
if (prefetched?.response) {
this.store.prime({ response: prefetched.response, etag: prefetched.etag });
}
const prefetched = this.prefetch.get(runId);
if (prefetched?.response) {
this.store.prime({ response: prefetched.response, etag: prefetched.etag });
}
this.ttfsPrefetchHit = !!prefetched?.response?.firstSignal;
this.telemetry.emitTtfsStart(runId, 'ui');
this.ttfsPrefetchHit = !!prefetched?.response?.firstSignal;
this.telemetry.emitTtfsStart(runId, 'ui');
this.store.load(runId, { tenantId, projectId });
if (enableRealTime) {
this.store.connect(runId, { tenantId, projectId, pollIntervalMs });
}
},
{ allowSignalWrites: true }
);
this.store.load(runId, { tenantId, projectId });
if (enableRealTime) {
this.store.connect(runId, { tenantId, projectId, pollIntervalMs });
}
});
effect(() => {
const sig = this.signal();
@@ -135,23 +132,20 @@ export class FirstSignalCardComponent implements OnDestroy {
this.ttfsEmittedKey = trackingKey;
});
effect(
() => {
const state = this.state();
const delayMs = Math.max(0, Math.floor(this.skeletonDelayMs()));
effect(() => {
const state = this.state();
const delayMs = Math.max(0, Math.floor(this.skeletonDelayMs()));
if (state !== 'loading' || !!this.response()) {
this.clearSkeletonDelay();
this.showSkeletonSignal.set(false);
return;
}
this.showSkeletonSignal.set(false);
if (state !== 'loading' || !!this.response()) {
this.clearSkeletonDelay();
this.skeletonDelayHandle = setTimeout(() => this.showSkeletonSignal.set(true), delayMs);
},
{ allowSignalWrites: true }
);
this.showSkeletonSignal.set(false);
return;
}
this.showSkeletonSignal.set(false);
this.clearSkeletonDelay();
this.skeletonDelayHandle = setTimeout(() => this.showSkeletonSignal.set(true), delayMs);
});
}
ngOnDestroy(): void {
@@ -223,4 +217,3 @@ export class FirstSignalCardComponent implements OnDestroy {
return 'failure_index';
}
}

View File

@@ -210,6 +210,11 @@ export interface MergedListItem {
<!-- Side-by-Side View -->
@if (sideBySideMode()) {
@if (sideBySideMismatch()) {
<div class="sbom-diff__mismatch" role="status">
Side-by-side counts differ because components were added or removed between versions.
</div>
}
<div class="sbom-diff__side-by-side">
<!-- Left Panel (Version A - Removed & Changed From) -->
<div
@@ -221,34 +226,40 @@ export interface MergedListItem {
<span>{{ versionA() }}</span>
<span class="side-panel__count">{{ summary()?.totalA ?? 0 }} components</span>
</div>
<ul class="component-list" role="list">
@for (item of mergedListA(); track item.purl) {
<li
class="component-item"
[class.component-item--removed]="item.changeType === 'removed'"
[class.component-item--changed]="item.changeType === 'changed'"
[class.component-item--unchanged]="item.changeType === 'unchanged'"
>
<button
type="button"
class="component-item__button"
(click)="onMergedItemSelect(item)"
[attr.aria-label]="'Select ' + item.name"
@if (mergedListA().length > 0) {
<ul class="component-list" role="list">
@for (item of mergedListA(); track item.purl) {
<li
class="component-item"
[class.component-item--removed]="item.changeType === 'removed'"
[class.component-item--changed]="item.changeType === 'changed'"
[class.component-item--unchanged]="item.changeType === 'unchanged'"
>
<span class="component-item__icon" aria-hidden="true">
@switch (item.changeType) {
@case ('removed') { - }
@case ('changed') { ~ }
@default { }
}
</span>
<span class="component-item__name">{{ item.name }}</span>
<span class="component-item__version">{{ item.version }}</span>
<span class="component-item__ecosystem">{{ getEcosystemLabel(item.ecosystem) }}</span>
</button>
</li>
}
</ul>
<button
type="button"
class="component-item__button"
(click)="onMergedItemSelect(item)"
[attr.aria-label]="'Select ' + item.name"
>
<span class="component-item__icon" aria-hidden="true">
@switch (item.changeType) {
@case ('removed') { - }
@case ('changed') { ~ }
@default { }
}
</span>
<span class="component-item__name">{{ item.name }}</span>
<span class="component-item__version">{{ item.version }}</span>
<span class="component-item__ecosystem">{{ getEcosystemLabel(item.ecosystem) }}</span>
</button>
</li>
}
</ul>
} @else {
<div class="side-panel__empty">
No components on the baseline side for the current filters.
</div>
}
</div>
<!-- Right Panel (Version B - Added & Changed To) -->
@@ -261,34 +272,40 @@ export interface MergedListItem {
<span>{{ versionB() }}</span>
<span class="side-panel__count">{{ summary()?.totalB ?? 0 }} components</span>
</div>
<ul class="component-list" role="list">
@for (item of mergedListB(); track item.purl) {
<li
class="component-item"
[class.component-item--added]="item.changeType === 'added'"
[class.component-item--changed]="item.changeType === 'changed'"
[class.component-item--unchanged]="item.changeType === 'unchanged'"
>
<button
type="button"
class="component-item__button"
(click)="onMergedItemSelect(item)"
[attr.aria-label]="'Select ' + item.name"
@if (mergedListB().length > 0) {
<ul class="component-list" role="list">
@for (item of mergedListB(); track item.purl) {
<li
class="component-item"
[class.component-item--added]="item.changeType === 'added'"
[class.component-item--changed]="item.changeType === 'changed'"
[class.component-item--unchanged]="item.changeType === 'unchanged'"
>
<span class="component-item__icon" aria-hidden="true">
@switch (item.changeType) {
@case ('added') { + }
@case ('changed') { ~ }
@default { }
}
</span>
<span class="component-item__name">{{ item.name }}</span>
<span class="component-item__version">{{ item.version }}</span>
<span class="component-item__ecosystem">{{ getEcosystemLabel(item.ecosystem) }}</span>
</button>
</li>
}
</ul>
<button
type="button"
class="component-item__button"
(click)="onMergedItemSelect(item)"
[attr.aria-label]="'Select ' + item.name"
>
<span class="component-item__icon" aria-hidden="true">
@switch (item.changeType) {
@case ('added') { + }
@case ('changed') { ~ }
@default { }
}
</span>
<span class="component-item__name">{{ item.name }}</span>
<span class="component-item__version">{{ item.version }}</span>
<span class="component-item__ecosystem">{{ getEcosystemLabel(item.ecosystem) }}</span>
</button>
</li>
}
</ul>
} @else {
<div class="side-panel__empty">
No components on the current side for the current filters.
</div>
}
</div>
</div>
} @else {
@@ -905,6 +922,15 @@ export interface MergedListItem {
max-height: 600px;
}
.sbom-diff__mismatch {
border: 1px solid var(--st-border);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
color: var(--st-text-secondary);
background: var(--st-filter-bg);
}
.side-panel {
display: flex;
flex-direction: column;
@@ -960,6 +986,16 @@ export interface MergedListItem {
border: none;
}
.side-panel__empty {
margin: 0.75rem;
padding: 0.75rem;
border: 1px dashed var(--st-border);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: var(--st-text-secondary);
text-align: center;
}
.side-panel .component-item--unchanged {
opacity: 0.5;
@@ -1059,7 +1095,7 @@ export class SbomDiffViewComponent implements OnInit, OnDestroy {
}
// Sort alphabetically by name for deterministic display
return items.sort((a, b) => a.name.localeCompare(b.name));
return items.sort((a, b) => this.compareMergedItems(a, b));
});
/**
@@ -1097,8 +1133,11 @@ export class SbomDiffViewComponent implements OnInit, OnDestroy {
}
// Sort alphabetically by name for deterministic display
return items.sort((a, b) => a.name.localeCompare(b.name));
return items.sort((a, b) => this.compareMergedItems(a, b));
});
readonly sideBySideMismatch = computed(() =>
this.mergedListA().length !== this.mergedListB().length
);
ngOnInit(): void {
this.loadDiff();
@@ -1248,4 +1287,14 @@ export class SbomDiffViewComponent implements OnInit, OnDestroy {
isDowngrade(change: SbomComponentChange): boolean {
return compareVersions(change.versionA, change.versionB) < 0;
}
private compareMergedItems(a: MergedListItem, b: MergedListItem): number {
const nameCompare = a.name.localeCompare(b.name);
if (nameCompare !== 0) return nameCompare;
const ecosystemCompare = a.ecosystem.localeCompare(b.ecosystem);
if (ecosystemCompare !== 0) return ecosystemCompare;
const versionCompare = a.version.localeCompare(b.version, undefined, { numeric: true, sensitivity: 'base' });
if (versionCompare !== 0) return versionCompare;
return a.purl.localeCompare(b.purl);
}
}

View File

@@ -4,7 +4,7 @@
* @description Source detail page with run history
*/
import { Component, OnInit, signal, inject } from '@angular/core';
import { Component, OnInit, signal, inject, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { SbomSourcesService } from '../../services/sbom-sources.service';
@@ -42,7 +42,11 @@ import { SbomSource, SbomSourceRun } from '../../models/sbom-source.models';
<section class="runs-section">
<h2>Run History</h2>
@if (runs().length > 0) {
@if (runsError()) {
<div class="alert alert-error" role="alert">{{ runsError() }}</div>
}
@if (sortedRuns().length > 0) {
<table class="runs-table">
<thead>
<tr>
@@ -53,7 +57,7 @@ import { SbomSource, SbomSourceRun } from '../../models/sbom-source.models';
</tr>
</thead>
<tbody>
@for (run of runs(); track run.runId) {
@for (run of sortedRuns(); track run.runId) {
<tr>
<td>{{ run.startedAt | date:'short' }}</td>
<td>{{ run.status }}</td>
@@ -77,6 +81,13 @@ import { SbomSource, SbomSourceRun } from '../../models/sbom-source.models';
.info-section, .runs-section { margin-bottom: 32px; }
dl { display: grid; grid-template-columns: 150px 1fr; gap: 8px; }
dt { font-weight: 600; }
.alert-error {
padding: 12px;
margin-bottom: 12px;
border-radius: 4px;
background: #ffebee;
color: #c62828;
}
.runs-table { width: 100%; border-collapse: collapse; }
.runs-table th, .runs-table td { padding: 8px; border: 1px solid #ddd; text-align: left; }
`]
@@ -88,8 +99,20 @@ export class SourceDetailComponent implements OnInit {
readonly source = signal<SbomSource | null>(null);
readonly runs = signal<SbomSourceRun[]>([]);
readonly runsError = signal<string | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly sortedRuns = computed(() => {
const runs = [...this.runs()];
runs.sort((left, right) => {
const byStartedAt = this.compareDateDesc(left.startedAt, right.startedAt);
if (byStartedAt !== 0) {
return byStartedAt;
}
return right.runId.localeCompare(left.runId);
});
return runs;
});
ngOnInit(): void {
const sourceId = this.route.snapshot.paramMap.get('id');
@@ -104,25 +127,28 @@ export class SourceDetailComponent implements OnInit {
loadSource(sourceId: string): void {
this.loading.set(true);
this.error.set(null);
this.service.getSource(sourceId).subscribe({
next: (source) => {
this.source.set(source);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.error.set(this.formatError('Unable to load source details', err));
this.loading.set(false);
},
});
}
loadRuns(sourceId: string): void {
this.runsError.set(null);
this.service.getSourceRuns(sourceId).subscribe({
next: (result) => {
this.runs.set(result.items);
},
error: () => {
// Silently fail for runs
error: (err) => {
this.runsError.set(this.formatError('Unable to load run history', err));
},
});
}
@@ -132,6 +158,34 @@ export class SourceDetailComponent implements OnInit {
}
onEdit(): void {
this.router.navigate(['/sbom-sources', this.source()?.sourceId, 'edit']);
const sourceId = this.source()?.sourceId;
if (!sourceId) {
this.error.set('Cannot edit source before details finish loading.');
return;
}
this.router.navigate(['/sbom-sources', sourceId, 'edit']);
}
private formatError(prefix: string, error: unknown): string {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: string }).message ?? '')
: '';
if (message.trim().length > 0) {
return `${prefix}: ${message}`;
}
return `${prefix}. Check API connectivity and retry.`;
}
private compareDateDesc(left?: string, right?: string): number {
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
const normalizedLeft = Number.isNaN(leftTime) ? Number.NEGATIVE_INFINITY : leftTime;
const normalizedRight = Number.isNaN(rightTime) ? Number.NEGATIVE_INFINITY : rightTime;
if (normalizedLeft === normalizedRight) {
return 0;
}
return normalizedLeft > normalizedRight ? -1 : 1;
}
}

View File

@@ -12,6 +12,7 @@ import { Router, ActivatedRoute } from '@angular/router';
import { SbomSourcesService } from '../../services/sbom-sources.service';
import {
SbomSourceType,
SbomSource,
CreateSourceRequest,
ZastavaSourceConfig,
DockerSourceConfig,
@@ -907,14 +908,131 @@ export class SourceWizardComponent implements OnInit {
this.service.getSource(sourceId).subscribe({
next: (source) => {
this.selectedType.set(source.sourceType);
this.currentStep.set('basic');
this.basicForm.patchValue({
name: source.name,
description: source.description ?? '',
tags: source.tags.join(', '),
tags: this.joinCommas(source.tags),
});
// Load type-specific config...
if (source.authRef) {
this.credentialsForm.patchValue({
authMethod: 'authref',
authRefId: source.authRef,
});
}
this.applyScheduleSettings(source);
this.applyTypeConfiguration(source.sourceType, source.configuration);
},
error: (err) => this.error.set(err.message || 'Failed to load source'),
error: (err) => this.error.set(this.formatError('Failed to load source', err)),
});
}
private applyScheduleSettings(source: SbomSource): void {
if (!source.cronSchedule) {
this.scheduleForm.patchValue({
scheduleType: 'none',
timezone: source.cronTimezone ?? 'UTC',
maxScansPerHour: source.maxScansPerHour ?? 10,
});
return;
}
const preset = this.cronToPreset(source.cronSchedule);
this.scheduleForm.patchValue({
scheduleType: preset ? 'preset' : 'cron',
preset: preset ?? this.scheduleForm.controls.preset.value,
cronExpression: source.cronSchedule,
timezone: source.cronTimezone ?? 'UTC',
maxScansPerHour: source.maxScansPerHour ?? 10,
});
}
private applyTypeConfiguration(type: SbomSourceType, configuration: unknown): void {
switch (type) {
case 'zastava':
this.applyZastavaConfiguration(configuration);
return;
case 'docker':
this.applyDockerConfiguration(configuration);
return;
case 'cli':
this.applyCliConfiguration(configuration);
return;
case 'git':
this.applyGitConfiguration(configuration);
return;
}
}
private applyZastavaConfiguration(configuration: unknown): void {
const config = this.asRecord(configuration);
const filters = this.asRecord(config['filters']);
const scanOptions = this.asRecord(config['scanOptions']);
this.zastavaForm.patchValue({
registryType: this.asString(config['registryType']),
registryUrl: this.asString(config['registryUrl']),
repoFilter: this.joinLines(this.readStringArray(filters['repositories'])),
tagFilter: this.joinLines(this.readStringArray(filters['tags'])),
enableReachability: this.asBoolean(scanOptions['enableReachability'], false),
enableVexLookup: this.asBoolean(scanOptions['enableVexLookup'], true),
});
}
private applyDockerConfiguration(configuration: unknown): void {
const config = this.asRecord(configuration);
const scanOptions = this.asRecord(config['scanOptions']);
const images = this.readObjectArray(config['images']);
const references = images
.map((image) => this.asString(image['reference']))
.filter((reference) => reference.length > 0);
const digestPin = images.some((image) => this.asBoolean(image['digestPin'], false));
this.dockerForm.patchValue({
registryUrl: this.asString(config['registryUrl']),
images: this.joinLines(references),
platforms: this.joinCommas(this.readStringArray(scanOptions['platforms'])),
digestPin,
enableReachability: this.asBoolean(scanOptions['enableReachability'], false),
});
}
private applyCliConfiguration(configuration: unknown): void {
const config = this.asRecord(configuration);
const validation = this.asRecord(config['validation']);
const attribution = this.asRecord(config['attribution']);
const maxBytesRaw = validation['maxSbomSizeBytes'];
const maxBytes = typeof maxBytesRaw === 'number' ? maxBytesRaw : 10 * 1024 * 1024;
const maxSizeMb = Math.max(1, Math.round(maxBytes / (1024 * 1024)));
this.cliForm.patchValue({
allowedTools: this.sortUnique(this.readStringArray(config['allowedTools'])),
allowedFormats: this.sortUnique(this.readStringArray(validation['allowedFormats'])),
maxSizeMb,
requireSignedSbom: this.asBoolean(validation['requireSignedSbom'], false),
requireBuildId: this.asBoolean(attribution['requireBuildId'], true),
requireCommitSha: this.asBoolean(attribution['requireCommitSha'], true),
});
}
private applyGitConfiguration(configuration: unknown): void {
const config = this.asRecord(configuration);
const branches = this.asRecord(config['branches']);
const triggers = this.asRecord(config['triggers']);
const scanOptions = this.asRecord(config['scanOptions']);
this.gitForm.patchValue({
provider: this.asString(config['provider']),
repositoryUrl: this.asString(config['repositoryUrl']),
branches: this.joinLines(this.readStringArray(branches['include'])),
onPush: this.asBoolean(triggers['onPush'], true),
onPullRequest: this.asBoolean(triggers['onPullRequest'], true),
onTag: this.asBoolean(triggers['onTag'], true),
scanPaths: this.joinLines(this.readStringArray(scanOptions['scanPaths'])),
lockfileOnly: this.asBoolean(scanOptions['enableLockfileOnly'], false),
enableReachability: this.asBoolean(scanOptions['enableReachability'], false),
});
}
@@ -1087,7 +1205,7 @@ export class SourceWizardComponent implements OnInit {
case 'docker':
configuration = {
registryUrl: this.dockerForm.controls.registryUrl.value || undefined,
images: this.parseLines(this.dockerForm.controls.images.value ?? '').map(ref => ({
images: this.parseLines(this.dockerForm.controls.images.value ?? '').map((ref) => ({
reference: ref,
digestPin: this.dockerForm.controls.digestPin.value,
})),
@@ -1095,18 +1213,20 @@ export class SourceWizardComponent implements OnInit {
analyzers: ['os', 'lang.node'],
enableReachability: this.dockerForm.controls.enableReachability.value,
enableVexLookup: true,
platforms: this.parseCommas(this.dockerForm.controls.platforms.value ?? ''),
platforms: this.sortUnique(this.parseCommas(this.dockerForm.controls.platforms.value ?? '')),
},
} as DockerSourceConfig;
break;
case 'cli':
configuration = {
allowedTools: this.cliForm.controls.allowedTools.value,
allowedTools: this.sortUnique(this.cliForm.controls.allowedTools.value ?? []),
validation: {
requireSignedSbom: this.cliForm.controls.requireSignedSbom.value,
maxSbomSizeBytes: (this.cliForm.controls.maxSizeMb.value ?? 10) * 1024 * 1024,
allowedFormats: this.cliForm.controls.allowedFormats.value,
allowedFormats: this.sortUnique(
this.cliForm.controls.allowedFormats.value ?? []
) as CliSourceConfig['validation']['allowedFormats'],
},
attribution: {
requireBuildId: this.cliForm.controls.requireBuildId.value,
@@ -1160,7 +1280,7 @@ export class SourceWizardComponent implements OnInit {
cronSchedule,
cronTimezone: schedule !== 'none' ? (this.scheduleForm.controls.timezone.value ?? 'UTC') : undefined,
maxScansPerHour: schedule !== 'none' ? (this.scheduleForm.controls.maxScansPerHour.value ?? 10) : undefined,
tags: this.parseCommas(this.basicForm.controls.tags.value ?? ''),
tags: this.sortUnique(this.parseCommas(this.basicForm.controls.tags.value ?? '')),
};
}
@@ -1179,9 +1299,16 @@ export class SourceWizardComponent implements OnInit {
this.error.set(null);
const request = this.buildCreateRequest();
const editSourceId = this.route.snapshot.paramMap.get('id');
if (this.isEditMode() && !editSourceId) {
this.saving.set(false);
this.error.set('Cannot update source without a valid source id.');
return;
}
const operation = this.isEditMode()
? this.service.updateSource(this.route.snapshot.paramMap.get('id')!, request)
? this.service.updateSource(editSourceId!, request)
: this.service.createSource(request);
operation.subscribe({
@@ -1191,7 +1318,7 @@ export class SourceWizardComponent implements OnInit {
},
error: (err) => {
this.saving.set(false);
this.error.set(err.message || 'Failed to save source');
this.error.set(this.formatError('Failed to save source', err));
},
});
}
@@ -1199,4 +1326,70 @@ export class SourceWizardComponent implements OnInit {
onCancel(): void {
this.router.navigate(['/sbom-sources']);
}
private cronToPreset(cron: string): 'hourly' | 'daily' | 'weekly' | 'monthly' | null {
const normalized = cron.trim();
const presets: Record<string, 'hourly' | 'daily' | 'weekly' | 'monthly'> = {
'0 * * * *': 'hourly',
'0 2 * * *': 'daily',
'0 2 * * 0': 'weekly',
'0 2 1 * *': 'monthly',
};
return presets[normalized] ?? null;
}
private asRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
}
private readObjectArray(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => this.asRecord(entry))
.filter((entry) => Object.keys(entry).length > 0);
}
private readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
private asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
private asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
private joinLines(values: string[]): string {
return values.join('\n');
}
private joinCommas(values: string[]): string {
return values.join(', ');
}
private sortUnique(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))].sort((left, right) =>
left.localeCompare(right)
);
}
private formatError(prefix: string, error: unknown): string {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: string }).message ?? '')
: '';
if (message.trim().length > 0) {
return `${prefix}: ${message}`;
}
return `${prefix}. Check configuration or backend availability and retry.`;
}
}

View File

@@ -15,7 +15,8 @@
<input
type="text"
placeholder="Search sources..."
[(ngModel)]="searchQuery"
[ngModel]="searchQuery()"
(ngModelChange)="onSearchQueryChange($event)"
(keyup.enter)="onSearch()"
class="search-input"
/>
@@ -23,7 +24,10 @@
</div>
<div class="filter-group">
<select [(ngModel)]="selectedType" (change)="onFilterChange()" class="filter-select">
<select
[ngModel]="selectedType()"
(ngModelChange)="onSelectedTypeChange($event)"
class="filter-select">
<option value="all">All Types</option>
<option value="zastava">Registry Webhook</option>
<option value="docker">Docker Image</option>
@@ -31,7 +35,10 @@
<option value="git">Git Repository</option>
</select>
<select [(ngModel)]="selectedStatus" (change)="onFilterChange()" class="filter-select">
<select
[ngModel]="selectedStatus()"
(ngModelChange)="onSelectedStatusChange($event)"
class="filter-select">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
@@ -50,8 +57,22 @@
<!-- Error message -->
@if (error()) {
<div class="alert alert-error">
{{ error() }}
<div class="alert alert-error" role="alert">
<span>{{ error() }}</span>
<button class="btn btn-sm btn-outline" type="button" (click)="loadSources()">Retry</button>
</div>
}
<!-- Operation feedback -->
@if (operationNotice()) {
<div
class="alert"
[class.alert-success]="operationNotice()!.kind === 'success'"
[class.alert-info]="operationNotice()!.kind === 'info'"
[class.alert-error]="operationNotice()!.kind === 'error'"
role="status">
<span>{{ operationNotice()!.message }}</span>
<button class="btn btn-sm btn-outline" type="button" (click)="dismissOperationNotice()">Dismiss</button>
</div>
}
@@ -63,7 +84,7 @@
</div>
} @else {
<!-- Sources table -->
@if (sources().length > 0) {
@if (sortedSources().length > 0) {
<div class="table-container">
<table class="sources-table">
<thead>
@@ -92,7 +113,7 @@
</tr>
</thead>
<tbody>
@for (source of sources(); track source.sourceId) {
@for (source of sortedSources(); track source.sourceId) {
<tr (click)="onViewSource(source)" class="table-row-clickable">
<td>
<div class="source-name">

View File

@@ -392,6 +392,10 @@
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.alert-error {
@@ -400,6 +404,18 @@
border: 1px solid var(--color-status-error);
}
.alert-success {
background: var(--color-status-success-bg);
color: var(--color-status-success);
border: 1px solid var(--color-status-success);
}
.alert-info {
background: var(--color-status-info-bg);
color: var(--color-status-info);
border: 1px solid var(--color-status-info);
}
/* Responsive */
@include screen-below-md {
.sources-list-container {

View File

@@ -30,6 +30,7 @@ export class SourcesListComponent implements OnInit {
readonly sources = signal<SbomSource[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly operationNotice = signal<{ kind: 'success' | 'error' | 'info'; message: string } | null>(null);
// Pagination
readonly pageNumber = signal(1);
@@ -54,6 +55,9 @@ export class SourcesListComponent implements OnInit {
this.selectedType() !== 'all' ||
this.selectedStatus() !== 'all'
);
readonly sortedSources = computed(() =>
this.sortSources(this.sources(), this.sortBy(), this.sortDirection())
);
ngOnInit(): void {
this.loadSources();
@@ -75,17 +79,35 @@ export class SourcesListComponent implements OnInit {
this.sourcesService.listSources(params).subscribe({
next: (result) => {
this.sources.set(result.items);
this.sources.set(result.items ?? []);
this.totalCount.set(result.totalCount);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load sources');
this.error.set(this.formatError('Unable to load SBOM sources', err));
this.loading.set(false);
},
});
}
onSearchQueryChange(value: string): void {
this.searchQuery.set(value);
}
onSelectedTypeChange(value: SbomSourceType | 'all'): void {
this.selectedType.set(value);
this.onFilterChange();
}
onSelectedStatusChange(value: SbomSourceStatus | 'all'): void {
this.selectedStatus.set(value);
this.onFilterChange();
}
dismissOperationNotice(): void {
this.operationNotice.set(null);
}
onSearch(): void {
this.pageNumber.set(1); // Reset to first page
this.loadSources();
@@ -145,10 +167,14 @@ export class SourcesListComponent implements OnInit {
next: () => {
this.showDeleteDialog.set(false);
this.selectedSource.set(null);
this.operationNotice.set({
kind: 'success',
message: `Source "${source.name}" was deleted.`,
});
this.loadSources();
},
error: (err) => {
this.error.set(`Failed to delete source: ${err.message}`);
this.error.set(this.formatError(`Failed to delete source "${source.name}"`, err));
this.showDeleteDialog.set(false);
},
});
@@ -163,10 +189,18 @@ export class SourcesListComponent implements OnInit {
event.stopPropagation();
this.sourcesService.testConnection(source.sourceId).subscribe({
next: (result) => {
alert(result.success ? 'Connection successful!' : `Connection failed: ${result.message}`);
this.operationNotice.set({
kind: result.success ? 'success' : 'error',
message: result.success
? `Connection test passed for "${source.name}".`
: `Connection test failed for "${source.name}": ${result.message}`,
});
},
error: (err) => {
alert(`Test failed: ${err.message}`);
this.operationNotice.set({
kind: 'error',
message: this.formatError(`Connection test failed for "${source.name}"`, err),
});
},
});
}
@@ -177,11 +211,17 @@ export class SourcesListComponent implements OnInit {
this.sourcesService.triggerScan(source.sourceId).subscribe({
next: (run) => {
alert(`Scan triggered successfully! Run ID: ${run.runId}`);
this.operationNotice.set({
kind: 'success',
message: `Scan triggered for "${source.name}" (run ${run.runId}).`,
});
this.loadSources(); // Refresh to show updated status
},
error: (err) => {
alert(`Failed to trigger scan: ${err.message}`);
this.operationNotice.set({
kind: 'error',
message: this.formatError(`Failed to trigger scan for "${source.name}"`, err),
});
},
});
}
@@ -193,10 +233,17 @@ export class SourcesListComponent implements OnInit {
this.sourcesService.pauseSource(source.sourceId, { reason }).subscribe({
next: () => {
this.operationNotice.set({
kind: 'info',
message: `Source "${source.name}" is paused.`,
});
this.loadSources();
},
error: (err) => {
alert(`Failed to pause source: ${err.message}`);
this.operationNotice.set({
kind: 'error',
message: this.formatError(`Failed to pause "${source.name}"`, err),
});
},
});
}
@@ -205,10 +252,17 @@ export class SourcesListComponent implements OnInit {
event.stopPropagation();
this.sourcesService.resumeSource(source.sourceId).subscribe({
next: () => {
this.operationNotice.set({
kind: 'success',
message: `Source "${source.name}" is active.`,
});
this.loadSources();
},
error: (err) => {
alert(`Failed to resume source: ${err.message}`);
this.operationNotice.set({
kind: 'error',
message: this.formatError(`Failed to resume "${source.name}"`, err),
});
},
});
}
@@ -243,4 +297,76 @@ export class SourcesListComponent implements OnInit {
};
return icons[type] || '📄';
}
private formatError(prefix: string, error: unknown): string {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: string }).message ?? '')
: '';
if (message.trim().length > 0) {
return `${prefix}: ${message}`;
}
return `${prefix}. Check API connectivity/permissions and retry.`;
}
private sortSources(
sources: SbomSource[],
sortBy: 'name' | 'status' | 'lastRun' | 'createdAt',
sortDirection: 'asc' | 'desc'
): SbomSource[] {
const direction = sortDirection === 'asc' ? 1 : -1;
const sorted = [...sources];
sorted.sort((left, right) => {
const primary = this.compareColumn(left, right, sortBy) * direction;
if (primary !== 0) {
return primary;
}
const byName = this.compareText(left.name, right.name);
if (byName !== 0) {
return byName;
}
return this.compareText(left.sourceId, right.sourceId);
});
return sorted;
}
private compareColumn(
left: SbomSource,
right: SbomSource,
sortBy: 'name' | 'status' | 'lastRun' | 'createdAt'
): number {
if (sortBy === 'name') {
return this.compareText(left.name, right.name);
}
if (sortBy === 'status') {
return this.compareText(left.status, right.status);
}
if (sortBy === 'lastRun') {
return this.compareDate(left.lastRunAt, right.lastRunAt);
}
return this.compareDate(left.createdAt, right.createdAt);
}
private compareText(left: string, right: string): number {
return left.localeCompare(right);
}
private compareDate(left?: string, right?: string): number {
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
const normalizedLeft = Number.isNaN(leftTime) ? Number.NEGATIVE_INFINITY : leftTime;
const normalizedRight = Number.isNaN(rightTime) ? Number.NEGATIVE_INFINITY : rightTime;
if (normalizedLeft === normalizedRight) {
return 0;
}
return normalizedLeft < normalizedRight ? -1 : 1;
}
}

View File

@@ -443,7 +443,7 @@ export class PatchListComponent {
/** Computed patches list */
readonly patches = computed<PedigreePatch[]>(() => {
return this.pedigree()?.patches ?? [];
return [...(this.pedigree()?.patches ?? [])];
});
/** Check if patch at index is expanded */

View File

@@ -1,18 +1,74 @@
// Determinism Settings Component
// Sprint 025: Scanner Ops Settings UI
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
input,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
interface DeterminismSettingsState {
stableOrdering: boolean;
contentAddressed: boolean;
utcTimestamps: boolean;
recordReachability: boolean;
preserveRawOutput: boolean;
offlineMode: boolean;
maxReplayHours: number;
maxEventBufferMb: number;
}
const SETTINGS_STORAGE_KEY = 'stellaops.scannerOps.settings.v1';
const DEFAULT_SETTINGS: DeterminismSettingsState = {
stableOrdering: true,
contentAddressed: true,
utcTimestamps: true,
recordReachability: true,
preserveRawOutput: false,
offlineMode: false,
maxReplayHours: 24,
maxEventBufferMb: 128,
};
@Component({
selector: 'app-determinism-settings',
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
selector: 'app-determinism-settings',
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="determinism-settings">
<h3>Determinism & Replay Settings</h3>
@if (loading()) {
<div class="settings-banner settings-banner--info" role="status">
Loading scanner settings...
</div>
}
@if (!canEdit()) {
<div class="settings-banner settings-banner--warning" role="alert">
You have read-only access. Request scanner write permissions to persist settings changes.
</div>
}
@if (error()) {
<div class="settings-banner settings-banner--error" role="alert">
<span>{{ error() }}</span>
<button class="btn btn--secondary btn--small" type="button" (click)="retrySave()">
Retry Save
</button>
</div>
}
@if (saveNotice()) {
<div class="settings-banner settings-banner--success" role="status">
{{ saveNotice() }}
</div>
}
<section class="settings-section">
<h4>Scan Determinism</h4>
<p class="section-desc">
@@ -23,7 +79,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>Stable Ordering</label>
<span class="setting-hint">
Ensure findings are always output in the same order
Ensure findings are always output in the same order.
</span>
</div>
<label class="toggle">
@@ -31,6 +87,7 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="stableOrdering()"
(ngModelChange)="stableOrdering.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
@@ -40,7 +97,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>Content-Addressed Bundles</label>
<span class="setting-hint">
Use SHA-256 content addressing for all output bundles
Use SHA-256 content addressing for all output bundles.
</span>
</div>
<label class="toggle">
@@ -48,6 +105,7 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="contentAddressed()"
(ngModelChange)="contentAddressed.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
@@ -57,7 +115,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>UTC Timestamps Only</label>
<span class="setting-hint">
All timestamps use UTC ISO-8601 format
All timestamps use UTC ISO-8601 format.
</span>
</div>
<label class="toggle">
@@ -65,6 +123,7 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="utcTimestamps()"
(ngModelChange)="utcTimestamps.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
@@ -81,7 +140,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>Record Reachability</label>
<span class="setting-hint">
Record call graph data for reachability analysis
Record call graph data for reachability analysis.
</span>
</div>
<label class="toggle">
@@ -89,6 +148,7 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="recordReachability()"
(ngModelChange)="recordReachability.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
@@ -98,7 +158,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>Preserve Raw Output</label>
<span class="setting-hint">
Keep raw analyzer output for debugging
Keep raw analyzer output for debugging.
</span>
</div>
<label class="toggle">
@@ -106,10 +166,49 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="preserveRawOutput()"
(ngModelChange)="preserveRawOutput.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row setting-row--field">
<div class="setting-info">
<label for="maxReplayHours">Replay Retention (Hours)</label>
<span class="setting-hint">
Retain replay snapshots between 1 and 168 hours.
</span>
</div>
<input
id="maxReplayHours"
class="number-input"
type="number"
min="1"
max="168"
[ngModel]="maxReplayHours()"
(ngModelChange)="onMaxReplayHoursChange($event)"
[disabled]="!canEdit()"
/>
</div>
<div class="setting-row setting-row--field">
<div class="setting-info">
<label for="maxEventBufferMb">Event Buffer (MB)</label>
<span class="setting-hint">
Keep buffer between 16 and 2048 MB for deterministic replay imports.
</span>
</div>
<input
id="maxEventBufferMb"
class="number-input"
type="number"
min="16"
max="2048"
[ngModel]="maxEventBufferMb()"
(ngModelChange)="onMaxEventBufferMbChange($event)"
[disabled]="!canEdit()"
/>
</div>
</section>
<section class="settings-section">
@@ -122,7 +221,7 @@ import { FormsModule } from '@angular/forms';
<div class="setting-info">
<label>Offline Operation</label>
<span class="setting-hint">
Scanner operates without network access
Scanner operates without network access.
</span>
</div>
<label class="toggle">
@@ -130,6 +229,7 @@ import { FormsModule } from '@angular/forms';
type="checkbox"
[ngModel]="offlineMode()"
(ngModelChange)="offlineMode.set($event)"
[disabled]="!canEdit()"
/>
<span class="toggle-slider"></span>
</label>
@@ -142,17 +242,31 @@ import { FormsModule } from '@angular/forms';
}
</section>
@if (validationMessage()) {
<div class="validation-error" role="alert">
{{ validationMessage() }}
</div>
}
<div class="settings-actions">
<button class="btn btn--secondary" (click)="resetDefaults()">
<button
class="btn btn--secondary"
type="button"
[disabled]="!canEdit() || saving()"
(click)="resetDefaults()">
Reset to Defaults
</button>
<button class="btn btn--primary" (click)="saveSettings()">
Save Changes
<button
class="btn btn--primary"
type="button"
[disabled]="!canSave()"
(click)="saveSettings()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</div>
`,
styles: [`
styles: [`
.determinism-settings h3 {
font-size: 1.125rem;
font-weight: 600;
@@ -181,10 +295,46 @@ import { FormsModule } from '@angular/forms';
margin: 0 0 1rem;
}
.settings-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.settings-banner--info {
background: rgba(34, 211, 238, 0.12);
border: 1px solid rgba(34, 211, 238, 0.4);
color: #67e8f9;
}
.settings-banner--warning {
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.4);
color: #fcd34d;
}
.settings-banner--success {
background: rgba(74, 222, 128, 0.12);
border: 1px solid rgba(74, 222, 128, 0.4);
color: #86efac;
}
.settings-banner--error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.4);
color: #fda4af;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid #1f2937;
}
@@ -193,6 +343,10 @@ import { FormsModule } from '@angular/forms';
border-bottom: none;
}
.setting-row--field {
align-items: center;
}
.setting-info label {
display: block;
font-size: 0.875rem;
@@ -206,6 +360,21 @@ import { FormsModule } from '@angular/forms';
color: var(--color-text-secondary);
}
.number-input {
width: 7rem;
background: #111827;
border: 1px solid #334155;
color: #e5e7eb;
border-radius: 6px;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
}
.number-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle {
position: relative;
display: inline-block;
@@ -233,12 +402,12 @@ import { FormsModule } from '@angular/forms';
.toggle-slider::before {
position: absolute;
content: "";
content: '';
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
background-color: #fff;
transition: 0.3s;
border-radius: 50%;
}
@@ -251,6 +420,11 @@ import { FormsModule } from '@angular/forms';
transform: translateX(20px);
}
.toggle input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
.warning-banner {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
@@ -261,6 +435,17 @@ import { FormsModule } from '@angular/forms';
font-size: 0.875rem;
}
.validation-error {
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.4);
border-radius: 6px;
color: #fda4af;
padding: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.settings-actions {
display: flex;
justify-content: flex-end;
@@ -271,6 +456,7 @@ import { FormsModule } from '@angular/forms';
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
@@ -279,28 +465,181 @@ import { FormsModule } from '@angular/forms';
cursor: pointer;
}
.btn--primary { background: #22d3ee; color: var(--color-text-heading); }
.btn--secondary { background: var(--color-text-primary); color: #e5e7eb; }
`]
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.btn--primary {
background: #22d3ee;
color: var(--color-text-heading);
}
.btn--secondary {
background: var(--color-text-primary);
color: #e5e7eb;
}
`],
})
export class DeterminismSettingsComponent {
readonly stableOrdering = signal(true);
readonly contentAddressed = signal(true);
readonly utcTimestamps = signal(true);
readonly recordReachability = signal(true);
readonly preserveRawOutput = signal(false);
readonly offlineMode = signal(false);
export class DeterminismSettingsComponent implements OnInit {
readonly canEdit = input(true);
readonly loading = signal(true);
readonly saving = signal(false);
readonly saveNotice = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly stableOrdering = signal(DEFAULT_SETTINGS.stableOrdering);
readonly contentAddressed = signal(DEFAULT_SETTINGS.contentAddressed);
readonly utcTimestamps = signal(DEFAULT_SETTINGS.utcTimestamps);
readonly recordReachability = signal(DEFAULT_SETTINGS.recordReachability);
readonly preserveRawOutput = signal(DEFAULT_SETTINGS.preserveRawOutput);
readonly offlineMode = signal(DEFAULT_SETTINGS.offlineMode);
readonly maxReplayHours = signal(DEFAULT_SETTINGS.maxReplayHours);
readonly maxEventBufferMb = signal(DEFAULT_SETTINGS.maxEventBufferMb);
readonly validationMessage = computed(() => {
const replayHours = this.maxReplayHours();
if (!Number.isInteger(replayHours) || replayHours < 1 || replayHours > 168) {
return 'Replay retention must be an integer between 1 and 168 hours.';
}
const bufferMb = this.maxEventBufferMb();
if (!Number.isInteger(bufferMb) || bufferMb < 16 || bufferMb > 2048) {
return 'Event buffer must be an integer between 16 and 2048 MB.';
}
if (!this.offlineMode() && this.preserveRawOutput() && bufferMb < 64) {
return 'Set event buffer to at least 64 MB when preserve raw output is enabled.';
}
return null;
});
readonly canSave = computed(() => !this.saving() && this.canEdit() && this.validationMessage() === null);
ngOnInit(): void {
this.loadSettings();
}
onMaxReplayHoursChange(value: number | string): void {
const parsed = Number.parseInt(String(value), 10);
this.maxReplayHours.set(Number.isFinite(parsed) ? parsed : 0);
}
onMaxEventBufferMbChange(value: number | string): void {
const parsed = Number.parseInt(String(value), 10);
this.maxEventBufferMb.set(Number.isFinite(parsed) ? parsed : 0);
}
resetDefaults(): void {
this.stableOrdering.set(true);
this.contentAddressed.set(true);
this.utcTimestamps.set(true);
this.recordReachability.set(true);
this.preserveRawOutput.set(false);
this.offlineMode.set(false);
this.applySettings(DEFAULT_SETTINGS);
this.error.set(null);
this.saveNotice.set('Defaults restored. Save changes to persist.');
}
saveSettings(): void {
console.log('Saving settings...');
if (!this.canEdit()) {
this.error.set('Write permission is required to save scanner settings.');
return;
}
const validation = this.validationMessage();
if (validation) {
this.error.set(validation);
return;
}
this.saving.set(true);
this.error.set(null);
this.saveNotice.set(null);
try {
const payload = JSON.stringify(this.currentSettings());
localStorage.setItem(SETTINGS_STORAGE_KEY, payload);
this.saveNotice.set(`Settings saved at ${new Date().toISOString()}.`);
} catch {
this.error.set('Unable to save settings. Check browser storage availability and retry.');
} finally {
this.saving.set(false);
}
}
retrySave(): void {
this.saveSettings();
}
private loadSettings(): void {
this.loading.set(true);
this.error.set(null);
try {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) {
this.applySettings(DEFAULT_SETTINGS);
return;
}
const parsed = JSON.parse(raw) as Partial<DeterminismSettingsState>;
this.applySettings(this.normalizeSettings(parsed));
} catch {
this.applySettings(DEFAULT_SETTINGS);
this.error.set('Saved settings could not be loaded. Defaults were restored.');
} finally {
this.loading.set(false);
}
}
private currentSettings(): DeterminismSettingsState {
return {
stableOrdering: this.stableOrdering(),
contentAddressed: this.contentAddressed(),
utcTimestamps: this.utcTimestamps(),
recordReachability: this.recordReachability(),
preserveRawOutput: this.preserveRawOutput(),
offlineMode: this.offlineMode(),
maxReplayHours: this.maxReplayHours(),
maxEventBufferMb: this.maxEventBufferMb(),
};
}
private applySettings(settings: DeterminismSettingsState): void {
this.stableOrdering.set(settings.stableOrdering);
this.contentAddressed.set(settings.contentAddressed);
this.utcTimestamps.set(settings.utcTimestamps);
this.recordReachability.set(settings.recordReachability);
this.preserveRawOutput.set(settings.preserveRawOutput);
this.offlineMode.set(settings.offlineMode);
this.maxReplayHours.set(settings.maxReplayHours);
this.maxEventBufferMb.set(settings.maxEventBufferMb);
}
private normalizeSettings(
value: Partial<DeterminismSettingsState> | null | undefined
): DeterminismSettingsState {
const safe = value ?? {};
return {
stableOrdering: this.toBoolean(safe.stableOrdering, DEFAULT_SETTINGS.stableOrdering),
contentAddressed: this.toBoolean(safe.contentAddressed, DEFAULT_SETTINGS.contentAddressed),
utcTimestamps: this.toBoolean(safe.utcTimestamps, DEFAULT_SETTINGS.utcTimestamps),
recordReachability: this.toBoolean(safe.recordReachability, DEFAULT_SETTINGS.recordReachability),
preserveRawOutput: this.toBoolean(safe.preserveRawOutput, DEFAULT_SETTINGS.preserveRawOutput),
offlineMode: this.toBoolean(safe.offlineMode, DEFAULT_SETTINGS.offlineMode),
maxReplayHours: this.toInteger(safe.maxReplayHours, DEFAULT_SETTINGS.maxReplayHours),
maxEventBufferMb: this.toInteger(safe.maxEventBufferMb, DEFAULT_SETTINGS.maxEventBufferMb),
};
}
private toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
private toInteger(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
}
}

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