tests fixes and sprints work
This commit is contained in:
@@ -25,6 +25,11 @@
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src/styles"
|
||||
]
|
||||
},
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
@@ -92,6 +97,11 @@
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src/styles"
|
||||
]
|
||||
},
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/features/policy-studio/editor/monaco-loader.service.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
requirePolicyApproverGuard,
|
||||
requirePolicyReviewOrApproveGuard,
|
||||
requirePolicyViewerGuard,
|
||||
requireAnalyticsViewerGuard,
|
||||
} from './core/auth';
|
||||
|
||||
import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
|
||||
@@ -50,6 +51,16 @@ export const routes: Routes = [
|
||||
),
|
||||
},
|
||||
|
||||
// Analytics - SBOM and attestation insights (SPRINT_20260120_031)
|
||||
{
|
||||
path: 'analytics',
|
||||
canMatch: [requireAnalyticsViewerGuard],
|
||||
loadChildren: () =>
|
||||
import('./features/analytics/analytics.routes').then(
|
||||
(m) => m.ANALYTICS_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Policy - governance and exceptions (SEC-007)
|
||||
{
|
||||
path: 'policy',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { AnalyticsHttpClient } from './analytics.client';
|
||||
import { PlatformListResponse } from './analytics.models';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
return 'tenant-analytics';
|
||||
}
|
||||
}
|
||||
|
||||
describe('AnalyticsHttpClient', () => {
|
||||
let client: AnalyticsHttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AnalyticsHttpClient,
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
client = TestBed.inject(AnalyticsHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('adds tenant and trace headers when listing suppliers', () => {
|
||||
client.getSuppliers(10, 'prod', { traceId: 'trace-123' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url === '/api/analytics/suppliers' &&
|
||||
r.params.get('limit') === '10' &&
|
||||
r.params.get('environment') === 'prod'
|
||||
);
|
||||
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-analytics');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-123');
|
||||
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-123');
|
||||
|
||||
const response: PlatformListResponse<unknown> = {
|
||||
tenantId: 'tenant-analytics',
|
||||
actorId: 'actor-1',
|
||||
dataAsOf: '2026-01-20T00:00:00Z',
|
||||
cached: false,
|
||||
cacheTtlSeconds: 300,
|
||||
items: [],
|
||||
count: 0,
|
||||
};
|
||||
|
||||
req.flush(response);
|
||||
});
|
||||
|
||||
it('maps error responses with trace context', (done) => {
|
||||
client.getLicenses(null, { traceId: 'trace-error' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('trace-error');
|
||||
expect(String(err)).toContain('Analytics error');
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/analytics/licenses');
|
||||
req.flush({ detail: 'not ready' }, { status: 503, statusText: 'Unavailable' });
|
||||
});
|
||||
});
|
||||
218
src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts
Normal file
218
src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// Sprint: SPRINT_20260120_031_FE_sbom_analytics_console
|
||||
|
||||
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import {
|
||||
AnalyticsAttestationCoverage,
|
||||
AnalyticsComponentTrendPoint,
|
||||
AnalyticsFixableBacklogItem,
|
||||
AnalyticsLicenseDistribution,
|
||||
AnalyticsSupplierConcentration,
|
||||
AnalyticsVulnerabilityExposure,
|
||||
AnalyticsVulnerabilityTrendPoint,
|
||||
PlatformListResponse,
|
||||
} from './analytics.models';
|
||||
|
||||
export interface AnalyticsRequestOptions {
|
||||
traceId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsHttpClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = '/api/analytics';
|
||||
|
||||
getSuppliers(
|
||||
limit?: number,
|
||||
environment?: string | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsSupplierConcentration>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (limit !== undefined) {
|
||||
params = params.set('limit', String(limit));
|
||||
}
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsSupplierConcentration>>(
|
||||
`${this.baseUrl}/suppliers`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getLicenses(
|
||||
environment?: string | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsLicenseDistribution>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsLicenseDistribution>>(
|
||||
`${this.baseUrl}/licenses`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getVulnerabilities(
|
||||
environment?: string | null,
|
||||
minSeverity?: string | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsVulnerabilityExposure>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
if (minSeverity) {
|
||||
params = params.set('minSeverity', minSeverity);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsVulnerabilityExposure>>(
|
||||
`${this.baseUrl}/vulnerabilities`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getFixableBacklog(
|
||||
environment?: string | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsFixableBacklogItem>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsFixableBacklogItem>>(
|
||||
`${this.baseUrl}/backlog`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getAttestationCoverage(
|
||||
environment?: string | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsAttestationCoverage>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsAttestationCoverage>>(
|
||||
`${this.baseUrl}/attestation-coverage`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getVulnerabilityTrends(
|
||||
environment?: string | null,
|
||||
days?: number | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsVulnerabilityTrendPoint>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
if (days !== undefined && days !== null) {
|
||||
params = params.set('days', String(days));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsVulnerabilityTrendPoint>>(
|
||||
`${this.baseUrl}/trends/vulnerabilities`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getComponentTrends(
|
||||
environment?: string | null,
|
||||
days?: number | null,
|
||||
options: AnalyticsRequestOptions = {}
|
||||
): Observable<PlatformListResponse<AnalyticsComponentTrendPoint>> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
let params = new HttpParams();
|
||||
if (environment) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
if (days !== undefined && days !== null) {
|
||||
params = params.set('days', String(days));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<PlatformListResponse<AnalyticsComponentTrendPoint>>(
|
||||
`${this.baseUrl}/trends/components`,
|
||||
{ headers: this.buildHeaders(traceId, options.tenantId), params }
|
||||
)
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string, tenantId?: string): HttpHeaders {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
const detail = this.extractDetail(err);
|
||||
return new Error(`[${traceId}] Analytics error: ${detail}`);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return new Error(`[${traceId}] Analytics error: ${err.message}`);
|
||||
}
|
||||
|
||||
return new Error(`[${traceId}] Analytics error: Unknown error`);
|
||||
}
|
||||
|
||||
private extractDetail(error: HttpErrorResponse): string {
|
||||
if (typeof error.error?.detail === 'string' && error.error.detail.trim()) {
|
||||
return error.error.detail.trim();
|
||||
}
|
||||
|
||||
if (typeof error.error?.message === 'string' && error.error.message.trim()) {
|
||||
return error.error.message.trim();
|
||||
}
|
||||
|
||||
if (typeof error.error === 'string' && error.error.trim()) {
|
||||
return error.error.trim();
|
||||
}
|
||||
|
||||
if (error.status === 503) {
|
||||
return 'Analytics storage is not configured.';
|
||||
}
|
||||
|
||||
if (error.status) {
|
||||
return `${error.status} ${error.statusText || 'request failed'}`.trim();
|
||||
}
|
||||
|
||||
return error.message || 'Unknown error';
|
||||
}
|
||||
}
|
||||
84
src/Web/StellaOps.Web/src/app/core/api/analytics.models.ts
Normal file
84
src/Web/StellaOps.Web/src/app/core/api/analytics.models.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Sprint: SPRINT_20260120_031_FE_sbom_analytics_console
|
||||
|
||||
export interface PlatformListResponse<T> {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
dataAsOf: string;
|
||||
cached: boolean;
|
||||
cacheTtlSeconds: number;
|
||||
items: T[];
|
||||
count: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsSupplierConcentration {
|
||||
supplier: string;
|
||||
componentCount: number;
|
||||
artifactCount: number;
|
||||
teamCount: number;
|
||||
criticalVulnCount: number;
|
||||
highVulnCount: number;
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface AnalyticsLicenseDistribution {
|
||||
licenseConcluded?: string | null;
|
||||
licenseCategory: string;
|
||||
componentCount: number;
|
||||
artifactCount: number;
|
||||
ecosystems?: string[];
|
||||
}
|
||||
|
||||
export interface AnalyticsVulnerabilityExposure {
|
||||
vulnId: string;
|
||||
severity: string;
|
||||
cvssScore?: number | null;
|
||||
epssScore?: number | null;
|
||||
kevListed: boolean;
|
||||
fixAvailable: boolean;
|
||||
rawComponentCount: number;
|
||||
rawArtifactCount: number;
|
||||
effectiveComponentCount: number;
|
||||
effectiveArtifactCount: number;
|
||||
vexMitigated: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsFixableBacklogItem {
|
||||
service: string;
|
||||
environment: string;
|
||||
component: string;
|
||||
version?: string | null;
|
||||
vulnId: string;
|
||||
severity: string;
|
||||
fixedVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsAttestationCoverage {
|
||||
environment: string;
|
||||
team?: string | null;
|
||||
totalArtifacts: number;
|
||||
withProvenance: number;
|
||||
provenancePct?: number | null;
|
||||
slsaLevel2Plus: number;
|
||||
slsa2Pct?: number | null;
|
||||
missingProvenance: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsVulnerabilityTrendPoint {
|
||||
snapshotDate: string;
|
||||
environment: string;
|
||||
totalVulns: number;
|
||||
fixableVulns: number;
|
||||
vexMitigated: number;
|
||||
netExposure: number;
|
||||
kevVulns: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsComponentTrendPoint {
|
||||
snapshotDate: string;
|
||||
environment: string;
|
||||
totalComponents: number;
|
||||
uniqueSuppliers: number;
|
||||
}
|
||||
@@ -215,6 +215,14 @@ export const requirePolicyAuditGuard: CanMatchFn = requireScopesGuard(
|
||||
'/console/profile'
|
||||
);
|
||||
|
||||
/**
|
||||
* Guard requiring ui.read and analytics.read scope for analytics console.
|
||||
*/
|
||||
export const requireAnalyticsViewerGuard: CanMatchFn = requireScopesGuard(
|
||||
[StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ],
|
||||
'/console/profile'
|
||||
);
|
||||
|
||||
/**
|
||||
* Guard requiring exception:read scope for Exception Center access.
|
||||
*/
|
||||
|
||||
@@ -105,10 +105,12 @@ const MOCK_USER: AuthUser = {
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
// UI permissions
|
||||
StellaOpsScopes.UI_READ,
|
||||
],
|
||||
};
|
||||
// UI permissions
|
||||
StellaOpsScopes.UI_READ,
|
||||
// Analytics permissions
|
||||
StellaOpsScopes.ANALYTICS_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
requirePolicyOperatorGuard,
|
||||
requirePolicySimulatorGuard,
|
||||
requirePolicyAuditGuard,
|
||||
requireAnalyticsViewerGuard,
|
||||
} from './auth.guard';
|
||||
|
||||
export {
|
||||
|
||||
@@ -60,15 +60,18 @@ export const StellaOpsScopes = {
|
||||
VEX_READ: 'vex:read',
|
||||
VEX_EXPORT: 'vex:export',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
RELEASE_PUBLISH: 'release:publish',
|
||||
RELEASE_BYPASS: 'release:bypass',
|
||||
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
RELEASE_PUBLISH: 'release:publish',
|
||||
RELEASE_BYPASS: 'release:bypass',
|
||||
|
||||
// Analytics scopes
|
||||
ANALYTICS_READ: 'analytics.read',
|
||||
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
@@ -278,12 +281,13 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'advisory:read': 'View Advisories',
|
||||
'vex:read': 'View VEX Evidence',
|
||||
'vex:export': 'Export VEX Evidence',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'analytics.read': 'View Analytics',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
|
||||
@@ -56,6 +56,11 @@ export class ConsoleSessionStore {
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly currentTenant = computed(() => {
|
||||
const tenantId = this.selectedTenantIdSignal();
|
||||
if (!tenantId) return null;
|
||||
return this.tenantsSignal().find(tenant => tenant.id === tenantId) ?? null;
|
||||
});
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
@@ -117,6 +122,10 @@ export class ConsoleSessionStore {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
currentTenantSnapshot(): ConsoleTenant | null {
|
||||
return this.currentTenant();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
|
||||
@@ -22,9 +22,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Analyze - Scanning, vulnerabilities, and reachability
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'analyze',
|
||||
label: 'Analyze',
|
||||
@@ -90,9 +90,28 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Analytics - SBOM and attestation insights
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
icon: 'bar-chart',
|
||||
requiredScopes: ['ui.read', 'analytics.read'],
|
||||
items: [
|
||||
{
|
||||
id: 'sbom-lake',
|
||||
label: 'SBOM Lake',
|
||||
route: '/analytics/sbom-lake',
|
||||
icon: 'chart',
|
||||
tooltip: 'SBOM analytics lake dashboards and trends',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Triage - Artifact management and risk assessment
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'triage',
|
||||
label: 'Triage',
|
||||
|
||||
@@ -26,7 +26,7 @@ export const PolicyGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
|
||||
// Check if user is authenticated
|
||||
const session = authStore.session();
|
||||
if (!session?.accessToken) {
|
||||
if (!session?.tokens?.accessToken) {
|
||||
return router.createUrlTree(['/welcome'], {
|
||||
queryParams: { returnUrl: route.url.join('/') },
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export const PolicyGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
}
|
||||
|
||||
// Get user scopes from token
|
||||
const userScopes = parseScopes(session.accessToken);
|
||||
const userScopes = parseScopes(session.tokens.accessToken);
|
||||
|
||||
// Check if user has at least one of the required scopes
|
||||
const hasScope = requiredScopes.some(scope => userScopes.includes(scope));
|
||||
@@ -74,7 +74,7 @@ export const PolicyReadGuard: CanActivateFn = (route) => {
|
||||
const modifiedRoute = {
|
||||
...route,
|
||||
data: { ...route.data, requiredScopes: ['policy:read'] as PolicyScope[] },
|
||||
} as ActivatedRouteSnapshot;
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
return PolicyGuard(modifiedRoute, {} as never);
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export const PolicyEditGuard: CanActivateFn = (route) => {
|
||||
const modifiedRoute = {
|
||||
...route,
|
||||
data: { ...route.data, requiredScopes: ['policy:edit'] as PolicyScope[] },
|
||||
} as ActivatedRouteSnapshot;
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
return PolicyGuard(modifiedRoute, {} as never);
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ export const PolicyActivateGuard: CanActivateFn = (route) => {
|
||||
const modifiedRoute = {
|
||||
...route,
|
||||
data: { ...route.data, requiredScopes: ['policy:activate'] as PolicyScope[] },
|
||||
} as ActivatedRouteSnapshot;
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
return PolicyGuard(modifiedRoute, {} as never);
|
||||
};
|
||||
|
||||
@@ -107,7 +107,7 @@ export const AirGapGuard: CanActivateFn = (route) => {
|
||||
const modifiedRoute = {
|
||||
...route,
|
||||
data: { ...route.data, requiredScopes: ['airgap:seal'] as PolicyScope[] },
|
||||
} as ActivatedRouteSnapshot;
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
return PolicyGuard(modifiedRoute, {} as never);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ import { AgentActionModalComponent } from './components/agent-action-modal/agent
|
||||
|
||||
type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
|
||||
|
||||
interface ActionFeedback {
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-detail-page',
|
||||
standalone: true,
|
||||
@@ -49,7 +54,8 @@ type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
|
||||
<p class="error-state__message">{{ store.error() }}</p>
|
||||
<a routerLink="/ops/agents" class="btn btn--secondary">Back to Fleet</a>
|
||||
</div>
|
||||
} @else if (agent(); as agentData) {
|
||||
} @else {
|
||||
@if (agent(); as agentData) {
|
||||
<!-- Header -->
|
||||
<header class="detail-header">
|
||||
<div class="detail-header__info">
|
||||
@@ -342,6 +348,7 @@ type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
@@ -764,10 +771,6 @@ type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
|
||||
}
|
||||
`],
|
||||
})
|
||||
interface ActionFeedback {
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class AgentDetailPageComponent implements OnInit {
|
||||
readonly store = inject(AgentStore);
|
||||
|
||||
@@ -24,7 +24,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
<header class="tab-header">
|
||||
<h2 class="tab-header__title">Tasks</h2>
|
||||
<div class="tab-header__filters">
|
||||
@for (filterOption of filterOptions; track filterOption.value) {
|
||||
@for (filterOption of filterOptions(); track filterOption.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface Agent {
|
||||
readonly lastHeartbeat: string;
|
||||
readonly registeredAt: string;
|
||||
readonly resources: AgentResources;
|
||||
readonly metrics?: AgentResources;
|
||||
readonly certificate?: AgentCertificate;
|
||||
readonly config?: AgentConfig;
|
||||
readonly activeTasks: number;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Analytics Routes
|
||||
* Sprint: SPRINT_20260120_031_FE_sbom_analytics_console
|
||||
*/
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const ANALYTICS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'sbom-lake',
|
||||
pathMatch: 'full',
|
||||
data: { breadcrumb: 'Analytics' },
|
||||
},
|
||||
{
|
||||
path: 'sbom-lake',
|
||||
loadComponent: () =>
|
||||
import('./sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
|
||||
title: 'SBOM Lake',
|
||||
data: { breadcrumb: 'SBOM Lake' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AnalyticsHttpClient } from '../../core/api/analytics.client';
|
||||
import {
|
||||
AnalyticsAttestationCoverage,
|
||||
AnalyticsComponentTrendPoint,
|
||||
AnalyticsFixableBacklogItem,
|
||||
AnalyticsLicenseDistribution,
|
||||
AnalyticsSupplierConcentration,
|
||||
AnalyticsVulnerabilityExposure,
|
||||
AnalyticsVulnerabilityTrendPoint,
|
||||
PlatformListResponse,
|
||||
} from '../../core/api/analytics.models';
|
||||
import { SbomLakePageComponent } from './sbom-lake-page.component';
|
||||
|
||||
const createResponse = <T,>(items: T[]): PlatformListResponse<T> => ({
|
||||
tenantId: 'tenant-01',
|
||||
actorId: 'actor-01',
|
||||
dataAsOf: '2026-01-20T00:00:00Z',
|
||||
cached: false,
|
||||
cacheTtlSeconds: 300,
|
||||
items,
|
||||
count: items.length,
|
||||
});
|
||||
|
||||
describe('SbomLakePageComponent', () => {
|
||||
let fixture: ComponentFixture<SbomLakePageComponent>;
|
||||
let component: SbomLakePageComponent;
|
||||
let analytics: jasmine.SpyObj<AnalyticsHttpClient>;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
let route: ActivatedRoute;
|
||||
|
||||
beforeEach(async () => {
|
||||
analytics = jasmine.createSpyObj<AnalyticsHttpClient>('AnalyticsHttpClient', [
|
||||
'getSuppliers',
|
||||
'getLicenses',
|
||||
'getVulnerabilities',
|
||||
'getFixableBacklog',
|
||||
'getAttestationCoverage',
|
||||
'getVulnerabilityTrends',
|
||||
'getComponentTrends',
|
||||
]);
|
||||
|
||||
analytics.getSuppliers.and.returnValue(of(createResponse<AnalyticsSupplierConcentration>([])));
|
||||
analytics.getLicenses.and.returnValue(of(createResponse<AnalyticsLicenseDistribution>([])));
|
||||
analytics.getVulnerabilities.and.returnValue(of(createResponse<AnalyticsVulnerabilityExposure>([])));
|
||||
analytics.getFixableBacklog.and.returnValue(of(createResponse<AnalyticsFixableBacklogItem>([])));
|
||||
analytics.getAttestationCoverage.and.returnValue(of(createResponse<AnalyticsAttestationCoverage>([])));
|
||||
analytics.getVulnerabilityTrends.and.returnValue(of(createResponse<AnalyticsVulnerabilityTrendPoint>([])));
|
||||
analytics.getComponentTrends.and.returnValue(of(createResponse<AnalyticsComponentTrendPoint>([])));
|
||||
|
||||
router = jasmine.createSpyObj<Router>('Router', ['navigate']);
|
||||
router.navigate.and.returnValue(Promise.resolve(true));
|
||||
|
||||
route = { queryParams: of({ env: 'Prod', severity: 'high', days: '90' }) } as ActivatedRoute;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SbomLakePageComponent],
|
||||
providers: [
|
||||
{ provide: AnalyticsHttpClient, useValue: analytics },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SbomLakePageComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('loads filters from query params and requests analytics data', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.environment()).toBe('Prod');
|
||||
expect(component.minSeverity()).toBe('high');
|
||||
expect(component.days()).toBe(90);
|
||||
|
||||
expect(analytics.getVulnerabilities).toHaveBeenCalledWith('Prod', 'high');
|
||||
expect(analytics.getComponentTrends).toHaveBeenCalledWith('Prod', 90);
|
||||
});
|
||||
|
||||
it('updates query params when environment changes', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onEnvironmentChange({ target: { value: 'QA' } } as unknown as Event);
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: route,
|
||||
queryParams: { env: 'QA', severity: 'high', days: '90' },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the SBOM Lake title', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.textContent).toContain('SBOM Lake');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.byte-diff-viewer {
|
||||
background: var(--color-surface-primary);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.delta-list {
|
||||
background: var(--color-surface-primary);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.proof-panel {
|
||||
background: var(--color-surface-primary);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.summary-header {
|
||||
background: var(--color-surface-primary);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.actionables-panel {
|
||||
padding: var(--space-4);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.compare-view {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Trust Indicators Component Styles
|
||||
|
||||
@@ -621,6 +621,11 @@ export class RolesListComponent implements OnInit {
|
||||
{ module: 'SBOM', tier: 'operator', role: 'role/sbom-operator', scopes: ['sbom:read', 'sbom:export', 'aoc:verify'], description: 'Export SBOMs' },
|
||||
{ module: 'SBOM', tier: 'admin', role: 'role/sbom-admin', scopes: ['sbom:read', 'sbom:export', 'sbom:write', 'aoc:verify'], description: 'Full SBOM administration' },
|
||||
|
||||
// Analytics
|
||||
{ module: 'Analytics', tier: 'viewer', role: 'role/analytics-viewer', scopes: ['analytics.read', 'ui.read'], description: 'View analytics dashboards' },
|
||||
{ module: 'Analytics', tier: 'operator', role: 'role/analytics-operator', scopes: ['analytics.read', 'ui.read'], description: 'Operate analytics reporting' },
|
||||
{ module: 'Analytics', tier: 'admin', role: 'role/analytics-admin', scopes: ['analytics.read', 'ui.read'], description: 'Administer analytics access' },
|
||||
|
||||
// Excititor (VEX)
|
||||
{ module: 'Excititor', tier: 'viewer', role: 'role/vex-viewer', scopes: ['vex:read', 'aoc:verify'], description: 'View VEX documents' },
|
||||
{ module: 'Excititor', tier: 'operator', role: 'role/vex-operator', scopes: ['vex:read', 'vex:export', 'aoc:verify'], description: 'Export VEX documents' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Check Result Component Styles
|
||||
|
||||
@@ -51,7 +51,7 @@ export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API');
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HttpDoctorClient implements DoctorApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/api/v1/doctor`;
|
||||
private readonly baseUrl = `${environment.apiBaseUrl}/api/v1/doctor`;
|
||||
|
||||
listChecks(category?: string, plugin?: string): Observable<CheckListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
@@ -14,7 +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 { LineageNode } from '../../models/lineage.models';
|
||||
import { LineageNode, LineageViewOptions } from '../../models/lineage.models';
|
||||
|
||||
/**
|
||||
* Container component that orchestrates the lineage graph visualization.
|
||||
@@ -368,8 +368,8 @@ export class LineageGraphContainerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onOptionsChange(options: Partial<typeof LineageGraphService.prototype.viewOptions>): void {
|
||||
this.lineageService.updateViewOptions(options as any);
|
||||
onOptionsChange(options: Partial<LineageViewOptions>): void {
|
||||
this.lineageService.updateViewOptions(options);
|
||||
}
|
||||
|
||||
onZoomIn(): void {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
LineageNode,
|
||||
LineageEdge,
|
||||
SelectionState,
|
||||
LineageSelection,
|
||||
ViewOptions,
|
||||
LayoutNode,
|
||||
} from '../../models/lineage.models';
|
||||
@@ -343,7 +343,7 @@ interface DragState {
|
||||
export class LineageGraphComponent implements AfterViewInit, OnChanges {
|
||||
@Input() nodes: LayoutNode[] = [];
|
||||
@Input() edges: LineageEdge[] = [];
|
||||
@Input() selection: SelectionState = { mode: 'single', nodeA: null, nodeB: null };
|
||||
@Input() selection: LineageSelection = { mode: 'single' };
|
||||
@Input() viewOptions: ViewOptions = {
|
||||
showLanes: true,
|
||||
showDigests: true,
|
||||
|
||||
@@ -82,23 +82,22 @@ import { LineageNode, LineageDiffResponse } from '../../models/lineage.models';
|
||||
<div class="diff-section">
|
||||
<div class="section-title">Changes from Parent</div>
|
||||
|
||||
@if (diff.components) {
|
||||
@if (diff.componentDiff) {
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">Components:</span>
|
||||
<span class="diff-value">
|
||||
<span class="added">+{{ diff.components.added.length }}</span>
|
||||
<span class="removed">-{{ diff.components.removed.length }}</span>
|
||||
<span class="modified">~{{ diff.components.modified.length }}</span>
|
||||
<span class="added">+{{ diff.componentDiff.added.length }}</span>
|
||||
<span class="removed">-{{ diff.componentDiff.removed.length }}</span>
|
||||
<span class="modified">~{{ diff.componentDiff.changed.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (diff.vex) {
|
||||
@if (diff.vexDeltas) {
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">VEX:</span>
|
||||
<span class="diff-value">
|
||||
<span class="added">+{{ diff.vex.added.length }}</span>
|
||||
<span class="removed">-{{ diff.vex.removed.length }}</span>
|
||||
<span class="added">{{ diff.vexDeltas.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -53,10 +53,10 @@ interface ViewportRect {
|
||||
@for (edge of edges; track trackEdge(edge)) {
|
||||
<line
|
||||
class="minimap-edge"
|
||||
[attr.x1]="getNodeX(edge.sourceDigest)"
|
||||
[attr.y1]="getNodeY(edge.sourceDigest)"
|
||||
[attr.x2]="getNodeX(edge.targetDigest)"
|
||||
[attr.y2]="getNodeY(edge.targetDigest)"
|
||||
[attr.x1]="getNodeX(resolveEdgeSource(edge))"
|
||||
[attr.y1]="getNodeY(resolveEdgeSource(edge))"
|
||||
[attr.x2]="getNodeX(resolveEdgeTarget(edge))"
|
||||
[attr.y2]="getNodeY(resolveEdgeTarget(edge))"
|
||||
/>
|
||||
}
|
||||
</g>
|
||||
@@ -164,17 +164,27 @@ export class LineageMinimapComponent implements AfterViewInit, OnChanges {
|
||||
this.viewBox.set(`-50 -50 ${maxX + 100} ${maxY + 100}`);
|
||||
}
|
||||
|
||||
getNodeX(digest: string): number {
|
||||
getNodeX(digest: string | undefined | null): number {
|
||||
if (!digest) return 0;
|
||||
const node = this.nodes.find(n => n.artifactDigest === digest);
|
||||
if (!node) return 0;
|
||||
return (node.lane ?? 0) * this.laneWidth + this.laneWidth / 2;
|
||||
}
|
||||
|
||||
getNodeY(digest: string): number {
|
||||
getNodeY(digest: string | undefined | null): number {
|
||||
if (!digest) return 0;
|
||||
const node = this.nodes.find(n => n.artifactDigest === digest);
|
||||
return node?.y ?? 0;
|
||||
}
|
||||
|
||||
resolveEdgeSource(edge: LineageEdge): string {
|
||||
return edge.sourceDigest ?? edge.fromDigest;
|
||||
}
|
||||
|
||||
resolveEdgeTarget(edge: LineageEdge): string {
|
||||
return edge.targetDigest ?? edge.toDigest;
|
||||
}
|
||||
|
||||
nodeColor(node: LayoutNode): string {
|
||||
if (node.isRoot) return '#4a90d9';
|
||||
|
||||
@@ -189,7 +199,7 @@ export class LineageMinimapComponent implements AfterViewInit, OnChanges {
|
||||
}
|
||||
|
||||
trackEdge(edge: LineageEdge): string {
|
||||
return `${edge.sourceDigest}-${edge.targetDigest}`;
|
||||
return `${edge.sourceDigest ?? edge.fromDigest}-${edge.targetDigest ?? edge.toDigest}`;
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LineageSelection,
|
||||
HoverCardState,
|
||||
LineageViewOptions,
|
||||
LayoutNode,
|
||||
} from '../models/lineage.models';
|
||||
|
||||
/**
|
||||
@@ -96,7 +97,7 @@ export class LineageGraphService {
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
/** Computed: nodes with layout positions */
|
||||
readonly layoutNodes = computed(() => {
|
||||
readonly layoutNodes = computed((): LayoutNode[] => {
|
||||
const graph = this.currentGraph();
|
||||
if (!graph) return [];
|
||||
return this.computeLayout(graph.nodes, graph.edges);
|
||||
@@ -353,7 +354,7 @@ export class LineageGraphService {
|
||||
/**
|
||||
* Compute layout positions for nodes using lane-based algorithm.
|
||||
*/
|
||||
private computeLayout(nodes: LineageNode[], edges: { fromDigest: string; toDigest: string }[]): LineageNode[] {
|
||||
private computeLayout(nodes: LineageNode[], edges: { fromDigest: string; toDigest: string }[]): LayoutNode[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
const options = this.viewOptions();
|
||||
@@ -400,7 +401,7 @@ export class LineageGraphService {
|
||||
});
|
||||
|
||||
// Apply positions to nodes
|
||||
return nodes.map(node => {
|
||||
return nodes.map((node) => {
|
||||
const column = columnAssignments.get(node.artifactDigest) ?? 0;
|
||||
const lane = laneAssignments.get(node.artifactDigest) ?? 0;
|
||||
|
||||
@@ -424,7 +425,7 @@ export class LineageGraphService {
|
||||
lane,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
} as LayoutNode;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@ang
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { OfflineModeService } from '../../../core/services/offline-mode.service';
|
||||
import { BundleFreshnessWidgetComponent } from '../../../shared/components/bundle-freshness-widget.component';
|
||||
import { OfflineAssetCategories } from '../../../core/api/offline-kit.models';
|
||||
|
||||
interface DashboardStats {
|
||||
bundlesLoaded: number;
|
||||
@@ -403,14 +404,8 @@ export class OfflineDashboardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private countAssets(assets: Record<string, unknown>): number {
|
||||
let count = 0;
|
||||
for (const category of Object.values(assets)) {
|
||||
if (typeof category === 'object' && category !== null) {
|
||||
count += Object.keys(category).length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
private countAssets(assets: OfflineAssetCategories): number {
|
||||
return Object.values(assets).reduce((total, category) => total + Object.keys(category).length, 0);
|
||||
}
|
||||
|
||||
private loadFeatures(): void {
|
||||
|
||||
@@ -290,13 +290,13 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
|
||||
<a
|
||||
[routerLink]="['services', service.name]"
|
||||
class="block p-3 rounded-lg border hover:shadow-md transition-shadow"
|
||||
[class]="SERVICE_STATE_BG_LIGHT[service.state]"
|
||||
[class]="getServiceStateBg(service.state)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-gray-900">{{ service.displayName }}</span>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
[class]="SERVICE_STATE_COLORS[service.state]"
|
||||
[class]="getServiceStateColor(service.state)"
|
||||
></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
@@ -435,4 +435,12 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
|
||||
if (rate >= 1) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
}
|
||||
|
||||
getServiceStateBg(state: ServiceHealthState): string {
|
||||
return SERVICE_STATE_BG_LIGHT[state];
|
||||
}
|
||||
|
||||
getServiceStateColor(state: ServiceHealthState): string {
|
||||
return SERVICE_STATE_COLORS[state];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,7 +905,7 @@ export class SealedModeControlComponent implements OnInit {
|
||||
protected revokeOverride(override: SealedModeOverride): void {
|
||||
if (!confirm('Revoke this override?')) return;
|
||||
|
||||
this.api.revokeSealedModeOverride(override.id, { tenantId: 'acme-tenant' }).subscribe({
|
||||
this.api.revokeSealedModeOverride(override.id, 'user_revoked', { tenantId: 'acme-tenant' }).subscribe({
|
||||
next: () => this.loadStatus(),
|
||||
error: (err) => console.error('Failed to revoke override:', err),
|
||||
});
|
||||
|
||||
@@ -843,7 +843,7 @@ export class TrustWeightingComponent implements OnInit {
|
||||
this.impact.set(null);
|
||||
|
||||
this.api
|
||||
.previewTrustWeightImpact(weight, { tenantId: 'acme-tenant' })
|
||||
.previewTrustWeightImpact([weight], { tenantId: 'acme-tenant' })
|
||||
.pipe(finalize(() => this.impactLoading.set(false)))
|
||||
.subscribe({
|
||||
next: (result) => this.impact.set(result),
|
||||
|
||||
@@ -970,16 +970,16 @@ export class PolicyStudioComponent implements OnInit {
|
||||
|
||||
// RBAC computed properties
|
||||
readonly canRead = computed(() =>
|
||||
hasScope(this.authStore.session()?.accessToken, 'policy:read')
|
||||
hasScope(this.authStore.session()?.tokens.accessToken, 'policy:read')
|
||||
);
|
||||
readonly canEdit = computed(() =>
|
||||
hasScope(this.authStore.session()?.accessToken, 'policy:edit')
|
||||
hasScope(this.authStore.session()?.tokens.accessToken, 'policy:edit')
|
||||
);
|
||||
readonly canActivate = computed(() =>
|
||||
hasScope(this.authStore.session()?.accessToken, 'policy:activate')
|
||||
hasScope(this.authStore.session()?.tokens.accessToken, 'policy:activate')
|
||||
);
|
||||
readonly canSeal = computed(() =>
|
||||
hasScope(this.authStore.session()?.accessToken, 'airgap:seal')
|
||||
hasScope(this.authStore.session()?.tokens.accessToken, 'airgap:seal')
|
||||
);
|
||||
|
||||
private get tenantId(): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* PathViewerComponent Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* RiskDriftCardComponent Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Active Deployments Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Pending Approvals Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Pipeline Overview Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Recent Releases Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.release-dashboard {
|
||||
max-width: 1400px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.first-signal-card {
|
||||
display: block;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Sources List Component Styles
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Artifact Detail Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-artifact-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Artifact Detail</h1>
|
||||
<p class="page-subtitle">Artifact metadata and related evidence.</p>
|
||||
</div>
|
||||
<a routerLink="/security/artifacts" class="btn btn--secondary">Back to Artifacts</a>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Artifact detail content will be available in a future update.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ArtifactDetailPageComponent {}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Exception Detail Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Exception Detail</h1>
|
||||
<p class="page-subtitle">Policy exception details and evidence.</p>
|
||||
</div>
|
||||
<a routerLink="/policy/exceptions" class="btn btn--secondary">Back to Exceptions</a>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Exception detail data will appear here once loaded.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ExceptionDetailPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Lineage Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lineage-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Lineage</h1>
|
||||
<p class="page-subtitle">Track provenance and artifact ancestry.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Lineage views will appear here when data sources are configured.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class LineagePageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Patch Map Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-patch-map-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Patch Map</h1>
|
||||
<p class="page-subtitle">Track patch coverage across environments.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Patch map dashboards will be available in a future release.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class PatchMapPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Reachability Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reachability-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Reachability</h1>
|
||||
<p class="page-subtitle">Analyze runtime reachability across findings.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Reachability analytics are queued for implementation.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ReachabilityPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Risk Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-risk-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Risk Dashboard</h1>
|
||||
<p class="page-subtitle">Monitor risk trends and remediation status.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Risk scoring visuals are not yet wired.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class RiskPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SBOM Graph Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sbom-graph-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">SBOM Graph</h1>
|
||||
<p class="page-subtitle">Visualize dependency relationships and component impact.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>SBOM graph visualization is not yet available in this build.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class SbomGraphPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Scan Detail Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scan-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Scan Detail</h1>
|
||||
<p class="page-subtitle">Inspect scan metadata and outputs.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Scan detail data will appear after scans are ingested.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ScanDetailPageComponent {}
|
||||
@@ -18,13 +18,13 @@ export const SECURITY_ROUTES: Routes = [
|
||||
{
|
||||
path: 'overview',
|
||||
loadComponent: () =>
|
||||
import('./overview/security-overview-page.component').then(m => m.SecurityOverviewPageComponent),
|
||||
import('./security-overview-page.component').then(m => m.SecurityOverviewPageComponent),
|
||||
data: { breadcrumb: 'Overview' },
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
loadComponent: () =>
|
||||
import('./findings/security-findings-page.component').then(m => m.SecurityFindingsPageComponent),
|
||||
import('./security-findings-page.component').then(m => m.SecurityFindingsPageComponent),
|
||||
data: { breadcrumb: 'Findings' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Unknowns Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unknowns-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Unknowns</h1>
|
||||
<p class="page-subtitle">Review findings pending classification.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>No unknowns data is available yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class UnknownsPageComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Vulnerabilities Page Component
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-003)
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vulnerabilities-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Vulnerabilities</h1>
|
||||
<p class="page-subtitle">Track vulnerability status and remediation.</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<p>Vulnerability list is pending data integration.</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class VulnerabilitiesPageComponent {}
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Merge Preview Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Snapshot Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Verify Determinism Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.triage-inbox {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Triage Artifacts Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Triage Attestation Detail Modal Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Triage Audit Bundle New (Wizard) Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Triage Audit Bundles Component Styles
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
// =============================================================================
|
||||
// Triage Workspace Component - Migrated to Design System Tokens
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* VEX Decision Modal Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Graph Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Policy Breadcrumb Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Verdict Actions Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Verdict Detail Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.vex-conflict-studio {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.vuln-detail {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Vulnerability Explorer Component Styles
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AUTH_SERVICE, MockAuthService } from '../../core/auth';
|
||||
|
||||
import { AppShellComponent } from './app-shell.component';
|
||||
|
||||
@@ -11,7 +12,10 @@ describe('AppShellComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppShellComponent],
|
||||
providers: [provideRouter([])],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_SERVICE, useClass: MockAuthService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppShellComponent);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AppSidebarComponent } from './app-sidebar.component';
|
||||
import { AUTH_SERVICE, MockAuthService, StellaOpsScopes } from '../../core/auth';
|
||||
|
||||
describe('AppSidebarComponent', () => {
|
||||
let authService: MockAuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
authService = new MockAuthService();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppSidebarComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_SERVICE, useValue: authService },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('hides analytics navigation when analytics scope is missing', () => {
|
||||
setScopes([StellaOpsScopes.UI_READ]);
|
||||
const fixture = createComponent();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Analytics');
|
||||
});
|
||||
|
||||
it('shows analytics navigation when analytics scope is present', () => {
|
||||
setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ]);
|
||||
const fixture = createComponent();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Analytics');
|
||||
});
|
||||
|
||||
function setScopes(scopes: readonly string[]): void {
|
||||
const baseUser = authService.user();
|
||||
if (!baseUser) {
|
||||
throw new Error('Mock auth user is not initialized.');
|
||||
}
|
||||
|
||||
authService.user.set({
|
||||
...baseUser,
|
||||
scopes,
|
||||
});
|
||||
}
|
||||
|
||||
function createComponent(): ComponentFixture<AppSidebarComponent> {
|
||||
const fixture = TestBed.createComponent(AppSidebarComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
|
||||
import type { StellaOpsScope } from '../../core/auth';
|
||||
|
||||
import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
|
||||
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
@@ -25,6 +27,8 @@ export interface NavSection {
|
||||
route: string;
|
||||
badge$?: () => number | null;
|
||||
children?: NavItem[];
|
||||
requiredScopes?: readonly StellaOpsScope[];
|
||||
requireAnyScope?: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +105,7 @@ export interface NavSection {
|
||||
|
||||
<!-- Navigation items -->
|
||||
<nav class="sidebar__nav">
|
||||
@for (section of navSections; track section.id) {
|
||||
@for (section of visibleSections(); track section.id) {
|
||||
@if (section.children && section.children.length > 0) {
|
||||
<app-sidebar-nav-group
|
||||
[label]="section.label"
|
||||
@@ -250,6 +254,7 @@ export interface NavSection {
|
||||
})
|
||||
export class AppSidebarComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
@Input() collapsed = false;
|
||||
@Output() toggleCollapse = new EventEmitter<void>();
|
||||
@@ -293,6 +298,16 @@ export class AppSidebarComponent {
|
||||
{ id: 'security-exceptions', label: 'Exceptions', route: '/security/exceptions', icon: 'x-circle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
icon: 'bar-chart',
|
||||
route: '/analytics',
|
||||
requiredScopes: [StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ],
|
||||
children: [
|
||||
{ id: 'analytics-sbom-lake', label: 'SBOM Lake', route: '/analytics/sbom-lake', icon: 'chart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence',
|
||||
label: 'Evidence',
|
||||
@@ -334,6 +349,60 @@ export class AppSidebarComponent {
|
||||
},
|
||||
];
|
||||
|
||||
/** Navigation sections filtered by user scopes */
|
||||
readonly visibleSections = computed(() => {
|
||||
return this.navSections
|
||||
.map((section) => this.filterSection(section))
|
||||
.filter((section): section is NavSection => section !== null);
|
||||
});
|
||||
|
||||
private filterSection(section: NavSection): NavSection | null {
|
||||
if (section.requiredScopes && !this.hasAllScopes(section.requiredScopes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (section.requireAnyScope && !this.hasAnyScope(section.requireAnyScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!section.children || section.children.length === 0) {
|
||||
return section;
|
||||
}
|
||||
|
||||
const visibleChildren = section.children
|
||||
.map((child) => this.filterItem(child))
|
||||
.filter((child): child is NavItem => child !== null);
|
||||
|
||||
if (visibleChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...section,
|
||||
children: visibleChildren,
|
||||
};
|
||||
}
|
||||
|
||||
private filterItem(item: NavItem): NavItem | null {
|
||||
if (item.requiredScopes && !this.hasAllScopes(item.requiredScopes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.requireAnyScope && !this.hasAnyScope(item.requireAnyScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return this.authService.hasAllScopes(scopes);
|
||||
}
|
||||
|
||||
private hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return this.authService.hasAnyScope(scopes);
|
||||
}
|
||||
|
||||
onGroupToggle(groupId: string, expanded: boolean): void {
|
||||
this.expandedGroups.update((groups) => {
|
||||
const newGroups = new Set(groups);
|
||||
|
||||
@@ -49,6 +49,11 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<svg *ngSwitchCase="'bar-chart'" viewBox="0 0 24 24" width="20" height="20">
|
||||
<line x1="12" y1="20" x2="12" y2="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="18" y1="20" x2="18" y2="4" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="6" y1="20" x2="6" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<svg *ngSwitchDefault viewBox="0 0 24 24" width="20" height="20">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import type { StellaOpsScope } from '../../core/auth';
|
||||
|
||||
/**
|
||||
* Navigation item structure.
|
||||
@@ -12,6 +13,8 @@ export interface NavItem {
|
||||
icon: string;
|
||||
badge?: number;
|
||||
tooltip?: string;
|
||||
requiredScopes?: readonly StellaOpsScope[];
|
||||
requireAnyScope?: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Command Palette Styles
|
||||
// Modern Cmd+K style quick navigation
|
||||
|
||||
@use '../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
// =============================================================================
|
||||
// Overlay
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.entropy-panel {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Desktop: horizontal nav with hover dropdowns
|
||||
// Mobile: hamburger button with slide-out drawer
|
||||
|
||||
@use '../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
@use '../../../../styles/tokens/motion' as motion;
|
||||
|
||||
// =============================================================================
|
||||
|
||||
322
src/Web/StellaOps.Web/tests/e2e/analytics-sbom-lake.spec.ts
Normal file
322
src/Web/StellaOps.Web/tests/e2e/analytics-sbom-lake.spec.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// analytics-sbom-lake.spec.ts
|
||||
// Sprint: SPRINT_20260120_031_FE_sbom_analytics_console
|
||||
// Task: TASK-031-005 - E2E route load + filter behavior
|
||||
// -----------------------------------------------------------------------------
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
const analyticsSession = {
|
||||
...policyAuthorSession,
|
||||
scopes: [...policyAuthorSession.scopes, 'analytics.read'],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
const createResponse = <T,>(items: T[]) => ({
|
||||
tenantId: 'tenant-analytics',
|
||||
actorId: 'actor-analytics',
|
||||
dataAsOf: '2026-01-20T00:00:00Z',
|
||||
cached: false,
|
||||
cacheTtlSeconds: 300,
|
||||
items,
|
||||
count: items.length,
|
||||
});
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
supplier: 'Acme Corp',
|
||||
componentCount: 120,
|
||||
artifactCount: 8,
|
||||
teamCount: 2,
|
||||
criticalVulnCount: 4,
|
||||
highVulnCount: 10,
|
||||
environments: ['Prod'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockLicenses = [
|
||||
{
|
||||
licenseConcluded: 'Apache-2.0',
|
||||
licenseCategory: 'permissive',
|
||||
componentCount: 240,
|
||||
artifactCount: 12,
|
||||
ecosystems: ['npm'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockVulnerabilities = [
|
||||
{
|
||||
vulnId: 'CVE-2026-1234',
|
||||
severity: 'high',
|
||||
cvssScore: 8.8,
|
||||
epssScore: 0.12,
|
||||
kevListed: true,
|
||||
fixAvailable: true,
|
||||
rawComponentCount: 3,
|
||||
rawArtifactCount: 2,
|
||||
effectiveComponentCount: 2,
|
||||
effectiveArtifactCount: 1,
|
||||
vexMitigated: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const mockBacklog = [
|
||||
{
|
||||
service: 'api-gateway',
|
||||
environment: 'Prod',
|
||||
component: 'openssl',
|
||||
version: '1.1.1k',
|
||||
vulnId: 'CVE-2026-1234',
|
||||
severity: 'high',
|
||||
fixedVersion: '1.1.1l',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAttestation = [
|
||||
{
|
||||
environment: 'Prod',
|
||||
team: 'platform',
|
||||
totalArtifacts: 12,
|
||||
withProvenance: 10,
|
||||
provenancePct: 83.3,
|
||||
slsaLevel2Plus: 8,
|
||||
slsa2Pct: 66.7,
|
||||
missingProvenance: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const mockVulnTrends = [
|
||||
{
|
||||
snapshotDate: '2026-01-01',
|
||||
environment: 'Prod',
|
||||
totalVulns: 10,
|
||||
fixableVulns: 5,
|
||||
vexMitigated: 2,
|
||||
netExposure: 8,
|
||||
kevVulns: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const mockComponentTrends = [
|
||||
{
|
||||
snapshotDate: '2026-01-01',
|
||||
environment: 'Prod',
|
||||
totalComponents: 200,
|
||||
uniqueSuppliers: 12,
|
||||
},
|
||||
];
|
||||
|
||||
const setupAnalyticsMocks = async (page: Page) => {
|
||||
await page.route('**/api/analytics/suppliers**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockSuppliers)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/licenses**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockLicenses)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/vulnerabilities**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockVulnerabilities)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/backlog**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockBacklog)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/attestation-coverage**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockAttestation)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/trends/vulnerabilities**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockVulnTrends)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/trends/components**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockComponentTrends)),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const setupSession = async (page: Page, session: typeof policyAuthorSession) => {
|
||||
await page.addInitScript((sessionValue) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// Ignore storage errors in restricted contexts.
|
||||
}
|
||||
(window as any).__stellaopsTestSession = sessionValue;
|
||||
}, session);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
};
|
||||
|
||||
test.describe('SBOM Lake Analytics Console', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSession(page, analyticsSession);
|
||||
await setupAnalyticsMocks(page);
|
||||
});
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// Ignore storage errors in restricted contexts.
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, analyticsSession);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
|
||||
await page.route('**/api/analytics/suppliers**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockSuppliers)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/licenses**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockLicenses)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/vulnerabilities**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockVulnerabilities)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/backlog**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockBacklog)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/attestation-coverage**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockAttestation)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/trends/vulnerabilities**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockVulnTrends)),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/analytics/trends/components**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createResponse(mockComponentTrends)),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('loads analytics panels and updates filters', async ({ page }) => {
|
||||
await page.goto('/analytics/sbom-lake?env=Prod&severity=high&days=90');
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'SBOM Lake' })
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await expect(page.locator('#envFilter')).toHaveValue('Prod');
|
||||
await expect(page.locator('#severityFilter')).toHaveValue('high');
|
||||
await expect(page.locator('#daysFilter')).toHaveValue('90');
|
||||
|
||||
await expect(page.getByText('Acme Corp')).toBeVisible();
|
||||
await expect(page.locator('.table-grid .data-table').first()).toContainText(
|
||||
'api-gateway'
|
||||
);
|
||||
|
||||
await page.locator('#envFilter').selectOption('QA');
|
||||
await expect(page).toHaveURL(/env=QA/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('SBOM Lake Analytics Guard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSession(page, policyAuthorSession);
|
||||
await setupAnalyticsMocks(page);
|
||||
});
|
||||
|
||||
test('redirects when analytics scope is missing', async ({ page }) => {
|
||||
await page.goto('/analytics/sbom-lake');
|
||||
await expect(page).toHaveURL(/\/console\/profile/);
|
||||
});
|
||||
});
|
||||
@@ -6,16 +6,16 @@
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
@@ -28,6 +28,6 @@
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
"strictTemplates": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user