save checkpoint: save features
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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(','));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -147,6 +147,8 @@ export class PlaybookSuggestionService {
|
||||
reachability: query.reachability,
|
||||
componentType: query.componentType,
|
||||
contextTags: query.contextTags,
|
||||
maxResults: query.maxResults,
|
||||
minConfidence: query.minConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
👁
|
||||
</a>
|
||||
<button
|
||||
class="btn-icon"
|
||||
title="Open Drawer"
|
||||
aria-label="Open packet drawer"
|
||||
(click)="openDrawer(packet)"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<button class="btn-icon" title="Download" (click)="onDownload(packet.id)">
|
||||
⬇
|
||||
</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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">↑</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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user