consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -51,7 +51,7 @@
{
"type": "initial",
"maximumWarning": "750kb",
"maximumError": "1.5mb"
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@@ -93,22 +93,22 @@
"buildTarget": "stellaops-web:build"
}
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "stellaops-web:build:development",
"runner": "vitest",
"setupFiles": ["src/test-setup.ts"],
"exclude": [
"**/*.e2e.spec.ts",
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts"
]
}
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "stellaops-web:build:development",
"runner": "vitest",
"setupFiles": ["src/test-setup.ts"],
"exclude": [
"**/*.e2e.spec.ts",
"src/app/core/api/vex-hub.client.spec.ts",
"src/app/core/services/*.spec.ts",
"src/app/features/**/*.spec.ts",
"src/app/shared/components/**/*.spec.ts"
]
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {

View File

@@ -40,7 +40,7 @@ const EN_US_BUNDLE: Record<string, string> = {
'ui.severity.low': 'Low',
'ui.severity.info': 'Info',
'ui.severity.none': 'None',
'ui.release_orchestrator.title': 'Release Orchestrator',
'ui.release_orchestrator.title': 'Release JobEngine',
'ui.release_orchestrator.subtitle': 'Pipeline overview and release management',
'ui.release_orchestrator.pipeline_runs': 'Pipeline Runs',
'ui.risk_dashboard.title': 'Risk Profiles',
@@ -82,7 +82,7 @@ const DE_DE_BUNDLE: Record<string, string> = {
'ui.actions.cancel': 'Abbrechen',
'ui.actions.delete': 'L\u00f6schen',
'ui.actions.search': 'Suche',
'ui.release_orchestrator.title': 'Release-Orchestrator',
'ui.release_orchestrator.title': 'Release-JobEngine',
'ui.risk_dashboard.title': 'Risikoprofile',
'ui.findings.title': 'Ergebnisse',
'ui.timeline.title': 'Zeitleiste',
@@ -243,7 +243,7 @@ test.describe('i18n Translated Content on Routes', () => {
}[] = [
{ path: '/findings', name: 'Findings', expectedText: 'Findings' },
{ path: '/', name: 'Control Plane' },
{ path: '/operations/orchestrator', name: 'Release Orchestrator' },
{ path: '/operations/jobengine', name: 'Release JobEngine' },
{ path: '/security', name: 'Risk Dashboard' },
{ path: '/timeline', name: 'Timeline' },
{ path: '/policy/exceptions', name: 'Exception Center' },
@@ -324,7 +324,7 @@ test.describe('i18n Locale Switching', () => {
});
// This route maintains background activity; avoid networkidle waits for this case.
await page.goto('/operations/orchestrator', {
await page.goto('/operations/jobengine', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});

View File

@@ -257,9 +257,9 @@ test.describe('Section 5: Ops - Operations', () => {
expect(body.length).toBeGreaterThan(10);
});
test('orchestrator dashboard', async ({ authenticatedPage: page }) => {
await go(page, '/ops/operations/orchestrator');
await snap(page, '05-ops-orchestrator');
test('jobengine dashboard', async ({ authenticatedPage: page }) => {
await go(page, '/ops/operations/jobengine');
await snap(page, '05-ops-jobengine');
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(10);
});

View File

@@ -40,7 +40,7 @@ const CRITICAL_ROUTES: { path: string; name: string; expectRedirect?: boolean }[
{ path: '/policy/governance', name: 'Policy Governance' },
{ path: '/policy/exceptions', name: 'Policy Exceptions' },
{ path: '/operations', name: 'Operations' },
{ path: '/operations/orchestrator', name: 'Operations Orchestrator' },
{ path: '/operations/jobengine', name: 'Operations JobEngine' },
{ path: '/operations/scheduler', name: 'Operations Scheduler' },
{ path: '/evidence', name: 'Evidence' },
{ path: '/evidence-packs', name: 'Evidence Packs' },

View File

@@ -28,11 +28,11 @@ const EXTENDED_ROUTES: { path: string; name: string }[] = [
{ path: '/console/admin', name: 'Console Admin' },
{ path: '/console/configuration', name: 'Configuration' },
// Orchestrator (legacy paths)
{ path: '/orchestrator', name: 'Orchestrator (legacy)' },
{ path: '/orchestrator/jobs', name: 'Orchestrator Jobs' },
{ path: '/orchestrator/quotas', name: 'Orchestrator Quotas' },
{ path: '/release-orchestrator', name: 'Release Orchestrator' },
// JobEngine (legacy paths)
{ path: '/jobengine', name: 'JobEngine (legacy)' },
{ path: '/jobengine/jobs', name: 'JobEngine Jobs' },
{ path: '/jobengine/quotas', name: 'JobEngine Quotas' },
{ path: '/release-jobengine', name: 'Release JobEngine' },
// Policy Studio
{ path: '/policy-studio/packs', name: 'Policy Studio Packs' },
@@ -102,8 +102,8 @@ test.describe('Extended Route Rendering (Batch 2)', () => {
test.describe('Extended Route — Deep Paths', () => {
const DEEP_PATHS: { path: string; name: string }[] = [
{ path: '/ops/quotas', name: 'Quota Dashboard' },
{ path: '/ops/orchestrator/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/orchestrator/slo', name: 'SLO Burn Rate' },
{ path: '/ops/jobengine/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/jobengine/slo', name: 'SLO Burn Rate' },
{ path: '/ops/health', name: 'Platform Health' },
{ path: '/ops/doctor', name: 'Doctor Diagnostics' },
{ path: '/ops/agents', name: 'Agent Fleet' },

View File

@@ -49,7 +49,7 @@ const RAW_ROUTES: RouteTarget[] = [
{ path: '/policy/governance', name: 'Policy Governance' },
{ path: '/policy/exceptions', name: 'Policy Exceptions' },
{ path: '/operations', name: 'Operations' },
{ path: '/operations/orchestrator', name: 'Operations Orchestrator' },
{ path: '/operations/jobengine', name: 'Operations JobEngine' },
{ path: '/operations/scheduler', name: 'Operations Scheduler' },
{ path: '/evidence', name: 'Evidence' },
{ path: '/evidence-packs', name: 'Evidence Packs' },
@@ -66,10 +66,10 @@ const RAW_ROUTES: RouteTarget[] = [
{ path: '/console/status', name: 'Console Status' },
{ path: '/console/admin', name: 'Console Admin' },
{ path: '/console/configuration', name: 'Configuration' },
{ path: '/orchestrator', name: 'Orchestrator Legacy' },
{ path: '/orchestrator/jobs', name: 'Orchestrator Jobs' },
{ path: '/orchestrator/quotas', name: 'Orchestrator Quotas' },
{ path: '/release-orchestrator', name: 'Release Orchestrator' },
{ path: '/jobengine', name: 'JobEngine Legacy' },
{ path: '/jobengine/jobs', name: 'JobEngine Jobs' },
{ path: '/jobengine/quotas', name: 'JobEngine Quotas' },
{ path: '/release-jobengine', name: 'Release JobEngine' },
{ path: '/policy-studio/packs', name: 'Policy Studio Packs' },
{ path: '/concelier/trivy-db-settings', name: 'Trivy DB Settings' },
{ path: '/risk', name: 'Risk Dashboard' },
@@ -101,8 +101,8 @@ const RAW_ROUTES: RouteTarget[] = [
{ path: '/admin/audit', name: 'Audit Log' },
{ path: '/welcome', name: 'Welcome Page' },
{ path: '/ops/quotas', name: 'Quota Dashboard' },
{ path: '/ops/orchestrator/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/orchestrator/slo', name: 'SLO Burn Rate' },
{ path: '/ops/jobengine/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/jobengine/slo', name: 'SLO Burn Rate' },
{ path: '/ops/health', name: 'Platform Health' },
{ path: '/ops/doctor', name: 'Doctor Diagnostics' },
{ path: '/ops/agents', name: 'Agent Fleet' },
@@ -141,10 +141,10 @@ const KNOWN_NO_CONTROL_ROUTES = new Set<string>([
'/console/profile',
'/triage/inbox',
'/operations',
'/operations/orchestrator',
'/orchestrator',
'/orchestrator/jobs',
'/orchestrator/quotas',
'/operations/jobengine',
'/jobengine',
'/jobengine/jobs',
'/jobengine/quotas',
'/admin/audit',
'/workspace/dev',
'/workspace/audit',

View File

@@ -83,7 +83,7 @@
"target": "http://127.1.0.1:80",
"secure": false
},
"/orchestrator": {
"/jobengine": {
"target": "http://127.1.0.1:80",
"secure": false
},

View File

@@ -92,15 +92,15 @@ import {
} from './core/api/policy-evidence.client';
import {
ORCHESTRATOR_API,
ORCHESTRATOR_API_BASE_URL,
JOBENGINE_API_BASE_URL,
OrchestratorHttpClient,
MockOrchestratorClient,
} from './core/api/orchestrator.client';
MockJobEngineClient,
} from './core/api/jobengine.client';
import {
ORCHESTRATOR_CONTROL_API,
OrchestratorControlHttpClient,
MockOrchestratorControlClient,
} from './core/api/orchestrator-control.client';
JobEngineControlHttpClient,
MockJobEngineControlClient,
} from './core/api/jobengine-control.client';
import {
FIRST_SIGNAL_API,
FirstSignalHttpClient,
@@ -535,7 +535,7 @@ export const appConfig: ApplicationConfig = {
useExisting: PolicyEvidenceCompositeClient,
},
{
provide: ORCHESTRATOR_API_BASE_URL,
provide: JOBENGINE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
@@ -543,16 +543,16 @@ export const appConfig: ApplicationConfig = {
},
},
OrchestratorHttpClient,
MockOrchestratorClient,
MockJobEngineClient,
{
provide: ORCHESTRATOR_API,
useExisting: OrchestratorHttpClient,
},
OrchestratorControlHttpClient,
MockOrchestratorControlClient,
JobEngineControlHttpClient,
MockJobEngineControlClient,
{
provide: ORCHESTRATOR_CONTROL_API,
useExisting: OrchestratorControlHttpClient,
useExisting: JobEngineControlHttpClient,
},
FirstSignalHttpClient,
MockFirstSignalClient,
@@ -667,7 +667,7 @@ export const appConfig: ApplicationConfig = {
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/release-orchestrator', gatewayBase).toString();
return new URL('/api/v1/release-jobengine', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/release-orchestrator`;
@@ -686,7 +686,7 @@ export const appConfig: ApplicationConfig = {
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/release-orchestrator', gatewayBase).toString();
return new URL('/api/v1/release-jobengine', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/release-orchestrator`;
@@ -747,10 +747,10 @@ export const appConfig: ApplicationConfig = {
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/notifier', gatewayBase).toString();
return new URL('/api/v1/notify', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/notifier`;
return `${normalized}/api/v1/notify`;
}
},
},
@@ -944,7 +944,7 @@ export const appConfig: ApplicationConfig = {
provide: POLICY_GATES_API,
useExisting: PolicyGatesHttpClient,
},
// Release API (Release Orchestrator backend)
// Release API (Release JobEngine backend)
ReleaseHttpClient,
{
provide: RELEASE_API,

View File

@@ -102,7 +102,7 @@ export const routes: Routes = [
title: 'Security',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSecurityGuard],
data: { breadcrumb: 'Security' },
loadChildren: () => import('./routes/security.routes').then((m) => m.SECURITY_ROUTES),
loadChildren: () => import('./routes/security-risk.routes').then((m) => m.SECURITY_RISK_ROUTES),
},
{
path: 'evidence',
@@ -147,6 +147,72 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/auth/silent-refresh.component').then((m) => m.SilentRefreshComponent),
},
{
path: 'administration',
title: 'Administration',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSetupGuard],
data: { breadcrumb: 'Administration' },
loadChildren: () => import('./routes/administration.routes').then((m) => m.ADMINISTRATION_ROUTES),
},
{
path: 'console-admin',
title: 'Console Admin',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Console Admin' },
loadChildren: () => import('./features/console-admin/console-admin.routes').then((m) => m.consoleAdminRoutes),
},
{
path: 'platform',
children: [
{ path: 'ops', redirectTo: '/ops', pathMatch: 'full' },
{ path: 'ops/:rest', redirectTo: '/ops/:rest' },
{ path: 'setup', redirectTo: '/setup', pathMatch: 'full' },
{ path: 'setup/:rest', redirectTo: '/setup/:rest' },
{ path: '**', redirectTo: '/ops' },
],
},
{
path: 'release-control',
children: [
{ path: '', redirectTo: '/releases/deployments', pathMatch: 'full' },
{ path: 'releases', redirectTo: '/releases/deployments', pathMatch: 'full' },
{ path: 'approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'runs', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' },
{ path: 'bundles/create', redirectTo: '/releases/bundles/create', pathMatch: 'full' },
{ path: 'promotions', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'promotions/create', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' },
{ path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' },
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
{ path: 'governance', redirectTo: '/ops/policy', pathMatch: 'full' },
{ path: '**', redirectTo: '/releases/deployments' },
],
},
{
path: 'evidence-audit',
children: [
{ path: '', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'evidence', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'evidence/export', redirectTo: '/evidence/exports', pathMatch: 'full' },
{ path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' },
{ path: 'replay', redirectTo: '/evidence/verify-replay', pathMatch: 'full' },
{ path: 'proofs', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'trust-signing', redirectTo: '/administration/trust-signing', pathMatch: 'full' },
{ path: '**', redirectTo: '/evidence/overview' },
],
},
{
path: 'security-risk',
children: [
{ path: '', redirectTo: '/security', pathMatch: 'full' },
{ path: ':rest', redirectTo: '/security/:rest' },
],
},
{
path: 'setup-wizard',
loadChildren: () => import('./features/setup-wizard/setup-wizard.routes').then((m) => m.setupWizardRoutes),

View File

@@ -1,5 +1,5 @@
/**
* Approval Models for Release Orchestrator
* Approval Models for Release JobEngine
* Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui
*/

View File

@@ -24,7 +24,7 @@ export class AuditLogClient {
private readonly endpoints: Record<AuditModule, string> = {
authority: '/console/admin/audit',
policy: '/api/v1/policy/audit/events',
orchestrator: '/api/v1/orchestrator/audit/events',
jobengine: '/api/v1/jobengine/audit/events',
integrations: '/api/v1/integrations/audit/events',
vex: '/api/v1/vex/audit/events',
scanner: '/api/v1/scanner/audit/events',
@@ -362,13 +362,13 @@ export class AuditLogClient {
}
/**
* Get Orchestrator-specific audit events (jobs, dead-letter).
* Get JobEngine-specific audit events (jobs, dead-letter).
*/
getOrchestratorAudit(
filters?: AuditLogFilters,
cursor?: string,
limit: number = 50
): Observable<AuditEventsPagedResponse> {
return this.getModuleEvents('orchestrator', filters, cursor, limit);
return this.getModuleEvents('jobengine', filters, cursor, limit);
}
}

View File

@@ -4,7 +4,7 @@
export type AuditModule =
| 'authority'
| 'policy'
| 'orchestrator'
| 'jobengine'
| 'integrations'
| 'vex'
| 'scanner'

View File

@@ -127,7 +127,7 @@ interface ApiReplayAuditListResponse {
@Injectable({ providedIn: 'root' })
export class DeadLetterClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/orchestrator/deadletter';
private readonly baseUrl = '/api/v1/jobengine/deadletter';
private readonly batchProgressById = new Map<string, BatchReplayProgress>();
list(

View File

@@ -203,7 +203,7 @@ export const ERROR_CODE_REFERENCES: Record<ErrorCode, ErrorCodeReference> = {
],
relatedDocs: [
{ title: 'Scanner Timeout Configuration', url: '/docs/scanner/timeout' },
{ title: 'Orchestrator Retry Policy', url: '/docs/orchestrator/retry' },
{ title: 'Orchestrator Retry Policy', url: '/docs/jobengine/retry' },
],
},
DLQ_RESOURCE: {
@@ -276,7 +276,7 @@ export const ERROR_CODE_REFERENCES: Record<ErrorCode, ErrorCodeReference> = {
'Mark as resolved if no longer needed',
],
relatedDocs: [
{ title: 'Job Payload Schema', url: '/docs/orchestrator/payloads' },
{ title: 'Job Payload Schema', url: '/docs/jobengine/payloads' },
],
},
DLQ_POLICY: {
@@ -330,7 +330,7 @@ export const ERROR_CODE_REFERENCES: Record<ErrorCode, ErrorCodeReference> = {
'Resubmit with updated reference if needed',
],
relatedDocs: [
{ title: 'Idempotency', url: '/docs/orchestrator/idempotency' },
{ title: 'Idempotency', url: '/docs/jobengine/idempotency' },
],
},
DLQ_UNKNOWN: {

View File

@@ -215,9 +215,9 @@ export class MockDeploymentClient implements DeploymentApi {
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]> {
const baseLogs: LogEntry[] = [
{ timestamp: new Date(Date.now() - 280000).toISOString(), level: 'info', source: 'orchestrator', targetId: null, message: 'Deployment started' },
{ timestamp: new Date(Date.now() - 279000).toISOString(), level: 'info', source: 'orchestrator', targetId: null, message: 'Validating deployment configuration...' },
{ timestamp: new Date(Date.now() - 278000).toISOString(), level: 'debug', source: 'orchestrator', targetId: null, message: 'Configuration validated successfully' },
{ timestamp: new Date(Date.now() - 280000).toISOString(), level: 'info', source: 'jobengine', targetId: null, message: 'Deployment started' },
{ timestamp: new Date(Date.now() - 279000).toISOString(), level: 'info', source: 'jobengine', targetId: null, message: 'Validating deployment configuration...' },
{ timestamp: new Date(Date.now() - 278000).toISOString(), level: 'debug', source: 'jobengine', targetId: null, message: 'Configuration validated successfully' },
{ timestamp: new Date(Date.now() - 275000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Starting deployment to api-server-1' },
{ timestamp: new Date(Date.now() - 274000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Pulling image: registry.example.com/backend-api:2.5.0' },
{ timestamp: new Date(Date.now() - 260000).toISOString(), level: 'info', source: 'agent-01', targetId: 'tgt-1', message: 'Image pulled successfully' },

View File

@@ -6,7 +6,7 @@ import { catchError, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
import { generateTraceId } from './trace.util';
@@ -27,7 +27,7 @@ export class FirstSignalHttpClient implements FirstSignalApi {
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string,
@Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
@@ -42,12 +42,12 @@ export class FirstSignalHttpClient implements FirstSignalApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http
.get<FirstSignalResponse>(`${this.baseUrl}/orchestrator/runs/${encodeURIComponent(runId)}/first-signal`, {
.get<FirstSignalResponse>(`${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
observe: 'response',
})
@@ -75,7 +75,7 @@ export class FirstSignalHttpClient implements FirstSignalApi {
const traceId = options.traceId ?? generateTraceId();
const params = new HttpParams().set('tenant', tenant).set('traceId', traceId);
const url = `${this.baseUrl}/orchestrator/stream/runs/${encodeURIComponent(runId)}?${params.toString()}`;
const url = `${this.baseUrl}/jobengine/stream/runs/${encodeURIComponent(runId)}?${params.toString()}`;
return new Observable<FirstSignalRunStreamPayload>((observer) => {
const source = this.eventSourceFactory(url);

View File

@@ -1,6 +1,6 @@
/**
* Orchestrator First Signal API response types.
* Mirrors `StellaOps.Orchestrator.WebService.Contracts.FirstSignalResponse`.
* JobEngine First Signal API response types.
* Mirrors `StellaOps.JobEngine.WebService.Contracts.FirstSignalResponse`.
*/
export interface FirstSignalResponse {

View File

@@ -3,8 +3,8 @@ import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { MockOrchestratorControlClient, OrchestratorControlHttpClient } from './orchestrator-control.client';
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
import { MockJobEngineControlClient, JobEngineControlHttpClient } from './jobengine-control.client';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
class FakeAuthSessionStore {
@@ -15,8 +15,8 @@ class FakeAuthSessionStore {
}
}
describe('OrchestratorControlHttpClient', () => {
let client: OrchestratorControlHttpClient;
describe('JobEngineControlHttpClient', () => {
let client: JobEngineControlHttpClient;
let authSession: FakeAuthSessionStore;
let httpMock: HttpTestingController;
let tenantService: { authorize: jasmine.Spy };
@@ -27,8 +27,8 @@ describe('OrchestratorControlHttpClient', () => {
TestBed.configureTestingModule({
imports: [],
providers: [
OrchestratorControlHttpClient,
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
JobEngineControlHttpClient,
{ provide: JOBENGINE_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
provideHttpClient(withInterceptorsFromDi()),
@@ -36,7 +36,7 @@ describe('OrchestratorControlHttpClient', () => {
]
});
client = TestBed.inject(OrchestratorControlHttpClient);
client = TestBed.inject(JobEngineControlHttpClient);
authSession = TestBed.inject(AuthSessionStore) as unknown as FakeAuthSessionStore;
httpMock = TestBed.inject(HttpTestingController);
});
@@ -58,7 +58,7 @@ describe('OrchestratorControlHttpClient', () => {
.subscribe();
const req = httpMock.expectOne(
(r) => r.url === '/api/orchestrator/quotas' && r.params.get('jobType') === 'pack-run'
(r) => r.url === '/api/jobengine/quotas' && r.params.get('jobType') === 'pack-run'
);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('paused')).toBe('false');
@@ -80,7 +80,7 @@ describe('OrchestratorControlHttpClient', () => {
)
.subscribe();
const req = httpMock.expectOne('/api/orchestrator/quotas');
const req = httpMock.expectOne('/api/jobengine/quotas');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Require-Operator')).toBe('1');
@@ -107,7 +107,7 @@ describe('OrchestratorControlHttpClient', () => {
it('marks backfill operations with operator metadata sentinel header', () => {
client.replayDeadLetterEntry('entry-1', { tenantId: 'tenant-x', traceId: 'trace-3' }).subscribe();
const req = httpMock.expectOne('/api/orchestrator/deadletter/entry-1/replay');
const req = httpMock.expectOne('/api/jobengine/deadletter/entry-1/replay');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
expect(req.request.headers.get('X-Stella-Require-Operator')).toBe('1');
@@ -121,7 +121,7 @@ describe('OrchestratorControlHttpClient', () => {
next: () => reject(new Error('expected error')),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/orchestrator/quotas');
httpMock.expectNone('/api/jobengine/quotas');
resolve();
},
});
@@ -132,7 +132,7 @@ describe('OrchestratorControlHttpClient', () => {
next: () => reject(new Error('expected error')),
error: (err: unknown) => {
expect(String(err)).toContain('Invalid limit');
httpMock.expectNone('/api/orchestrator/quotas');
httpMock.expectNone('/api/jobengine/quotas');
resolve();
},
});
@@ -143,16 +143,16 @@ describe('OrchestratorControlHttpClient', () => {
client.listQuotas({ traceId: 'trace-6' }).subscribe();
const req = httpMock.expectOne('/api/orchestrator/quotas');
const req = httpMock.expectOne('/api/jobengine/quotas');
expect(req.request.headers.has('X-StellaOps-Tenant')).toBeFalse();
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-6');
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-3"', traceId: 'trace-6' });
});
});
describe('MockOrchestratorControlClient', () => {
describe('MockJobEngineControlClient', () => {
it('pauses quotas deterministically and persists the update', () => new Promise<void>((resolve, reject) => {
const mock = new MockOrchestratorControlClient();
const mock = new MockJobEngineControlClient();
mock
.pauseQuota('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { reason: 'Pause requested', ticket: 'OPS-9' }, { traceId: 'trace-6' })

View File

@@ -4,61 +4,61 @@ import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
import {
CreateOrchestratorQuotaRequest,
OrchestratorBatchReplayResultResponse,
CreateJobEngineQuotaRequest,
JobEngineBatchReplayResultResponse,
OrchestratorCancelPackRunRequest,
OrchestratorCancelPackRunResponse,
OrchestratorControlRequestOptions,
OrchestratorDeadLetterStatsResponse,
OrchestratorDeadLetterSummaryListResponse,
OrchestratorJobSummary,
OrchestratorQuota,
OrchestratorQuotaListResponse,
OrchestratorQuotaQueryOptions,
OrchestratorQuotaSummary,
JobEngineDeadLetterStatsResponse,
JobEngineDeadLetterSummaryListResponse,
JobEngineJobSummary,
JobEngineQuota,
JobEngineQuotaListResponse,
JobEngineQuotaQueryOptions,
JobEngineQuotaSummary,
OrchestratorReplayBatchRequest,
OrchestratorReplayPendingRequest,
OrchestratorReplayResultResponse,
OrchestratorRetryPackRunRequest,
OrchestratorRetryPackRunResponse,
PauseOrchestratorQuotaRequest,
UpdateOrchestratorQuotaRequest,
} from './orchestrator-control.models';
PauseJobEngineQuotaRequest,
UpdateJobEngineQuotaRequest,
} from './jobengine-control.models';
import { generateTraceId } from './trace.util';
export interface OrchestratorControlApi {
listQuotas(options?: OrchestratorQuotaQueryOptions): Observable<OrchestratorQuotaListResponse>;
getQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
createQuota(request: CreateOrchestratorQuotaRequest, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
listQuotas(options?: JobEngineQuotaQueryOptions): Observable<JobEngineQuotaListResponse>;
getQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<JobEngineQuota>;
createQuota(request: CreateJobEngineQuotaRequest, options?: OrchestratorControlRequestOptions): Observable<JobEngineQuota>;
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
request: UpdateJobEngineQuotaRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorQuota>;
): Observable<JobEngineQuota>;
deleteQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<void>;
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
request: PauseJobEngineQuotaRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorQuota>;
resumeQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuota>;
getQuotaSummary(options?: OrchestratorControlRequestOptions): Observable<OrchestratorQuotaSummary>;
): Observable<JobEngineQuota>;
resumeQuota(quotaId: string, options?: OrchestratorControlRequestOptions): Observable<JobEngineQuota>;
getQuotaSummary(options?: OrchestratorControlRequestOptions): Observable<JobEngineQuotaSummary>;
getJobSummary(options?: Pick<OrchestratorControlRequestOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<OrchestratorJobSummary>;
getJobSummary(options?: Pick<OrchestratorControlRequestOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'>): Observable<JobEngineJobSummary>;
getDeadLetterStats(options?: OrchestratorControlRequestOptions): Observable<OrchestratorDeadLetterStatsResponse>;
getDeadLetterSummary(options?: OrchestratorControlRequestOptions): Observable<OrchestratorDeadLetterSummaryListResponse>;
getDeadLetterStats(options?: OrchestratorControlRequestOptions): Observable<JobEngineDeadLetterStatsResponse>;
getDeadLetterSummary(options?: OrchestratorControlRequestOptions): Observable<JobEngineDeadLetterSummaryListResponse>;
replayDeadLetterEntry(entryId: string, options?: OrchestratorControlRequestOptions): Observable<OrchestratorReplayResultResponse>;
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorBatchReplayResultResponse>;
): Observable<JobEngineBatchReplayResultResponse>;
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options?: OrchestratorControlRequestOptions
): Observable<OrchestratorBatchReplayResultResponse>;
): Observable<JobEngineBatchReplayResultResponse>;
cancelPackRun(
packRunId: string,
@@ -77,26 +77,26 @@ export const ORCHESTRATOR_CONTROL_API = new InjectionToken<OrchestratorControlAp
const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
@Injectable({ providedIn: 'root' })
export class OrchestratorControlHttpClient implements OrchestratorControlApi {
export class JobEngineControlHttpClient implements OrchestratorControlApi {
private static readonly MAX_PAGE_SIZE = 200;
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string
@Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string
) {}
listQuotas(options: OrchestratorQuotaQueryOptions = {}): Observable<OrchestratorQuotaListResponse> {
listQuotas(options: JobEngineQuotaQueryOptions = {}): Observable<JobEngineQuotaListResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
if (options.limit && options.limit > OrchestratorControlHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${OrchestratorControlHttpClient.MAX_PAGE_SIZE}`));
if (options.limit && options.limit > JobEngineControlHttpClient.MAX_PAGE_SIZE) {
return throwError(() => new Error(`Invalid limit: max ${JobEngineControlHttpClient.MAX_PAGE_SIZE}`));
}
let params = new HttpParams();
@@ -105,51 +105,51 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http.get<OrchestratorQuotaListResponse>(`${this.baseUrl}/orchestrator/quotas`, {
return this.http.get<JobEngineQuotaListResponse>(`${this.baseUrl}/jobengine/quotas`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
getQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.read', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.get<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, {
return this.http.get<JobEngineQuota>(`${this.baseUrl}/jobengine/quotas/${encodeURIComponent(quotaId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
createQuota(request: CreateOrchestratorQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
createQuota(request: CreateJobEngineQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.create', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.create', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas`, request, {
return this.http.post<JobEngineQuota>(`${this.baseUrl}/jobengine/quotas`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
request: UpdateJobEngineQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
): Observable<JobEngineQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.update', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.update', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.put<OrchestratorQuota>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, request, {
return this.http.put<JobEngineQuota>(`${this.baseUrl}/jobengine/quotas/${encodeURIComponent(quotaId)}`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
@@ -158,29 +158,29 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.delete', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.delete', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.delete<void>(`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}`, {
return this.http.delete<void>(`${this.baseUrl}/jobengine/quotas/${encodeURIComponent(quotaId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
request: PauseJobEngineQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
): Observable<JobEngineQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.pause', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.pause', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(
`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}/pause`,
return this.http.post<JobEngineQuota>(
`${this.baseUrl}/jobengine/quotas/${encodeURIComponent(quotaId)}/pause`,
request,
{
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
@@ -188,16 +188,16 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
);
}
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.resume', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.resume', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.post<OrchestratorQuota>(
`${this.baseUrl}/orchestrator/quotas/${encodeURIComponent(quotaId)}/resume`,
return this.http.post<JobEngineQuota>(
`${this.baseUrl}/jobengine/quotas/${encodeURIComponent(quotaId)}/resume`,
{},
{
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
@@ -205,56 +205,56 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
);
}
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuotaSummary> {
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuotaSummary> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'quota.summary', ['orch:quota'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'quota.summary', ['orch:quota'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:quota scope'));
}
return this.http.get<OrchestratorQuotaSummary>(`${this.baseUrl}/orchestrator/quotas/summary`, {
return this.http.get<JobEngineQuotaSummary>(`${this.baseUrl}/jobengine/quotas/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getJobSummary(
options: Pick<OrchestratorControlRequestOptions, 'tenantId' | 'projectId' | 'traceId' | 'ifNoneMatch'> = {}
): Observable<OrchestratorJobSummary> {
): Observable<JobEngineJobSummary> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http.get<OrchestratorJobSummary>(`${this.baseUrl}/orchestrator/jobs/summary`, {
return this.http.get<JobEngineJobSummary>(`${this.baseUrl}/jobengine/jobs/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterStatsResponse> {
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineDeadLetterStatsResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.get<OrchestratorDeadLetterStatsResponse>(`${this.baseUrl}/orchestrator/deadletter/stats`, {
return this.http.get<JobEngineDeadLetterStatsResponse>(`${this.baseUrl}/jobengine/deadletter/stats`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterSummaryListResponse> {
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineDeadLetterSummaryListResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.get<OrchestratorDeadLetterSummaryListResponse>(`${this.baseUrl}/orchestrator/deadletter/summary`, {
return this.http.get<JobEngineDeadLetterSummaryListResponse>(`${this.baseUrl}/jobengine/deadletter/summary`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
@@ -263,12 +263,12 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorReplayResultResponse>(
`${this.baseUrl}/orchestrator/deadletter/${encodeURIComponent(entryId)}/replay`,
`${this.baseUrl}/jobengine/deadletter/${encodeURIComponent(entryId)}/replay`,
{},
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
@@ -277,11 +277,11 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
): Observable<JobEngineBatchReplayResultResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
@@ -289,7 +289,7 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
return throwError(() => new Error('Replay batch requires at least one entryId.'));
}
return this.http.post<OrchestratorBatchReplayResultResponse>(`${this.baseUrl}/orchestrator/deadletter/replay/batch`, request, {
return this.http.post<JobEngineBatchReplayResultResponse>(`${this.baseUrl}/jobengine/deadletter/replay/batch`, request, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true),
});
}
@@ -297,16 +297,16 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
): Observable<JobEngineBatchReplayResultResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorBatchReplayResultResponse>(
`${this.baseUrl}/orchestrator/deadletter/replay/pending`,
return this.http.post<JobEngineBatchReplayResultResponse>(
`${this.baseUrl}/jobengine/deadletter/replay/pending`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
@@ -320,12 +320,12 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'operate', ['orch:operate'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'operate', ['orch:operate'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:operate scope'));
}
return this.http.post<OrchestratorCancelPackRunResponse>(
`${this.baseUrl}/orchestrator/pack-runs/${encodeURIComponent(packRunId)}/cancel`,
`${this.baseUrl}/jobengine/pack-runs/${encodeURIComponent(packRunId)}/cancel`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
@@ -339,12 +339,12 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'backfill', ['orch:backfill'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:backfill scope'));
}
return this.http.post<OrchestratorRetryPackRunResponse>(
`${this.baseUrl}/orchestrator/pack-runs/${encodeURIComponent(packRunId)}/retry`,
`${this.baseUrl}/jobengine/pack-runs/${encodeURIComponent(packRunId)}/retry`,
request,
{ headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch, true) }
);
@@ -389,8 +389,8 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi {
}
@Injectable({ providedIn: 'root' })
export class MockOrchestratorControlClient implements OrchestratorControlApi {
private quotas: OrchestratorQuota[] = [
export class MockJobEngineControlClient implements OrchestratorControlApi {
private quotas: JobEngineQuota[] = [
{
quotaId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
tenantId: 'tenant-default',
@@ -429,7 +429,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
},
];
listQuotas(options: OrchestratorQuotaQueryOptions = {}): Observable<OrchestratorQuotaListResponse> {
listQuotas(options: JobEngineQuotaQueryOptions = {}): Observable<JobEngineQuotaListResponse> {
let items = this.quotas.map((q) => ({ ...q }));
if (options.jobType) {
@@ -459,7 +459,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
});
}
getQuota(quotaId: string, _options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
getQuota(quotaId: string, _options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const found = this.quotas.find((q) => q.quotaId === quotaId);
if (!found) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
@@ -468,9 +468,9 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
return of({ ...found });
}
createQuota(request: CreateOrchestratorQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
createQuota(request: CreateJobEngineQuotaRequest, options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const traceId = options.traceId ?? generateTraceId();
const quota: OrchestratorQuota = {
const quota: JobEngineQuota = {
quotaId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
tenantId: options.tenantId ?? 'tenant-default',
jobType: request.jobType ?? 'custom',
@@ -496,9 +496,9 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
updateQuota(
quotaId: string,
request: UpdateOrchestratorQuotaRequest,
request: UpdateJobEngineQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
): Observable<JobEngineQuota> {
void options;
const index = this.quotas.findIndex((q) => q.quotaId === quotaId);
if (index < 0) {
@@ -506,7 +506,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
}
const existing = this.quotas[index];
const updated: OrchestratorQuota = {
const updated: JobEngineQuota = {
...existing,
maxActive: request.maxActive ?? existing.maxActive,
maxPerHour: request.maxPerHour ?? existing.maxPerHour,
@@ -528,15 +528,15 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
pauseQuota(
quotaId: string,
request: PauseOrchestratorQuotaRequest,
request: PauseJobEngineQuotaRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorQuota> {
): Observable<JobEngineQuota> {
const existing = this.quotas.find((q) => q.quotaId === quotaId);
if (!existing) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
const updated: OrchestratorQuota = {
const updated: JobEngineQuota = {
...existing,
paused: true,
pauseReason: request.reason,
@@ -550,13 +550,13 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
return of({ ...updated });
}
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuota> {
resumeQuota(quotaId: string, options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuota> {
const existing = this.quotas.find((q) => q.quotaId === quotaId);
if (!existing) {
return throwError(() => new Error(`Quota not found: ${quotaId}`));
}
const updated: OrchestratorQuota = {
const updated: JobEngineQuota = {
...existing,
paused: false,
pauseReason: null,
@@ -570,7 +570,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
return of({ ...updated });
}
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorQuotaSummary> {
getQuotaSummary(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineQuotaSummary> {
const quotas = this.quotas.map((q) => ({ ...q })).sort((a, b) => (a.jobType ?? '').localeCompare(b.jobType ?? ''));
const pausedQuotas = quotas.filter((q) => q.paused).length;
@@ -598,7 +598,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
});
}
getJobSummary(_options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorJobSummary> {
getJobSummary(_options: OrchestratorControlRequestOptions = {}): Observable<JobEngineJobSummary> {
void _options;
return of({
totalJobs: 12,
@@ -613,7 +613,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
});
}
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterStatsResponse> {
getDeadLetterStats(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineDeadLetterStatsResponse> {
return of({
totalEntries: 4,
pendingEntries: 2,
@@ -630,7 +630,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
});
}
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<OrchestratorDeadLetterSummaryListResponse> {
getDeadLetterSummary(options: OrchestratorControlRequestOptions = {}): Observable<JobEngineDeadLetterSummaryListResponse> {
return of({
items: [
{
@@ -685,7 +685,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
replayDeadLetterBatch(
request: OrchestratorReplayBatchRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
): Observable<JobEngineBatchReplayResultResponse> {
void request;
return of({
attempted: 2,
@@ -702,7 +702,7 @@ export class MockOrchestratorControlClient implements OrchestratorControlApi {
replayDeadLetterPending(
request: OrchestratorReplayPendingRequest,
options: OrchestratorControlRequestOptions = {}
): Observable<OrchestratorBatchReplayResultResponse> {
): Observable<JobEngineBatchReplayResultResponse> {
void request;
return this.replayDeadLetterBatch({ entryIds: ['a', 'b'] }, options);
}

View File

@@ -1,4 +1,4 @@
export interface OrchestratorQuota {
export interface JobEngineQuota {
readonly quotaId: string;
readonly tenantId: string;
readonly jobType?: string | null;
@@ -17,7 +17,7 @@ export interface OrchestratorQuota {
readonly updatedBy: string;
}
export interface OrchestratorQuotaUtilization {
export interface JobEngineQuotaUtilization {
readonly quotaId: string;
readonly jobType?: string | null;
readonly tokenUtilization: number;
@@ -26,16 +26,16 @@ export interface OrchestratorQuotaUtilization {
readonly paused: boolean;
}
export interface OrchestratorQuotaSummary {
export interface JobEngineQuotaSummary {
readonly totalQuotas: number;
readonly pausedQuotas: number;
readonly averageTokenUtilization: number;
readonly averageConcurrencyUtilization: number;
readonly quotas: readonly OrchestratorQuotaUtilization[];
readonly quotas: readonly JobEngineQuotaUtilization[];
readonly traceId?: string;
}
export interface CreateOrchestratorQuotaRequest {
export interface CreateJobEngineQuotaRequest {
readonly jobType?: string | null;
readonly maxActive: number;
readonly maxPerHour: number;
@@ -43,19 +43,19 @@ export interface CreateOrchestratorQuotaRequest {
readonly refillRate: number;
}
export interface UpdateOrchestratorQuotaRequest {
export interface UpdateJobEngineQuotaRequest {
readonly maxActive?: number;
readonly maxPerHour?: number;
readonly burstCapacity?: number;
readonly refillRate?: number;
}
export interface PauseOrchestratorQuotaRequest {
export interface PauseJobEngineQuotaRequest {
readonly reason: string;
readonly ticket?: string | null;
}
export interface OrchestratorQuotaQueryOptions {
export interface JobEngineQuotaQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
@@ -66,15 +66,15 @@ export interface OrchestratorQuotaQueryOptions {
readonly continuationToken?: string;
}
export interface OrchestratorQuotaListResponse {
readonly items: readonly OrchestratorQuota[];
export interface JobEngineQuotaListResponse {
readonly items: readonly JobEngineQuota[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}
export interface OrchestratorJobSummary {
export interface JobEngineJobSummary {
readonly totalJobs: number;
readonly pendingJobs: number;
readonly scheduledJobs: number;
@@ -86,7 +86,7 @@ export interface OrchestratorJobSummary {
readonly traceId?: string;
}
export interface OrchestratorDeadLetterSummary {
export interface JobEngineDeadLetterSummary {
readonly errorCode: string;
readonly category: string;
readonly entryCount: number;
@@ -95,12 +95,12 @@ export interface OrchestratorDeadLetterSummary {
readonly sampleReason?: string | null;
}
export interface OrchestratorDeadLetterSummaryListResponse {
readonly items: readonly OrchestratorDeadLetterSummary[];
export interface JobEngineDeadLetterSummaryListResponse {
readonly items: readonly JobEngineDeadLetterSummary[];
readonly traceId?: string;
}
export interface OrchestratorDeadLetterStatsResponse {
export interface JobEngineDeadLetterStatsResponse {
readonly totalEntries: number;
readonly pendingEntries: number;
readonly replayingEntries: number;
@@ -115,7 +115,7 @@ export interface OrchestratorDeadLetterStatsResponse {
readonly traceId?: string;
}
export interface OrchestratorDeadLetterEntry {
export interface JobEngineDeadLetterEntry {
readonly entryId: string;
readonly originalJobId: string;
readonly runId?: string | null;
@@ -138,14 +138,14 @@ export interface OrchestratorReplayResult {
readonly success: boolean;
readonly newJobId?: string | null;
readonly errorMessage?: string | null;
readonly updatedEntry?: OrchestratorDeadLetterEntry | null;
readonly updatedEntry?: JobEngineDeadLetterEntry | null;
}
export interface OrchestratorReplayResultResponse extends OrchestratorReplayResult {
readonly traceId?: string;
}
export interface OrchestratorBatchReplayResultResponse {
export interface JobEngineBatchReplayResultResponse {
readonly attempted: number;
readonly succeeded: number;
readonly failed: number;

View File

@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { OrchestratorHttpClient, ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { OrchestratorHttpClient, JOBENGINE_API_BASE_URL } from './jobengine.client';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
class FakeAuthSessionStore {
@@ -24,7 +24,7 @@ describe('OrchestratorHttpClient', () => {
imports: [],
providers: [
OrchestratorHttpClient,
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
{ provide: JOBENGINE_API_BASE_URL, useValue: '/api' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
provideHttpClient(withInterceptorsFromDi()),
@@ -51,7 +51,7 @@ describe('OrchestratorHttpClient', () => {
})
.subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/orchestrator/sources' && r.params.get('sourceType') === 'concelier');
const req = httpMock.expectOne((r) => r.url === '/api/jobengine/sources' && r.params.get('sourceType') === 'concelier');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('enabled')).toBe('true');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-x');
@@ -69,7 +69,7 @@ describe('OrchestratorHttpClient', () => {
next: () => reject(new Error('expected error')),
error: (err: unknown) => {
expect(String(err)).toContain('Unauthorized');
httpMock.expectNone('/api/orchestrator/sources/11111111-1111-1111-1111-111111111111');
httpMock.expectNone('/api/jobengine/sources/11111111-1111-1111-1111-111111111111');
resolve();
},
});

View File

@@ -4,7 +4,7 @@ import { Observable, of, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { OrchestratorQueryOptions, OrchestratorSource, OrchestratorSourcesResponse } from './orchestrator.models';
import { OrchestratorQueryOptions, OrchestratorSource, OrchestratorSourcesResponse } from './jobengine.models';
import { generateTraceId } from './trace.util';
export interface OrchestratorApi {
@@ -13,7 +13,7 @@ export interface OrchestratorApi {
}
export const ORCHESTRATOR_API = new InjectionToken<OrchestratorApi>('ORCHESTRATOR_API');
export const ORCHESTRATOR_API_BASE_URL = new InjectionToken<string>('ORCHESTRATOR_API_BASE_URL');
export const JOBENGINE_API_BASE_URL = new InjectionToken<string>('JOBENGINE_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class OrchestratorHttpClient implements OrchestratorApi {
@@ -23,14 +23,14 @@ export class OrchestratorHttpClient implements OrchestratorApi {
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string
@Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string
) {}
listSources(options: OrchestratorQueryOptions = {}): Observable<OrchestratorSourcesResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
@@ -44,7 +44,7 @@ export class OrchestratorHttpClient implements OrchestratorApi {
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return this.http.get<OrchestratorSourcesResponse>(`${this.baseUrl}/orchestrator/sources`, {
return this.http.get<OrchestratorSourcesResponse>(`${this.baseUrl}/jobengine/sources`, {
params,
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
@@ -57,11 +57,11 @@ export class OrchestratorHttpClient implements OrchestratorApi {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
if (!this.tenantService.authorize('jobengine', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http.get<OrchestratorSource>(`${this.baseUrl}/orchestrator/sources/${encodeURIComponent(sourceId)}`, {
return this.http.get<OrchestratorSource>(`${this.baseUrl}/jobengine/sources/${encodeURIComponent(sourceId)}`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.ifNoneMatch),
});
}
@@ -92,7 +92,7 @@ export class OrchestratorHttpClient implements OrchestratorApi {
}
@Injectable({ providedIn: 'root' })
export class MockOrchestratorClient implements OrchestratorApi {
export class MockJobEngineClient implements OrchestratorApi {
private readonly sources: OrchestratorSource[] = [
{
sourceId: '11111111-1111-1111-1111-111111111111',
@@ -153,7 +153,7 @@ export class MockOrchestratorClient implements OrchestratorApi {
getSource(sourceId: string, options: Pick<OrchestratorQueryOptions, 'traceId'> = {}): Observable<OrchestratorSource> {
const found = this.sources.find((s) => s.sourceId === sourceId);
if (!found) {
return throwError(() => new Error(`Orchestrator source not found: ${sourceId}`));
return throwError(() => new Error(`JobEngine source not found: ${sourceId}`));
}
void options;
return of({ ...found });

View File

@@ -122,7 +122,7 @@ export const NOTIFIER_API_BASE_URL = new InjectionToken<string>('NOTIFIER_API_BA
export class NotifierApiHttpClient implements NotifierApi {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notifier';
private readonly baseUrl = inject(NOTIFIER_API_BASE_URL, { optional: true }) ?? '/api/v1/notify';
// ============================================================================
// Rules

View File

@@ -7,7 +7,7 @@ import { Pack, PackDetail, PackListResponse, PackVersion, CompatibilityResult }
@Injectable({ providedIn: 'root' })
export class PackRegistryClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/orchestrator/registry/packs';
private readonly baseUrl = '/api/v1/jobengine/registry/packs';
list(filter?: { status?: string; capability?: string }, limit = 50, cursor?: string): Observable<PackListResponse> {
let params = new HttpParams().set('limit', limit.toString());

View File

@@ -138,7 +138,7 @@ export class MockPlatformHealthClient {
private readonly mockServices: ServiceHealth[] = [
{ name: 'scanner', displayName: 'Scanner', state: 'healthy', uptime: 99.98, latencyP50Ms: 12, latencyP95Ms: 45, latencyP99Ms: 120, errorRate: 0.02, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'db', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.4.2', dependencies: ['authority', 'concelier'] },
{ name: 'orchestrator', displayName: 'Orchestrator', state: 'healthy', uptime: 99.95, latencyP50Ms: 8, latencyP95Ms: 32, latencyP99Ms: 85, errorRate: 0.05, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'queue', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.3.1', dependencies: ['scheduler', 'authority'] },
{ name: 'jobengine', displayName: 'JobEngine', state: 'healthy', uptime: 99.95, latencyP50Ms: 8, latencyP95Ms: 32, latencyP99Ms: 85, errorRate: 0.05, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'queue', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.3.1', dependencies: ['scheduler', 'authority'] },
{ name: 'policy', displayName: 'Policy Engine', state: 'healthy', uptime: 99.99, latencyP50Ms: 5, latencyP95Ms: 18, latencyP99Ms: 42, errorRate: 0.01, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '2.1.0', dependencies: [] },
{ name: 'authority', displayName: 'Authority', state: 'healthy', uptime: 99.99, latencyP50Ms: 6, latencyP95Ms: 22, latencyP99Ms: 55, errorRate: 0.01, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'db', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.2.0', dependencies: [] },
{ name: 'scheduler', displayName: 'Scheduler', state: 'degraded', uptime: 98.50, latencyP50Ms: 25, latencyP95Ms: 180, latencyP99Ms: 450, errorRate: 1.20, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'queue', status: 'warn', message: 'Queue depth above threshold', lastChecked: this.now }], lastUpdated: this.now, version: '1.1.3', dependencies: ['authority'] },
@@ -180,7 +180,7 @@ export class MockPlatformHealthClient {
{ from: 'authority', to: 'postgres', latencyMs: 2, healthy: true },
{ from: 'scanner', to: 'postgres', latencyMs: 3, healthy: true },
{ from: 'scheduler', to: 'rabbitmq', latencyMs: 15, healthy: false },
{ from: 'orchestrator', to: 'rabbitmq', latencyMs: 8, healthy: true },
{ from: 'jobengine', to: 'rabbitmq', latencyMs: 8, healthy: true },
{ from: 'notifier', to: 'smtp', latencyMs: 45, healthy: true },
{ from: 'scanner', to: 'redis', latencyMs: 1, healthy: true },
],
@@ -200,7 +200,7 @@ export class MockPlatformHealthClient {
state: 'active',
title: 'Scheduler queue depth elevated',
description: 'RabbitMQ queue depth for scheduler has exceeded the warning threshold of 500 messages.',
affectedServices: ['scheduler', 'orchestrator'],
affectedServices: ['scheduler', 'jobengine'],
rootCauseSuggestion: 'Increased scan workload from recent feed sync may be causing backpressure.',
correlatedEvents: [
{ timestamp: twoHoursAgo, service: 'scheduler', eventType: 'latency_spike', description: 'P95 latency increased to 180ms' },

View File

@@ -8,7 +8,7 @@ export type IncidentState = 'active' | 'resolved';
// Service definitions
export type ServiceName =
| 'scanner'
| 'orchestrator'
| 'jobengine'
| 'policy'
| 'concelier'
| 'excititor'
@@ -208,7 +208,7 @@ export const INCIDENT_SEVERITY_COLORS: Record<IncidentSeverity, string> = {
export const SERVICE_DISPLAY_NAMES: Record<ServiceName, string> = {
scanner: 'Scanner',
orchestrator: 'Orchestrator',
jobengine: 'JobEngine',
policy: 'Policy Engine',
concelier: 'Concelier',
excititor: 'Excititor',

View File

@@ -0,0 +1,152 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AppConfigService } from '../config/app-config.service';
import { ScoreReplayClient } from './proof.client';
import { ScoreReplayResponse } from './proof.models';
describe('ScoreReplayClient', () => {
let client: ScoreReplayClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ScoreReplayClient,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: AppConfigService,
useValue: {
config: {
apiBaseUrls: {
scanner: '/api/v1',
},
},
},
},
],
});
client = TestBed.inject(ScoreReplayClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('posts replay requests to scanner canonical replay route', async () => {
const replayPromise = firstValueFrom(
client.triggerReplay('scan-123', {
manifestHash: 'sha256:manifest',
}),
);
const req = httpMock.expectOne('/api/v1/scans/scan-123/score/replay');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ manifestHash: 'sha256:manifest' });
req.flush({
score: 0.82,
rootHash: 'sha256:root',
bundleUri: 'proof://bundle',
manifestHash: 'sha256:manifest',
manifestDigest: 'sha256:digest',
canonicalInputHash: 'sha256:input',
canonicalInputPayload: '{}',
seedHex: '0xabc',
factors: [],
verificationStatus: 'verified',
replayedAt: '2026-03-04T12:00:00Z',
deterministic: true,
} satisfies ScoreReplayResponse);
const replay = await replayPromise;
expect(replay.rootHash).toBe('sha256:root');
});
it('gets bundle from canonical bundle route with optional root hash query', () => {
client.getScoreBundle('scan-abc', 'sha256:root-x').subscribe();
const req = httpMock.expectOne('/api/v1/scans/scan-abc/score/bundle?rootHash=sha256:root-x');
expect(req.request.method).toBe('GET');
req.flush({
scanId: 'scan-abc',
rootHash: 'sha256:root-x',
bundleUri: 'proof://bundle',
manifestDsseValid: true,
createdAt: '2026-03-04T12:00:00Z',
});
});
it('posts verify requests to canonical verify route', () => {
client.verifyScore('scan-verify', {
expectedRootHash: 'sha256:root',
expectedCanonicalInputHash: 'sha256:input',
}).subscribe((verify) => {
expect(verify.valid).toBeTrue();
});
const req = httpMock.expectOne('/api/v1/scans/scan-verify/score/verify');
expect(req.request.method).toBe('POST');
expect(req.request.body.expectedRootHash).toBe('sha256:root');
req.flush({
valid: true,
computedRootHash: 'sha256:root',
expectedRootHash: 'sha256:root',
manifestValid: true,
ledgerValid: true,
canonicalInputHashValid: true,
expectedCanonicalInputHash: 'sha256:input',
canonicalInputHash: 'sha256:input',
verifiedAtUtc: '2026-03-04T12:01:00Z',
errorMessage: null,
});
});
it('gets history from canonical history route', () => {
client.getScoreHistory('scan-history').subscribe((history) => {
expect(history.length).toBe(1);
expect(history[0].rootHash).toBe('sha256:r1');
expect(history[0].factors[0].source).toBe('reachability');
});
const req = httpMock.expectOne('/api/v1/scans/scan-history/score/history');
expect(req.request.method).toBe('GET');
req.flush([
{
rootHash: 'sha256:r1',
replayedAt: '2026-03-04T12:00:00Z',
score: 0.78,
canonicalInputHash: 'sha256:i1',
manifestDigest: 'sha256:m1',
factors: [
{
name: 'Reachability',
weight: 0.35,
raw: 0.9,
weighted: 0.315,
source: 'reachability',
},
],
},
]);
});
it('surfaces actionable errors for route failures', async () => {
const replayPromise = firstValueFrom(client.triggerReplay('scan-fail'));
const req = httpMock.expectOne('/api/v1/scans/scan-fail/score/replay');
req.flush({ title: 'Not found' }, { status: 404, statusText: 'Not Found' });
try {
await replayPromise;
throw new Error('Expected replay request to fail.');
} catch (error: unknown) {
expect(error instanceof Error ? error.message : String(error))
.toContain('Failed to trigger score replay for scan scan-fail');
}
});
});

View File

@@ -1,12 +1,12 @@
/**
* Proof and Manifest API clients for Sprint 3500.0004.0002 - T6.
* Provides services for scan manifests, proof bundles, and score replay.
* Proof and Manifest API clients.
* Provides services for scan manifests, proof bundles, and deterministic score replay contracts.
*/
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { catchError } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
ScanManifest,
@@ -16,8 +16,12 @@ import {
ProofBundle,
ProofVerificationResult,
ScoreReplayRequest,
ScoreReplayResult,
ScoreBreakdown,
ScoreReplayResponse,
ScoreReplayFactor,
ScoreHistoryEntry,
ScoreBundleResponse,
ScoreVerifyRequest,
ScoreVerifyResponse,
DsseSignature,
} from './proof.models';
@@ -51,12 +55,14 @@ export interface ProofBundleApi {
}
/**
* API interface for score replay operations.
* API interface for Scanner score replay operations.
* Contract: /scans/{scanId}/score/{replay|bundle|verify|history}
*/
export interface ScoreReplayApi {
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult>;
getReplayStatus(replayId: string): Observable<ScoreReplayResult>;
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]>;
triggerReplay(scanId: string, request?: ScoreReplayRequest): Observable<ScoreReplayResponse>;
getScoreBundle(scanId: string, rootHash?: string): Observable<ScoreBundleResponse>;
verifyScore(scanId: string, request: ScoreVerifyRequest): Observable<ScoreVerifyResponse>;
getScoreHistory(scanId: string): Observable<readonly ScoreHistoryEntry[]>;
}
// ============================================================================
@@ -83,7 +89,6 @@ function buildMockMerkleTree(): MerkleTree {
position: i,
}));
// Build internal nodes (simplified binary tree)
const level1: MerkleTreeNode[] = [
{ nodeId: 'node-1-0', hash: 'int1a2b3c4d5e6f...', isLeaf: false, isRoot: false, level: 1, position: 0, children: [leaves[0], leaves[1]] },
{ nodeId: 'node-1-1', hash: 'int2b3c4d5e6f7...', isLeaf: false, isRoot: false, level: 1, position: 1, children: [leaves[2], leaves[3]] },
@@ -134,7 +139,7 @@ const mockProofBundle: ProofBundle = {
scanId: 'scan-abc123',
createdAt: '2025-12-20T10:05:00Z',
merkleRoot: 'sha256:root123456789abcdef1234567890abcdef1234567890abcdef1234567890',
dsseEnvelope: 'eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UuZW52ZWxvcGUrand...', // Base64 mock
dsseEnvelope: 'eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UuZW52ZWxvcGUrand...',
signatures: mockSignatures,
rekorEntry: {
logId: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
@@ -147,17 +152,98 @@ const mockProofBundle: ProofBundle = {
downloadUrl: '/api/v1/scanner/scans/scan-abc123/proofs/bundle-001/download',
};
const mockScoreBreakdown: ScoreBreakdown = {
totalScore: 85.5,
components: [
{ name: 'Vulnerability', weight: 0.4, rawScore: 75.0, weightedScore: 30.0, details: '3 medium, 12 low vulnerabilities' },
{ name: 'License', weight: 0.2, rawScore: 100.0, weightedScore: 20.0, details: 'All licenses approved' },
{ name: 'Determinism', weight: 0.2, rawScore: 100.0, weightedScore: 20.0, details: 'Merkle root verified' },
{ name: 'Provenance', weight: 0.1, rawScore: 90.0, weightedScore: 9.0, details: 'SLSA Level 2' },
{ name: 'Entropy', weight: 0.1, rawScore: 65.0, weightedScore: 6.5, details: '8% opaque ratio' },
],
computedAt: '2025-12-20T10:10:00Z',
};
function deterministicUnit(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 buildReplayFactors(scanId: string): readonly ScoreReplayFactor[] {
return [
{
name: 'Reachability',
weight: 0.35,
raw: Number((0.62 + deterministicUnit(scanId, 'reach') * 0.32).toFixed(3)),
weighted: Number((0.35 * (0.62 + deterministicUnit(scanId, 'reach') * 0.32)).toFixed(3)),
source: 'reachability',
},
{
name: 'Severity',
weight: 0.30,
raw: Number((0.68 + deterministicUnit(scanId, 'severity') * 0.28).toFixed(3)),
weighted: Number((0.30 * (0.68 + deterministicUnit(scanId, 'severity') * 0.28)).toFixed(3)),
source: 'cvss',
},
{
name: 'Exploitability',
weight: 0.20,
raw: Number((0.40 + deterministicUnit(scanId, 'exploit') * 0.52).toFixed(3)),
weighted: Number((0.20 * (0.40 + deterministicUnit(scanId, 'exploit') * 0.52)).toFixed(3)),
source: 'epss',
},
{
name: 'Mitigations',
weight: 0.15,
raw: Number((0.18 + deterministicUnit(scanId, 'mitigation') * 0.48).toFixed(3)),
weighted: Number((0.15 * (0.18 + deterministicUnit(scanId, 'mitigation') * 0.48)).toFixed(3)),
source: 'controls',
},
];
}
function buildMockReplayResponse(scanId: string): ScoreReplayResponse {
const factors = buildReplayFactors(scanId);
const score = Number(
(factors.reduce((sum, factor) => sum + factor.weighted, 0)).toFixed(3),
);
return {
score,
rootHash: `sha256:${scanId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20)}replay`,
bundleUri: `proof://scanner/${scanId}/bundle/latest`,
manifestHash: `sha256:${scanId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16)}manifest`,
manifestDigest: `sha256:${scanId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16)}digest`,
canonicalInputHash: `sha256:${scanId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 16)}canonical`,
canonicalInputPayload: JSON.stringify({ scanId, factors }, null, 0),
seedHex: '0x9f4d2b7a6e',
factors,
verificationStatus: 'verified',
replayedAt: new Date('2026-03-04T12:00:00Z').toISOString(),
deterministic: true,
};
}
function buildMockScoreHistory(scanId: string): readonly ScoreHistoryEntry[] {
const latest = buildMockReplayResponse(scanId);
const previousFactors = latest.factors.map((factor) => ({
...factor,
raw: Number(Math.max(0, factor.raw - 0.04).toFixed(3)),
weighted: Number(Math.max(0, factor.weighted - 0.012).toFixed(3)),
}));
return [
{
rootHash: latest.rootHash,
replayedAt: latest.replayedAt,
score: latest.score,
canonicalInputHash: latest.canonicalInputHash,
manifestDigest: latest.manifestDigest,
factors: latest.factors,
},
{
rootHash: `${latest.rootHash}-prev`,
replayedAt: new Date('2026-03-03T12:00:00Z').toISOString(),
score: Number(previousFactors.reduce((sum, factor) => sum + factor.weighted, 0).toFixed(3)),
canonicalInputHash: `${latest.canonicalInputHash}-prev`,
manifestDigest: `${latest.manifestDigest}-prev`,
factors: previousFactors,
},
];
}
// ============================================================================
// Mock Service Implementations
@@ -169,7 +255,7 @@ export class MockManifestApi implements ManifestApi {
return of({ ...mockManifest, scanId }).pipe(delay(200));
}
getMerkleTree(scanId: string): Observable<MerkleTree> {
getMerkleTree(_scanId: string): Observable<MerkleTree> {
return of(buildMockMerkleTree()).pipe(delay(300));
}
}
@@ -191,7 +277,7 @@ export class MockProofBundleApi implements ProofBundleApi {
}).pipe(delay(500));
}
downloadProofBundle(bundleId: string): Observable<Blob> {
downloadProofBundle(_bundleId: string): Observable<Blob> {
const mockData = new Blob(['mock-proof-bundle-content'], { type: 'application/gzip' });
return of(mockData).pipe(delay(100));
}
@@ -199,60 +285,42 @@ export class MockProofBundleApi implements ProofBundleApi {
@Injectable({ providedIn: 'root' })
export class MockScoreReplayApi implements ScoreReplayApi {
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult> {
const replayId = `replay-${Date.now()}`;
return of({
replayId,
scanId: request.scanId,
status: 'completed' as const,
startedAt: new Date(Date.now() - 5000).toISOString(),
completedAt: new Date().toISOString(),
originalScore: mockScoreBreakdown,
replayedScore: {
...mockScoreBreakdown,
totalScore: 86.0,
computedAt: new Date().toISOString(),
},
drifts: [
{
componentName: 'Vulnerability',
originalScore: 75.0,
replayedScore: 76.25,
delta: 1.25,
driftPercent: 1.67,
significant: false,
},
],
hasDrift: true,
proofBundle: mockProofBundle,
}).pipe(delay(1000));
triggerReplay(scanId: string, _request?: ScoreReplayRequest): Observable<ScoreReplayResponse> {
return of(buildMockReplayResponse(scanId)).pipe(delay(350));
}
getReplayStatus(replayId: string): Observable<ScoreReplayResult> {
getScoreBundle(scanId: string, rootHash?: string): Observable<ScoreBundleResponse> {
const replay = buildMockReplayResponse(scanId);
return of({
replayId,
scanId: 'scan-abc123',
status: 'completed' as const,
startedAt: new Date(Date.now() - 5000).toISOString(),
completedAt: new Date().toISOString(),
originalScore: mockScoreBreakdown,
replayedScore: mockScoreBreakdown,
hasDrift: false,
}).pipe(delay(200));
scanId,
rootHash: rootHash ?? replay.rootHash,
bundleUri: replay.bundleUri,
manifestDsseValid: true,
createdAt: replay.replayedAt,
}).pipe(delay(220));
}
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]> {
const history: ScoreBreakdown[] = [];
for (let i = 0; i < 5; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
history.push({
...mockScoreBreakdown,
totalScore: 85.5 - i * 0.5,
computedAt: date.toISOString(),
});
}
return of(history).pipe(delay(300));
verifyScore(scanId: string, request: ScoreVerifyRequest): Observable<ScoreVerifyResponse> {
const replay = buildMockReplayResponse(scanId);
const valid = request.expectedRootHash === replay.rootHash;
return of({
valid,
computedRootHash: replay.rootHash,
expectedRootHash: request.expectedRootHash,
manifestValid: valid,
ledgerValid: valid,
canonicalInputHashValid: request.expectedCanonicalInputHash
? request.expectedCanonicalInputHash === replay.canonicalInputHash
: true,
expectedCanonicalInputHash: request.expectedCanonicalInputHash ?? null,
canonicalInputHash: replay.canonicalInputHash,
verifiedAtUtc: new Date('2026-03-04T12:02:00Z').toISOString(),
errorMessage: valid ? null : 'Root hash mismatch detected.',
}).pipe(delay(260));
}
getScoreHistory(scanId: string): Observable<readonly ScoreHistoryEntry[]> {
return of(buildMockScoreHistory(scanId)).pipe(delay(250));
}
}
@@ -271,21 +339,21 @@ export class ManifestClient implements ManifestApi {
getManifest(scanId: string): Observable<ScanManifest> {
return this.http.get<ScanManifest>(
`${this.baseUrl}/scans/${scanId}/manifest`
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/manifest`,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch manifest: ${error.message}`))
)
throwError(() => new Error(`Failed to fetch manifest for scan ${scanId}: ${error.message}`)),
),
);
}
getMerkleTree(scanId: string): Observable<MerkleTree> {
return this.http.get<MerkleTree>(
`${this.baseUrl}/scans/${scanId}/manifest/tree`
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/manifest/tree`,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch Merkle tree: ${error.message}`))
)
throwError(() => new Error(`Failed to fetch Merkle tree for scan ${scanId}: ${error.message}`)),
),
);
}
}
@@ -301,33 +369,33 @@ export class ProofBundleClient implements ProofBundleApi {
getProofBundle(scanId: string): Observable<ProofBundle> {
return this.http.get<ProofBundle>(
`${this.baseUrl}/scans/${scanId}/proofs`
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/proofs`,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch proof bundle: ${error.message}`))
)
throwError(() => new Error(`Failed to fetch proof bundle for scan ${scanId}: ${error.message}`)),
),
);
}
verifyProofBundle(bundleId: string): Observable<ProofVerificationResult> {
return this.http.post<ProofVerificationResult>(
`${this.baseUrl}/proofs/${bundleId}/verify`,
{}
`${this.baseUrl}/proofs/${encodeURIComponent(bundleId)}/verify`,
{},
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to verify proof bundle: ${error.message}`))
)
throwError(() => new Error(`Failed to verify proof bundle ${bundleId}: ${error.message}`)),
),
);
}
downloadProofBundle(bundleId: string): Observable<Blob> {
return this.http.get(
`${this.baseUrl}/proofs/${bundleId}/download`,
{ responseType: 'blob' }
`${this.baseUrl}/proofs/${encodeURIComponent(bundleId)}/download`,
{ responseType: 'blob' },
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to download proof bundle: ${error.message}`))
)
throwError(() => new Error(`Failed to download proof bundle ${bundleId}: ${error.message}`)),
),
);
}
}
@@ -341,34 +409,51 @@ export class ScoreReplayClient implements ScoreReplayApi {
return this.config.config.apiBaseUrls.scanner;
}
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult> {
return this.http.post<ScoreReplayResult>(
`${this.baseUrl}/scans/${request.scanId}/score/replay`,
request
triggerReplay(scanId: string, request: ScoreReplayRequest = {}): Observable<ScoreReplayResponse> {
return this.http.post<ScoreReplayResponse>(
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/score/replay`,
request,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to trigger score replay: ${error.message}`))
)
throwError(() => new Error(`Failed to trigger score replay for scan ${scanId}: ${error.message}`)),
),
);
}
getReplayStatus(replayId: string): Observable<ScoreReplayResult> {
return this.http.get<ScoreReplayResult>(
`${this.baseUrl}/replays/${replayId}`
getScoreBundle(scanId: string, rootHash?: string): Observable<ScoreBundleResponse> {
let params = new HttpParams();
if (rootHash) {
params = params.set('rootHash', rootHash);
}
return this.http.get<ScoreBundleResponse>(
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/score/bundle`,
{ params },
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get replay status: ${error.message}`))
)
throwError(() => new Error(`Failed to fetch score bundle for scan ${scanId}: ${error.message}`)),
),
);
}
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]> {
return this.http.get<readonly ScoreBreakdown[]>(
`${this.baseUrl}/scans/${scanId}/score/history`
verifyScore(scanId: string, request: ScoreVerifyRequest): Observable<ScoreVerifyResponse> {
return this.http.post<ScoreVerifyResponse>(
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/score/verify`,
request,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get score history: ${error.message}`))
)
throwError(() => new Error(`Failed to verify score bundle for scan ${scanId}: ${error.message}`)),
),
);
}
getScoreHistory(scanId: string): Observable<readonly ScoreHistoryEntry[]> {
return this.http.get<readonly ScoreHistoryEntry[]>(
`${this.baseUrl}/scans/${encodeURIComponent(scanId)}/score/history`,
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch score history for scan ${scanId}: ${error.message}`)),
),
);
}
}

View File

@@ -123,7 +123,97 @@ export interface ProofVerificationResult {
export type ScoreReplayStatus = 'pending' | 'running' | 'completed' | 'failed';
/**
* Score component with breakdown.
* Deterministic score factor returned by Scanner replay/history APIs.
*/
export interface ScoreReplayFactor {
readonly name: string;
readonly weight: number;
readonly raw: number;
readonly weighted: number;
readonly source: string;
}
/**
* POST /scans/{scanId}/score/replay request payload.
*/
export interface ScoreReplayRequest {
readonly manifestHash?: string;
readonly freezeTimestamp?: string;
}
/**
* POST /scans/{scanId}/score/replay response payload.
*/
export interface ScoreReplayResponse {
readonly score: number;
readonly rootHash: string;
readonly bundleUri: string;
readonly manifestHash: string;
readonly manifestDigest: string;
readonly canonicalInputHash: string;
readonly canonicalInputPayload: string;
readonly seedHex: string;
readonly factors: readonly ScoreReplayFactor[];
readonly verificationStatus: string;
readonly replayedAt: string;
readonly deterministic: boolean;
}
/**
* GET /scans/{scanId}/score/bundle response payload.
*/
export interface ScoreBundleResponse {
readonly scanId: string;
readonly rootHash: string;
readonly bundleUri: string;
readonly manifestDsseValid: boolean;
readonly createdAt: string;
}
/**
* POST /scans/{scanId}/score/verify request payload.
*/
export interface ScoreVerifyRequest {
readonly expectedRootHash: string;
readonly bundleUri?: string;
readonly expectedCanonicalInputHash?: string;
readonly canonicalInputPayload?: string;
}
/**
* POST /scans/{scanId}/score/verify response payload.
*/
export interface ScoreVerifyResponse {
readonly valid: boolean;
readonly computedRootHash: string;
readonly expectedRootHash: string;
readonly manifestValid: boolean;
readonly ledgerValid: boolean;
readonly canonicalInputHashValid: boolean;
readonly expectedCanonicalInputHash?: string | null;
readonly canonicalInputHash?: string | null;
readonly verifiedAtUtc: string;
readonly errorMessage?: string | null;
}
/**
* GET /scans/{scanId}/score/history response item.
*/
export interface ScoreHistoryEntry {
readonly rootHash: string;
readonly replayedAt: string;
readonly score: number;
readonly canonicalInputHash: string;
readonly manifestDigest: string;
readonly factors: readonly ScoreReplayFactor[];
}
// ----------------------------------------------------------------------------
// Legacy compatibility models (kept for existing proof replay views).
// ----------------------------------------------------------------------------
/**
* @deprecated Use ScoreReplayFactor.
*/
export interface ScoreComponent {
readonly name: string;
@@ -134,16 +224,20 @@ export interface ScoreComponent {
}
/**
* Complete score breakdown.
* @deprecated Use ScoreHistoryEntry or ScoreReplayResponse.
*/
export interface ScoreBreakdown {
readonly totalScore: number;
readonly components: readonly ScoreComponent[];
readonly computedAt: string;
readonly rootHash?: string;
readonly canonicalInputHash?: string;
readonly manifestDigest?: string;
readonly explainabilityVectorVersion?: string;
}
/**
* Score comparison result showing drift between original and replayed.
* @deprecated Use ScoreReplayResponse + ScoreHistoryEntry.
*/
export interface ScoreDrift {
readonly componentName: string;
@@ -155,15 +249,7 @@ export interface ScoreDrift {
}
/**
* Score replay request.
*/
export interface ScoreReplayRequest {
readonly scanId: string;
readonly useCurrentPolicy?: boolean;
}
/**
* Score replay response with comparison.
* @deprecated Use ScoreReplayResponse + ScoreVerifyResponse + ScoreBundleResponse.
*/
export interface ScoreReplayResult {
readonly replayId: string;

View File

@@ -126,10 +126,10 @@ export class QuotaClient {
}
/**
* Get job quota status from Orchestrator.
* Get job quota status from JobEngine.
*/
getJobQuotaStatus(): Observable<JobQuotaStatus> {
return this.http.get<JobQuotaStatus>('/api/v1/orchestrator/quotas');
return this.http.get<JobQuotaStatus>('/api/v1/jobengine/quotas');
}
/**

View File

@@ -1,5 +1,5 @@
/**
* Release Orchestrator Dashboard Models
* Release JobEngine Dashboard Models
* TypeScript interfaces for dashboard data
*/

View File

@@ -1,5 +1,5 @@
/**
* Release Management Models for Release Orchestrator
* Release Management Models for Release JobEngine
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/

View File

@@ -156,7 +156,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
description: 'Navigate to job list',
icon: 'workflow',
route: '/ops/operations/jobs-queues',
keywords: ['jobs', 'orchestrator', 'list'],
keywords: ['jobs', 'jobengine', 'list'],
},
{
id: 'findings',

View File

@@ -1,10 +1,10 @@
/**
* Security Findings API Client
* Provides access to scanner findings data via the gateway.
* Security Findings API Client.
* Provides access to findings and vulnerability detail data via gateway contracts.
*/
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { catchError, delay, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { PlatformContextStore } from '../context/platform-context.store';
@@ -44,6 +44,74 @@ export interface FindingDetailDto extends FindingDto {
fixedVersions: string[];
}
export interface SignedScoreFactorDto {
name: string;
weight: number;
raw: number;
weighted: number;
source: string;
}
export interface SignedScoreProvenanceLinkDto {
label: string;
href: string;
}
export interface SignedScoreVerifyDto {
replaySuccessRatio: number;
medianVerifyTimeMs: number;
symbolCoverage: number;
verifiedAt?: string;
}
export type SignedScoreGateStatus = 'pass' | 'warn' | 'block';
export interface SignedScoreGateDto {
status: SignedScoreGateStatus;
threshold: number;
actual: number;
reason: string;
}
export interface SignedScoreDto {
score: number;
policyVersion: string;
computedAt: string;
rootHash?: string;
canonicalInputHash?: string;
factors: SignedScoreFactorDto[];
provenanceLinks: SignedScoreProvenanceLinkDto[];
verify?: SignedScoreVerifyDto;
gate: SignedScoreGateDto;
}
export interface DeployedEnvironmentDto {
name: string;
version: string;
deployedAt?: string;
releaseId?: string;
}
export interface GateImpactDto {
gateType: string;
impact: 'BLOCKS' | 'WARNS' | 'ALLOWS';
affectedPromotions: string[];
}
export interface VulnerabilityDetailDto extends FindingDetailDto {
cveId: string;
findingId?: string;
cvssVector?: string;
epss: number;
exploitedInWild: boolean;
fixedIn?: string;
vexJustification?: string | null;
deployedEnvironments: DeployedEnvironmentDto[];
gateImpacts: GateImpactDto[];
witnessPath: string[];
signedScore?: SignedScoreDto;
}
// ============================================================================
// API Interface
// ============================================================================
@@ -51,6 +119,7 @@ export interface FindingDetailDto extends FindingDto {
export interface SecurityFindingsApi {
listFindings(filter?: FindingsFilter): Observable<FindingDto[]>;
getFinding(findingId: string): Observable<FindingDetailDto>;
getVulnerabilityDetail(vulnerabilityId: string): Observable<VulnerabilityDetailDto>;
}
export const SECURITY_FINDINGS_API = new InjectionToken<SecurityFindingsApi>('SECURITY_FINDINGS_API');
@@ -97,10 +166,12 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
if (filter?.environment) params = params.set('environment', filter.environment);
if (filter?.limit) params = params.set('limit', filter.limit.toString());
if (filter?.sort) params = params.set('sort', filter.sort);
const selectedRegion = this.context.selectedRegions()[0];
if (selectedRegion) {
params = params.set('region', selectedRegion);
}
if (!filter?.environment) {
const selectedEnvironment = this.context.selectedEnvironments()[0];
if (selectedEnvironment) {
@@ -128,19 +199,40 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
getFinding(findingId: string): Observable<FindingDetailDto> {
return this.http
.get<any>(`${this.baseUrl}/api/v2/security/disposition/${findingId}`, {
.get<any>(`${this.baseUrl}/api/v2/security/disposition/${encodeURIComponent(findingId)}`, {
headers: this.buildHeaders(),
})
.pipe(
map((res) => this.mapDispositionToDetail(res?.item ?? res, findingId)),
catchError(() =>
this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${encodeURIComponent(findingId)}/summary`, {
headers: this.buildHeaders(),
}),
),
);
}
getVulnerabilityDetail(vulnerabilityId: string): Observable<VulnerabilityDetailDto> {
const id = vulnerabilityId.trim();
if (!id) {
return throwError(() => this.toError('Vulnerability identifier is required.', 400));
}
return this.http
.get<any>(`${this.baseUrl}/api/v2/security/vulnerabilities/${encodeURIComponent(id)}`, {
headers: this.buildHeaders(),
})
.pipe(
map((res) => this.mapVulnerabilityToDetail(res?.item ?? res, id)),
catchError((error: unknown) =>
this.getFinding(id).pipe(
map((detail) => this.fallbackVulnerabilityDetail(detail, id)),
catchError(() => throwError(() => this.normalizeVulnerabilityError(error, id))),
),
),
);
}
private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
const headers: Record<string, string> = {};
@@ -172,15 +264,15 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
const base = this.mapV2Finding({
findingId: row?.findingId ?? fallbackId,
cveId: row?.cveId ?? fallbackId,
severity: 'medium',
severity: row?.severity ?? 'medium',
packageName: row?.packageName ?? 'unknown',
componentName: row?.componentName ?? 'unknown',
releaseId: row?.releaseId ?? '',
releaseName: row?.releaseName ?? '',
environment: row?.environment ?? '',
region: row?.region ?? '',
reachable: true,
reachabilityScore: 0,
reachable: row?.reachable ?? true,
reachabilityScore: row?.reachabilityScore ?? 0,
effectiveDisposition: row?.effectiveDisposition ?? 'unknown',
vexStatus: row?.vex?.status ?? row?.effectiveDisposition ?? 'none',
updatedAt: row?.updatedAt ?? new Date().toISOString(),
@@ -195,6 +287,285 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
};
}
private mapVulnerabilityToDetail(row: any, fallbackId: string): VulnerabilityDetailDto {
const cveId = String(row?.cveId ?? row?.id ?? fallbackId).toUpperCase();
const finding = this.mapDispositionToDetail(
{
findingId: row?.findingId ?? row?.id ?? fallbackId,
cveId,
severity: row?.severity ?? row?.score?.severity ?? 'medium',
packageName: row?.packageName ?? row?.package ?? row?.component?.name ?? 'unknown',
componentName: row?.componentName ?? row?.component?.version ?? row?.version ?? 'unknown',
releaseId: row?.releaseId ?? row?.release?.id ?? '',
releaseName: row?.releaseName ?? row?.release?.name ?? '',
environment: row?.environment ?? '',
region: row?.region ?? '',
reachable: row?.reachable ?? row?.reachability?.reachable ?? true,
reachabilityScore: row?.reachabilityScore ?? row?.reachability?.confidence ?? 0,
effectiveDisposition: row?.effectiveDisposition ?? row?.vex?.status ?? 'unknown',
vexStatus: row?.vexStatus ?? row?.vex?.status ?? 'none',
updatedAt: row?.updatedAt ?? new Date().toISOString(),
},
fallbackId,
);
const description = String(row?.description ?? finding.description ?? 'No description available.');
const affectedVersions = this.toStringArray(row?.affectedVersions, []);
const fixedVersions = this.toStringArray(row?.fixedVersions, []);
return {
...finding,
id: cveId,
cveId,
findingId: row?.findingId ?? finding.id,
cvssVector: typeof row?.cvssVector === 'string' ? row.cvssVector : undefined,
epss: this.normalizeProbability(row?.epss, this.deterministicRange(cveId, 'epss', 0.14, 0.91)),
exploitedInWild: Boolean(row?.exploitedInWild ?? row?.kevListed ?? false),
description,
references: this.toStringArray(row?.references, []),
affectedVersions,
fixedVersions,
fixedIn: fixedVersions[0],
vexJustification: typeof row?.vexJustification === 'string' ? row.vexJustification : null,
deployedEnvironments: this.toEnvironmentList(row?.deployedEnvironments),
gateImpacts: this.toGateImpacts(row?.gateImpacts),
witnessPath: this.toStringArray(row?.witnessPath, []),
signedScore: this.toSignedScore(row?.signedScore, cveId),
};
}
private fallbackVulnerabilityDetail(detail: FindingDetailDto, vulnerabilityId: string): VulnerabilityDetailDto {
const cveId = vulnerabilityId.toUpperCase();
const reachable = detail.reachable ?? this.deterministicRange(cveId, 'reachable', 0, 1) >= 0.5;
const confidence = detail.reachabilityConfidence ?? Math.round(this.deterministicRange(cveId, 'conf', 61, 98));
const score = Math.round(Math.min(100, Math.max(0, detail.cvss * 10)));
const threshold = 70;
const gateStatus: SignedScoreGateStatus = score >= threshold ? 'pass' : score >= 50 ? 'warn' : 'block';
const gateReason = gateStatus === 'pass'
? 'Replay score meets release gate threshold.'
: gateStatus === 'warn'
? 'Replay score is near threshold; operator review is required.'
: 'Replay score below policy threshold blocks promotion.';
return {
...detail,
id: cveId,
cveId,
findingId: detail.id,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
epss: Number(this.deterministicRange(cveId, 'epss', 0.11, 0.97).toFixed(2)),
exploitedInWild: this.deterministicRange(cveId, 'kev', 0, 1) >= 0.8,
fixedIn: detail.fixedVersions[0],
vexJustification: detail.vexStatus === 'not_affected' ? 'Vendor states vulnerability is not exploitable.' : null,
deployedEnvironments: [
{
name: detail.environments[0] ?? 'production',
version: detail.releaseVersion || 'unknown',
releaseId: detail.releaseId || undefined,
},
],
gateImpacts: [
{
gateType: 'Critical Reachability Gate',
impact: reachable && detail.severity === 'CRITICAL' ? 'BLOCKS' : 'WARNS',
affectedPromotions: ['stage -> prod'],
},
],
witnessPath: reachable
? [
`${detail.package}.entrypoint()`,
`${detail.package}.service()`,
`${detail.package}.sink()`,
]
: [],
signedScore: {
score,
policyVersion: 'ews.v1.2',
computedAt: detail.firstSeen,
factors: [
{ name: 'Reachability', weight: 0.35, raw: reachable ? 1 : 0.2, weighted: reachable ? 0.35 : 0.07, source: 'reachability' },
{ name: 'Severity', weight: 0.3, raw: Math.min(1, detail.cvss / 10), weighted: Number((Math.min(1, detail.cvss / 10) * 0.3).toFixed(3)), source: 'cvss' },
{ name: 'Exploitability', weight: 0.2, raw: this.normalizeProbability(this.deterministicRange(cveId, 'xpl', 0.18, 0.89), 0.25), weighted: Number((this.deterministicRange(cveId, 'xpl', 0.18, 0.89) * 0.2).toFixed(3)), source: 'epss' },
{ name: 'Mitigations', weight: 0.15, raw: this.deterministicRange(cveId, 'mit', 0.1, 0.9), weighted: Number((this.deterministicRange(cveId, 'mit', 0.1, 0.9) * 0.15).toFixed(3)), source: 'controls' },
],
provenanceLinks: [
{ label: 'Replay history', href: `/api/v1/scans/${encodeURIComponent(vulnerabilityId)}/score/history` },
{ label: 'Proof bundle', href: `/api/v1/scans/${encodeURIComponent(vulnerabilityId)}/score/bundle` },
],
verify: {
replaySuccessRatio: Number(this.deterministicRange(cveId, 'ratio', 0.82, 0.99).toFixed(2)),
medianVerifyTimeMs: Math.round(this.deterministicRange(cveId, 'verifyMs', 45, 240)),
symbolCoverage: Math.round(this.deterministicRange(cveId, 'symbols', 73, 99)),
verifiedAt: detail.firstSeen,
},
gate: {
status: gateStatus,
threshold,
actual: score,
reason: gateReason,
},
},
};
}
private toEnvironmentList(value: unknown): DeployedEnvironmentDto[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
const item = entry as Record<string, unknown>;
return {
name: String(item['name'] ?? item['environment'] ?? ''),
version: String(item['version'] ?? item['releaseVersion'] ?? 'unknown'),
deployedAt: typeof item['deployedAt'] === 'string' ? item['deployedAt'] : undefined,
releaseId: typeof item['releaseId'] === 'string' ? item['releaseId'] : undefined,
} satisfies DeployedEnvironmentDto;
})
.filter((entry) => entry.name.length > 0);
}
private toGateImpacts(value: unknown): GateImpactDto[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
const item = entry as Record<string, unknown>;
const rawImpact = String(item['impact'] ?? '').toUpperCase();
const impact: GateImpactDto['impact'] = rawImpact === 'BLOCKS' || rawImpact === 'WARNS' || rawImpact === 'ALLOWS'
? rawImpact
: 'WARNS';
return {
gateType: String(item['gateType'] ?? item['name'] ?? 'Policy Gate'),
impact,
affectedPromotions: this.toStringArray(item['affectedPromotions'], []),
} satisfies GateImpactDto;
})
.filter((entry) => entry.gateType.length > 0);
}
private toSignedScore(value: unknown, cveId: string): SignedScoreDto | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const source = value as Record<string, unknown>;
const actual = this.normalizePercentage(source['score'], this.deterministicRange(cveId, 'score', 45, 95));
const threshold = this.normalizePercentage(source['threshold'], 70);
const gateStatus = this.toGateStatus(source['gateStatus'], actual, threshold);
const reason = typeof source['gateReason'] === 'string'
? source['gateReason']
: gateStatus === 'pass'
? 'Score passed policy threshold.'
: gateStatus === 'warn'
? 'Score requires manual approval.'
: 'Score below policy threshold.';
return {
score: actual,
policyVersion: String(source['policyVersion'] ?? 'ews.v1.2'),
computedAt: String(source['computedAt'] ?? new Date().toISOString()),
rootHash: typeof source['rootHash'] === 'string' ? source['rootHash'] : undefined,
canonicalInputHash: typeof source['canonicalInputHash'] === 'string' ? source['canonicalInputHash'] : undefined,
factors: this.toFactorList(source['factors']),
provenanceLinks: this.toProvenanceLinks(source['provenanceLinks'], cveId),
verify: this.toVerifySummary(source['verify']),
gate: {
status: gateStatus,
threshold,
actual,
reason,
},
};
}
private toFactorList(value: unknown): SignedScoreFactorDto[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => {
const item = entry as Record<string, unknown>;
return {
name: String(item['name'] ?? 'Factor'),
weight: Number(item['weight'] ?? 0),
raw: Number(item['raw'] ?? item['value'] ?? 0),
weighted: Number(item['weighted'] ?? item['contribution'] ?? 0),
source: String(item['source'] ?? 'unknown'),
} satisfies SignedScoreFactorDto;
})
.filter((factor) => Number.isFinite(factor.weight) && Number.isFinite(factor.raw) && Number.isFinite(factor.weighted));
}
private toProvenanceLinks(value: unknown, id: string): SignedScoreProvenanceLinkDto[] {
if (!Array.isArray(value)) {
return [
{ label: 'Replay history', href: `/api/v1/scans/${encodeURIComponent(id)}/score/history` },
];
}
return value
.map((entry) => {
const item = entry as Record<string, unknown>;
return {
label: String(item['label'] ?? 'Source'),
href: String(item['href'] ?? ''),
} satisfies SignedScoreProvenanceLinkDto;
})
.filter((link) => link.href.length > 0);
}
private toVerifySummary(value: unknown): SignedScoreVerifyDto | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const item = value as Record<string, unknown>;
return {
replaySuccessRatio: this.normalizeProbability(item['replaySuccessRatio'], 0),
medianVerifyTimeMs: Math.max(0, Math.round(Number(item['medianVerifyTimeMs'] ?? 0))),
symbolCoverage: Math.max(0, Math.min(100, Math.round(Number(item['symbolCoverage'] ?? 0)))),
verifiedAt: typeof item['verifiedAt'] === 'string' ? item['verifiedAt'] : undefined,
};
}
private toGateStatus(value: unknown, actual: number, threshold: number): SignedScoreGateStatus {
const raw = String(value ?? '').toLowerCase();
if (raw === 'pass' || raw === 'warn' || raw === 'block') {
return raw;
}
if (actual >= threshold) return 'pass';
if (actual >= threshold - 10) return 'warn';
return 'block';
}
private toStringArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return fallback;
}
return value.map((entry) => String(entry)).filter((entry) => entry.length > 0);
}
private normalizeProbability(value: unknown, fallback: number): number {
const candidate = Number(value);
if (!Number.isFinite(candidate)) {
return Number(fallback.toFixed(2));
}
return Number(Math.min(1, Math.max(0, candidate)).toFixed(2));
}
private normalizePercentage(value: unknown, fallback: number): number {
const candidate = Number(value);
if (!Number.isFinite(candidate)) {
return Math.round(fallback);
}
return Math.round(Math.min(100, Math.max(0, candidate)));
}
private mapSeverity(value: string): FindingDto['severity'] {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'CRITICAL' || normalized === 'HIGH' || normalized === 'MEDIUM' || normalized === 'LOW') {
@@ -202,6 +573,46 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
}
return 'MEDIUM';
}
private deterministicRange(seed: string, salt: string, min: number, max: number): number {
return min + this.deterministicUnit(seed, salt) * (max - min);
}
private deterministicUnit(seed: string, salt: string): number {
const input = `${seed}:${salt}`;
let hash = 2166136261;
for (let index = 0; index < input.length; index++) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
private normalizeVulnerabilityError(error: unknown, vulnerabilityId: string): Error {
if (error instanceof HttpErrorResponse) {
if (error.status === 404) {
return this.toError(`Vulnerability not found: ${vulnerabilityId}`, 404);
}
if (error.status === 400) {
return this.toError(`Malformed vulnerability identifier: ${vulnerabilityId}`, 400);
}
return this.toError(`Failed to load vulnerability detail (${error.status}).`, error.status);
}
if (error instanceof Error) {
return error;
}
return this.toError('Failed to load vulnerability detail.', undefined);
}
private toError(message: string, status: number | undefined): Error {
const error = new Error(message);
if (status !== undefined) {
(error as Error & { status?: number }).status = status;
}
return error;
}
}
// ============================================================================
@@ -244,4 +655,88 @@ export class MockSecurityFindingsClient implements SecurityFindingsApi {
fixedVersions: ['4.17.21'],
}).pipe(delay(200));
}
getVulnerabilityDetail(vulnerabilityId: string): Observable<VulnerabilityDetailDto> {
const id = vulnerabilityId.trim();
if (!id) {
return throwError(() => new Error('Vulnerability identifier is required.'));
}
if (!/^CVE-\d{4}-\d+$/i.test(id) && !/^f-\d+$/i.test(id)) {
return throwError(() => new Error(`Vulnerability not found: ${id}`));
}
const normalized = id.toUpperCase().startsWith('CVE-') ? id.toUpperCase() : 'CVE-2026-1234';
const score = normalized === 'CVE-2026-1234' ? 74 : 62;
const detail: VulnerabilityDetailDto = {
id: normalized,
cveId: normalized,
findingId: id.startsWith('f-') ? id : `f-${normalized.split('-')[2]}`,
package: normalized === 'CVE-2026-1234' ? 'openssl' : 'lodash',
version: normalized === 'CVE-2026-1234' ? '3.0.8' : '4.17.20',
severity: normalized === 'CVE-2026-1234' ? 'CRITICAL' : 'HIGH',
cvss: normalized === 'CVE-2026-1234' ? 9.1 : 7.4,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
epss: normalized === 'CVE-2026-1234' ? 0.87 : 0.64,
exploitedInWild: normalized === 'CVE-2026-1234',
reachable: true,
reachabilityConfidence: 0.91,
vexStatus: normalized === 'CVE-2026-1234' ? 'affected' : 'under_investigation',
vexJustification: normalized === 'CVE-2026-1234' ? null : 'Awaiting upstream vendor verification.',
releaseId: 'rel-1',
releaseVersion: '1.2.3',
delta: 'new',
environments: ['prod', 'staging'],
firstSeen: '2026-02-10T08:00:00Z',
description: 'Vulnerability detail sourced from deterministic security mock contract.',
references: ['https://nvd.nist.gov/vuln/detail/CVE-2026-1234'],
affectedVersions: ['< 3.0.9'],
fixedVersions: ['3.0.9'],
fixedIn: '3.0.9',
deployedEnvironments: [
{ name: 'staging', version: '1.2.5', releaseId: 'rel-staging-125' },
{ name: 'production', version: '1.2.3', releaseId: 'rel-prod-123' },
],
gateImpacts: [
{ gateType: 'Critical Reachability Gate', impact: 'BLOCKS', affectedPromotions: ['stage -> prod'] },
{ gateType: 'Policy Drift Gate', impact: 'WARNS', affectedPromotions: ['dev -> stage'] },
],
witnessPath: [
'api.gateway.handle()',
'service.vuln.evaluate()',
'package.sink.invoke()',
],
signedScore: {
score,
policyVersion: 'ews.v1.2',
computedAt: '2026-03-04T10:10:00Z',
rootHash: 'sha256:1f2a3b4c5d',
canonicalInputHash: 'sha256:5d4c3b2a1f',
factors: [
{ name: 'Reachability', weight: 0.35, raw: 0.92, weighted: 0.322, source: 'reachability' },
{ name: 'Severity', weight: 0.30, raw: 0.91, weighted: 0.273, source: 'cvss' },
{ name: 'Exploitability', weight: 0.20, raw: 0.87, weighted: 0.174, source: 'epss' },
{ name: 'Mitigations', weight: 0.15, raw: 0.23, weighted: 0.0345, source: 'controls' },
],
provenanceLinks: [
{ label: 'Replay history', href: '/api/v1/scans/scan-abc123/score/history' },
{ label: 'Proof bundle', href: '/api/v1/scans/scan-abc123/score/bundle' },
],
verify: {
replaySuccessRatio: 0.96,
medianVerifyTimeMs: 68,
symbolCoverage: 94,
verifiedAt: '2026-03-04T10:12:00Z',
},
gate: {
status: score >= 70 ? 'pass' : 'warn',
threshold: 70,
actual: score,
reason: score >= 70 ? 'Replay score meets the policy threshold.' : 'Replay score requires review before promotion.',
},
},
};
return of(detail).pipe(delay(220));
}
}

View File

@@ -23,7 +23,7 @@ import {
@Injectable({ providedIn: 'root' })
export class SloClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/orchestrator/slos';
private readonly baseUrl = '/api/v1/jobengine/slos';
// ─────────────────────────────────────────────────────────────────────────────
// SLO Definitions

View File

@@ -115,7 +115,7 @@ export interface ExportOrchestrationOptions {
}
/**
* Export Orchestrator API interface.
* Export JobEngine API interface.
*/
export interface VulnExportOrchestratorApi {
/** Start an export job. */
@@ -140,7 +140,7 @@ export interface VulnExportOrchestratorApi {
export const VULN_EXPORT_ORCHESTRATOR_API = new InjectionToken<VulnExportOrchestratorApi>('VULN_EXPORT_ORCHESTRATOR_API');
/**
* Vulnerability Export Orchestrator Service.
* Vulnerability Export JobEngine Service.
* Implements WEB-VULN-29-003 with SSE streaming, progress headers, and signed download links.
*/
@Injectable({ providedIn: 'root' })
@@ -502,7 +502,7 @@ export class VulnExportOrchestratorService implements VulnExportOrchestratorApi
}
/**
* Mock Export Orchestrator for quickstart mode.
* Mock Export JobEngine for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockVulnExportOrchestrator implements VulnExportOrchestratorApi {

View File

@@ -1,5 +1,5 @@
/**
* Workflow Models for Release Orchestrator
* Workflow Models for Release JobEngine
* Sprint: SPRINT_20260110_111_004_FE_workflow_editor
*/

View File

@@ -141,8 +141,8 @@ export const requirePolicyReviewOrApproveGuard: CanMatchFn = () => {
// Pre-built guards for common scope requirements (UI-ORCH-32-001)
/**
* Guard requiring orch:read scope for Orchestrator dashboard access.
* Redirects to /console/profile if user lacks Orchestrator viewer access.
* Guard requiring orch:read scope for JobEngine dashboard access.
* Redirects to /console/profile if user lacks JobEngine viewer access.
*/
export const requireOrchViewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ],
@@ -150,7 +150,7 @@ export const requireOrchViewerGuard: CanMatchFn = requireScopesGuard(
);
/**
* Guard requiring orch:operate scope for Orchestrator control actions.
* Guard requiring orch:operate scope for JobEngine control actions.
*/
export const requireOrchOperatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_OPERATE],

View File

@@ -42,10 +42,10 @@ export interface AuthService {
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
// Orchestrator access (UI-ORCH-32-001)
// JobEngine access (UI-ORCH-32-001)
canViewOrchestrator(): boolean;
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canManageJobEngineQuotas(): boolean;
canInitiateBackfill(): boolean;
// Policy Studio access (UI-POLICY-20-003)
canViewPolicies(): boolean;
@@ -103,7 +103,7 @@ const MOCK_USER: AuthUser = {
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
// Orchestrator permissions (UI-ORCH-32-001)
// JobEngine permissions (UI-ORCH-32-001)
StellaOpsScopes.ORCH_READ,
// UI permissions
StellaOpsScopes.UI_READ,
@@ -153,7 +153,7 @@ export class MockAuthService implements AuthService {
]);
}
// Orchestrator access methods (UI-ORCH-32-001)
// JobEngine access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean {
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_READ);
}
@@ -162,7 +162,7 @@ export class MockAuthService implements AuthService {
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
canManageJobEngineQuotas(): boolean {
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}

View File

@@ -88,7 +88,7 @@ describe('AuthorityAuthAdapterService', () => {
expect(service.canViewOrchestrator()).toBeTrue();
expect(service.canOperateOrchestrator()).toBeTrue();
expect(service.canManageOrchestratorQuotas()).toBeTrue();
expect(service.canManageJobEngineQuotas()).toBeTrue();
expect(service.canInitiateBackfill()).toBeTrue();
});
@@ -107,7 +107,7 @@ describe('AuthorityAuthAdapterService', () => {
expect(service.canViewOrchestrator()).toBeFalse();
expect(service.canOperateOrchestrator()).toBeFalse();
expect(service.canManageOrchestratorQuotas()).toBeFalse();
expect(service.canManageJobEngineQuotas()).toBeFalse();
expect(service.canInitiateBackfill()).toBeFalse();
});
});

View File

@@ -89,7 +89,7 @@ export class AuthorityAuthAdapterService implements AuthService {
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
canManageJobEngineQuotas(): boolean {
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}

View File

@@ -73,7 +73,7 @@ export const StellaOpsScopes = {
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Orchestrator scopes (UI-ORCH-32-001)
// JobEngine scopes (UI-ORCH-32-001)
ORCH_READ: 'orch:read',
ORCH_OPERATE: 'orch:operate',
ORCH_QUOTA: 'orch:quota',
@@ -193,7 +193,7 @@ export const ScopeGroups = {
StellaOpsScopes.POLICY_WRITE,
] as const,
// Orchestrator scope groups (UI-ORCH-32-001)
// JobEngine scope groups (UI-ORCH-32-001)
ORCH_VIEWER: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.UI_READ,
@@ -305,10 +305,10 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'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',
'orch:quota': 'Manage Orchestrator Quotas',
// JobEngine scope labels (UI-ORCH-32-001)
'orch:read': 'View JobEngine Jobs',
'orch:operate': 'Operate JobEngine',
'orch:quota': 'Manage JobEngine Quotas',
'orch:backfill': 'Initiate Backfill Runs',
// UI scope labels
'ui.read': 'Console Access',

View File

@@ -127,10 +127,11 @@ export class PlatformContextStore {
this.regions.set(sortedRegions);
this.loadPreferences();
},
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to load global regions.'));
this.loading.set(false);
this.persistPaused = false;
error: () => {
// Regions endpoint may fail (e.g. 401 before auth token is ready).
// Continue initialization with empty regions so the app remains usable.
this.regions.set([]);
this.loadPreferences();
},
});
}

View File

@@ -70,6 +70,9 @@ export class I18nService {
async loadTranslations(locale?: string): Promise<void> {
const requestedLocale = locale ?? this.getSavedLocale() ?? DEFAULT_LOCALE;
const effectiveLocale = normalizeLocale(requestedLocale);
const fallbackTranslations = this.toStringBundle(
FALLBACK_BUNDLES[effectiveLocale] ?? FALLBACK_BUNDLES[DEFAULT_LOCALE]
);
try {
const bundle = await firstValueFrom(
@@ -79,14 +82,13 @@ export class I18nService {
);
if (bundle && typeof bundle === 'object') {
// Remove metadata keys
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(bundle)) {
if (!key.startsWith('_') && typeof value === 'string') {
cleaned[key] = value;
}
}
this._translations.set(cleaned);
// Merge backend bundle over embedded locale bundle so missing backend keys
// still resolve to translated frontend defaults instead of English fallbacks.
const merged = {
...fallbackTranslations,
...this.toStringBundle(bundle),
};
this._translations.set(merged);
this._locale.set(effectiveLocale);
return;
}
@@ -94,16 +96,8 @@ export class I18nService {
// Platform API unavailable, use embedded fallback.
}
const fallbackTranslations = FALLBACK_BUNDLES[effectiveLocale] ?? FALLBACK_BUNDLES[DEFAULT_LOCALE];
// Offline fallback: load embedded locale bundle
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(fallbackTranslations)) {
if (!key.startsWith('_') && typeof value === 'string') {
cleaned[key] = value;
}
}
this._translations.set(cleaned);
// Offline fallback: load embedded locale bundle.
this._translations.set(fallbackTranslations);
this._locale.set(effectiveLocale);
}
@@ -167,6 +161,16 @@ export class I18nService {
});
}
private toStringBundle(bundle: RawTranslationBundle): Record<string, string> {
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(bundle)) {
if (!key.startsWith('_') && typeof value === 'string') {
cleaned[key] = value;
}
}
return cleaned;
}
private getSavedLocale(): string | null {
try {
const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);

View File

@@ -192,9 +192,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
],
},
{
id: 'orchestrator',
id: 'jobengine',
label: 'Jobs & Orchestration',
route: '/orchestrator',
route: '/jobengine',
icon: 'workflow',
tooltip: 'View and manage orchestration jobs',
},
@@ -279,20 +279,20 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'dead-letter',
label: 'Dead-Letter Queue',
route: '/ops/orchestrator/dead-letter',
route: '/ops/jobengine/dead-letter',
icon: 'alert-triangle',
tooltip: 'Failed job recovery, replay, and resolution workflows',
children: [
{
id: 'dlq-dashboard',
label: 'Dashboard',
route: '/ops/orchestrator/dead-letter',
route: '/ops/jobengine/dead-letter',
tooltip: 'Queue statistics and error distribution',
},
{
id: 'dlq-queue',
label: 'Queue Browser',
route: '/ops/orchestrator/dead-letter/queue',
route: '/ops/jobengine/dead-letter/queue',
tooltip: 'Browse and filter dead-letter entries',
},
],
@@ -300,26 +300,26 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'slo-monitoring',
label: 'SLO Monitoring',
route: '/ops/orchestrator/slo',
route: '/ops/jobengine/slo',
icon: 'activity',
tooltip: 'Service Level Objective health and burn rate tracking',
children: [
{
id: 'slo-dashboard',
label: 'Dashboard',
route: '/ops/orchestrator/slo',
route: '/ops/jobengine/slo',
tooltip: 'SLO health summary and burn rates',
},
{
id: 'slo-alerts',
label: 'Alerts',
route: '/ops/orchestrator/slo/alerts',
route: '/ops/jobengine/slo/alerts',
tooltip: 'Active and historical SLO alerts',
},
{
id: 'slo-definitions',
label: 'Definitions',
route: '/ops/orchestrator/slo/definitions',
route: '/ops/jobengine/slo/definitions',
tooltip: 'Manage SLO definitions and thresholds',
},
],

View File

@@ -295,8 +295,8 @@ interface HistoryEvent {
</table>
<div class="footer-links">
<a routerLink="/security-risk/findings">Open Findings (filtered)</a>
<a routerLink="/security-risk/vex">Open VEX Hub</a>
<a routerLink="/security/findings">Open Findings (filtered)</a>
<a routerLink="/security/vex">Open VEX Hub</a>
<a routerLink="/administration/policy-governance/exceptions">Open Exceptions</a>
</div>
</section>
@@ -417,8 +417,8 @@ interface HistoryEvent {
<p>Signature status: DSSE signed, transparency log anchored, replay metadata present.</p>
<div class="footer-links">
<button type="button" class="link-btn" (click)="exportPacket()">Export Packet</button>
<a routerLink="/evidence-audit/evidence/export">Open Export Center</a>
<a routerLink="/evidence-audit/proofs">Open Proof Chain</a>
<a routerLink="/evidence/exports">Open Export Center</a>
<a routerLink="/evidence/capsules">Open Proof Chain</a>
</div>
</section>
}
@@ -444,7 +444,7 @@ interface HistoryEvent {
</ul>
<div class="footer-links">
<a routerLink="/evidence-audit/replay">Open canonical Replay/Verify</a>
<a routerLink="/evidence/verify-replay">Open canonical Replay/Verify</a>
</div>
</section>
}
@@ -842,7 +842,7 @@ export class ApprovalDetailPageComponent implements OnInit {
evidenceAge: '2h 11m',
fixLinks: [
{ label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' },
{ label: 'Open Finding', route: '/security-risk/findings' },
{ label: 'Open Finding', route: '/security/findings' },
{ label: 'Request Exception', route: '/administration/policy-governance/exceptions' },
{ label: 'Open Data Integrity', route: '/platform-ops/data-integrity' },
],

View File

@@ -226,7 +226,7 @@ export class AuditExportComponent {
includeDetails = true;
includeDiffs = true;
readonly allModules: AuditModule[] = ['authority', 'policy', 'orchestrator', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'revoke', 'issue', 'approve', 'reject', 'fail', 'complete'];
requestExport(): void {

View File

@@ -142,7 +142,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
.stat-card.authority { border-left: 4px solid var(--color-status-excepted); }
.stat-card.vex { border-left: 4px solid var(--color-status-success); }
.stat-card.integrations { border-left: 4px solid var(--color-status-warning); }
.stat-card.orchestrator { border-left: 4px solid var(--color-brand-secondary); }
.stat-card.jobengine { border-left: 4px solid var(--color-brand-secondary); }
.anomaly-alerts { margin-bottom: 2rem; }
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
@@ -238,7 +238,7 @@ export class AuditLogDashboardComponent implements OnInit {
const labels: Record<AuditModule, string> = {
authority: 'Authority',
policy: 'Policy',
orchestrator: 'Orchestrator',
jobengine: 'JobEngine',
integrations: 'Integrations',
vex: 'VEX',
scanner: 'Scanner',

View File

@@ -272,7 +272,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.module.orchestrator { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.jobengine { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.scanner { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create, .badge.action.issue { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
@@ -342,7 +342,7 @@ export class AuditLogTableComponent implements OnInit {
searchQuery = '';
actorFilter = '';
readonly allModules: AuditModule[] = ['authority', 'policy', 'orchestrator', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay'];
readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical'];
@@ -447,7 +447,7 @@ export class AuditLogTableComponent implements OnInit {
const labels: Record<AuditModule, string> = {
authority: 'Authority',
policy: 'Policy',
orchestrator: 'Orchestrator',
jobengine: 'JobEngine',
integrations: 'Integrations',
vex: 'VEX',
scanner: 'Scanner',

View File

@@ -532,7 +532,7 @@ export class BundleBuilderComponent implements OnInit {
next: (version) => {
const bundleId = version.bundleId;
this.submitMessage.set(`Bundle version v${version.versionNumber} created.`);
this.router.navigate(['/release-control/bundles', bundleId, version.id]);
this.router.navigate(['/releases/bundles', bundleId, version.id]);
},
error: () => {
this.submitError.set('Failed to create bundle version via release-control endpoints.');

View File

@@ -94,7 +94,7 @@ import {
<p class="bvd__empty">
Published: {{ formatDateTime(versionDetailModel.publishedAt ?? versionDetailModel.createdAt) }}
</p>
<a routerLink="/release-control/approvals" class="bvd__link">Open approvals queue</a>
<a routerLink="/releases/approvals" class="bvd__link">Open approvals queue</a>
</section>
}
@@ -120,8 +120,8 @@ import {
@if (materializeError(); as materializeError) {
<p class="bvd__error">{{ materializeError }}</p>
}
<a routerLink="/release-control/releases" class="bvd__link">View all releases</a>
<a routerLink="/release-control/promotions/create" class="bvd__link">Create promotion from this version</a>
<a routerLink="/releases/deployments" class="bvd__link">View all releases</a>
<a routerLink="/releases/approvals" class="bvd__link">Create promotion from this version</a>
</section>
}
}

View File

@@ -49,8 +49,8 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
</p>
</div>
<div class="dashboard__actions">
<a routerLink="/release-control/releases" class="btn btn--secondary">Releases</a>
<a routerLink="/release-control/approvals" class="btn btn--primary">Approvals</a>
<a routerLink="/releases/deployments" class="btn btn--secondary">Releases</a>
<a routerLink="/releases/approvals" class="btn btn--primary">Approvals</a>
</div>
</header>
@@ -122,7 +122,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
@for (approval of pendingApprovals(); track approval.id) {
<li class="card__item">
<div class="card__item-header">
<a [routerLink]="['/release-control/approvals', approval.id]" class="card__item-link">
<a [routerLink]="['/releases/approvals', approval.id]" class="card__item-link">
{{ approval.releaseName }} {{ approval.releaseVersion }}
</a>
<span class="card__urgency" [class]="'card__urgency--' + approval.urgency">
@@ -135,7 +135,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
</ul>
}
<div class="card__actions">
<a routerLink="/release-control/approvals" class="btn btn--small">View All</a>
<a routerLink="/releases/approvals" class="btn btn--small">View All</a>
</div>
</section>
@@ -187,7 +187,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
@for (release of recentReleases(); track release.id) {
<tr>
<td>
<a [routerLink]="['/release-control/releases', release.id]">{{ release.name }}</a>
<a [routerLink]="['/releases/deployments', release.id]">{{ release.name }}</a>
</td>
<td>{{ release.version }}</td>
<td>
@@ -199,7 +199,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
<td>{{ release.componentCount }}</td>
<td>
@if (release.status === 'ready' || release.status === 'promoting') {
<a [routerLink]="['/release-control/approvals']" [queryParams]="{ releaseId: release.id }" class="btn btn--small btn--primary">
<a [routerLink]="['/releases/approvals']" [queryParams]="{ releaseId: release.id }" class="btn btn--small btn--primary">
Review
</a>
}

View File

@@ -20,7 +20,7 @@ import {
<div class="entry-detail-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/orchestrator/dead-letter" class="back-link">&larr; Back to Queue</a>
<a routerLink="/ops/jobengine/dead-letter" class="back-link">&larr; Back to Queue</a>
<h1>Dead-Letter Entry</h1>
@if (entry()) {
<p class="entry-id">{{ entry()?.id }}</p>
@@ -75,7 +75,7 @@ import {
}
@if (entry()?.state === 'replayed') {
<span class="status-detail">
New Job: <a [href]="'/platform-ops/orchestrator/jobs/' + entry()?.replayedJobId">{{ entry()?.replayedJobId }}</a>
New Job: <a [href]="'/platform-ops/jobengine/jobs/' + entry()?.replayedJobId">{{ entry()?.replayedJobId }}</a>
</span>
}
</div>
@@ -249,7 +249,7 @@ import {
@if (!loading() && !entry()) {
<div class="empty-state">
<p>Entry not found</p>
<a routerLink="/ops/orchestrator/dead-letter">Return to queue</a>
<a routerLink="/ops/jobengine/dead-letter">Return to queue</a>
</div>
}
@@ -654,7 +654,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy {
next: (response) => {
this.hideReplayDialog();
if (response.success && response.newJobId) {
window.location.href = `/platform-ops/orchestrator/jobs/${response.newJobId}`;
window.location.href = `/platform-ops/jobengine/jobs/${response.newJobId}`;
}
},
});

View File

@@ -20,7 +20,7 @@ import {
<div class="queue-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/orchestrator/dead-letter" class="back-link">&larr; Back to Dashboard</a>
<a routerLink="/ops/jobengine/dead-letter" class="back-link">&larr; Back to Dashboard</a>
<h1>Dead-Letter Queue</h1>
<p class="subtitle">Full queue browser with advanced filtering</p>
</div>

View File

@@ -116,11 +116,11 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
<section class="shortcuts-section" aria-label="Evidence home shortcuts">
<h2 class="section-title">Shortcuts</h2>
<div class="shortcut-links">
<a routerLink="/evidence-audit/evidence" class="shortcut-link">Export Center</a>
<a routerLink="/evidence-audit/bundles" class="shortcut-link">Evidence Bundles</a>
<a routerLink="/evidence-audit/replay" class="shortcut-link">Replay &amp; Verify</a>
<a routerLink="/evidence-audit/proofs" class="shortcut-link">Proof Chains</a>
<a routerLink="/evidence-audit/trust-signing" class="shortcut-link">Trust &amp; Signing</a>
<a routerLink="/evidence/exports" class="shortcut-link">Export Center</a>
<a routerLink="/releases/bundles" class="shortcut-link">Evidence Bundles</a>
<a routerLink="/evidence/verify-replay" class="shortcut-link">Replay &amp; Verify</a>
<a routerLink="/evidence/capsules" class="shortcut-link">Proof Chains</a>
<a routerLink="/administration/trust-signing" class="shortcut-link">Trust &amp; Signing</a>
</div>
</section>
@@ -150,7 +150,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
<section class="cross-links" aria-label="Related domain links">
<h2 class="section-title">Related Domains</h2>
<div class="cross-links-grid">
<a routerLink="/release-control" class="cross-link">
<a routerLink="/releases/runs" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9654;</span>
<div class="cross-link-body">
<div class="cross-link-title">Release Control</div>
@@ -158,7 +158,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
</div>
</a>
<a routerLink="/evidence-audit/trust-signing" class="cross-link">
<a routerLink="/administration/trust-signing" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9632;</span>
<div class="cross-link-body">
<div class="cross-link-title">Evidence &amp; Audit &gt; Trust &amp; Signing</div>
@@ -174,7 +174,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
</div>
</a>
<a routerLink="/security-risk/findings" class="cross-link">
<a routerLink="/security/findings" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9679;</span>
<div class="cross-link-body">
<div class="cross-link-title">Security &amp; Risk &gt; Findings</div>
@@ -194,7 +194,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Trust and signing operations are available at
<a routerLink="/evidence-audit/trust-signing">Evidence &gt; Trust &amp; Signing</a>
<a routerLink="/administration/trust-signing">Evidence &gt; Trust &amp; Signing</a>
with permanent aliases from legacy settings/admin paths.
</aside>
</div>

View File

@@ -209,9 +209,21 @@ export class FindingsContainerComponent implements OnInit {
// Delta summary for diff view
readonly deltaSummary = signal<{ added: number; removed: number; changed: number } | null>(null);
// Current scan ID from route
// Current scan ID resolved from route param, query param, or deterministic fallback.
private readonly scanId = toSignal(
this.route.paramMap.pipe(map(params => params.get('scanId')))
this.route.paramMap.pipe(
map((params) => params.get('scanId')),
switchMap((paramScanId) => {
if (paramScanId && paramScanId.trim()) {
return of(paramScanId.trim());
}
return this.route.queryParamMap.pipe(
map((query) => query.get('scanId')?.trim() || 'active-scan')
);
})
),
{ initialValue: 'active-scan' }
);
ngOnInit(): void {

View File

@@ -247,6 +247,25 @@
/>
}
@if (activePopoverId()) {
<section class="score-history-panel" data-testid="score-history-panel">
<h3 class="score-history-title">Score History</h3>
@if (historyLoading()) {
<p class="score-history-loading">Loading score history...</p>
} @else if (scoreHistoryError()) {
<p class="score-history-error">{{ scoreHistoryError() }}</p>
} @else if (scoreHistory().length > 0) {
<stella-score-history-chart
data-testid="score-history-chart"
[history]="scoreHistory()"
[height]="180"
[showRangeSelector]="false" />
} @else {
<p class="score-history-empty">No score history available.</p>
}
</section>
}
<!-- Trust breakdown popover -->
@if (activeTrustStatus(); as trustStatus) {
<stella-vex-trust-popover

View File

@@ -419,6 +419,37 @@
}
}
.score-history-panel {
margin: var(--space-3) var(--space-4) var(--space-4);
padding: var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
}
.score-history-title {
margin: 0 0 var(--space-2);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.score-history-loading,
.score-history-empty,
.score-history-error {
margin: 0;
font-size: var(--font-size-sm);
}
.score-history-loading,
.score-history-empty {
color: var(--color-text-secondary);
}
.score-history-error {
color: var(--color-status-error);
}
// Responsive - Tablet
@include screen-below-md {
.filters-row {

View File

@@ -10,8 +10,10 @@ import {
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EvidenceWeightedScoreResult,
ScoreHistoryEntry,
ScoreBucket,
ScoreFlag,
BUCKET_DISPLAY,
@@ -22,6 +24,7 @@ import {
ScorePillComponent,
ScoreBadgeComponent,
ScoreBreakdownPopoverComponent,
ScoreHistoryChartComponent,
} from '../../shared/components/score';
import { ExportAuditPackButtonComponent } from '../../shared/components/audit-pack';
import { VexTrustChipComponent, VexTrustPopoverComponent, TrustChipPopoverEvent } from '../../shared/components';
@@ -107,6 +110,7 @@ export interface FindingsFilter {
ScorePillComponent,
ScoreBadgeComponent,
ScoreBreakdownPopoverComponent,
ScoreHistoryChartComponent,
ExportAuditPackButtonComponent,
VexTrustChipComponent,
VexTrustPopoverComponent,
@@ -154,6 +158,15 @@ export class FindingsListComponent {
/** Popover anchor element */
readonly popoverAnchor = signal<HTMLElement | null>(null);
/** Score history for the active finding */
readonly scoreHistory = signal<ScoreHistoryEntry[]>([]);
/** Loading state for score history panel */
readonly historyLoading = signal(false);
/** Score history fetch error */
readonly scoreHistoryError = signal<string | null>(null);
/** Bucket options for filter dropdown */
readonly bucketOptions = BUCKET_DISPLAY;
@@ -445,10 +458,14 @@ export class FindingsListComponent {
// Toggle off
this.activePopoverId.set(null);
this.popoverAnchor.set(null);
this.scoreHistory.set([]);
this.scoreHistoryError.set(null);
this.historyLoading.set(false);
} else {
// Show popover
this.activePopoverId.set(finding.id);
this.popoverAnchor.set(anchor);
void this.loadScoreHistory(finding.id);
}
}
@@ -456,6 +473,9 @@ export class FindingsListComponent {
closePopover(): void {
this.activePopoverId.set(null);
this.popoverAnchor.set(null);
this.scoreHistory.set([]);
this.scoreHistoryError.set(null);
this.historyLoading.set(false);
}
/** Handle trust chip click - show trust popover */
@@ -521,4 +541,21 @@ export class FindingsListComponent {
hasHardFailStatus(finding: ScoredFinding): boolean {
return finding.score?.isHardFail === true;
}
private async loadScoreHistory(findingId: string): Promise<void> {
this.historyLoading.set(true);
this.scoreHistoryError.set(null);
try {
const result = await firstValueFrom(this.scoringService.getScoreHistory(findingId, { limit: 20 }));
const history = [...result.history].sort((a, b) =>
new Date(a.calculatedAt).getTime() - new Date(b.calculatedAt).getTime());
this.scoreHistory.set(history);
} catch (error) {
this.scoreHistory.set([]);
this.scoreHistoryError.set(error instanceof Error ? error.message : 'Failed to load score history');
} finally {
this.historyLoading.set(false);
}
}
}

View File

@@ -5,32 +5,32 @@ import { RouterLink } from '@angular/router';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
/**
* Orchestrator Dashboard - Main landing page for Orchestrator features.
* JobEngine Dashboard - Main landing page for JobEngine features.
* Requires orch:read scope for access (gated by requireOrchViewerGuard).
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-dashboard',
selector: 'app-jobengine-dashboard',
imports: [RouterLink],
template: `
<div class="orch-dashboard">
<header class="orch-dashboard__header">
<h1 class="orch-dashboard__title">Orchestrator Dashboard</h1>
<h1 class="orch-dashboard__title">JobEngine Dashboard</h1>
<p class="orch-dashboard__description">
Monitor and manage orchestrated jobs, quotas, and backfill operations.
</p>
</header>
<nav class="orch-dashboard__nav">
<a routerLink="/platform-ops/orchestrator/jobs" class="orch-dashboard__card">
<a routerLink="/platform-ops/jobengine/jobs" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/><line x1="8" y1="18" x2="12" y2="18"/></svg></span>
<span class="orch-dashboard__card-title">Jobs</span>
<span class="orch-dashboard__card-desc">View job status and history</span>
</a>
@if (authService.canOperateOrchestrator()) {
<a routerLink="/platform-ops/orchestrator/quotas" class="orch-dashboard__card">
<a routerLink="/platform-ops/jobengine/quotas" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><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 4.68 15a1.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 9 4.68a1.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"/></svg></span>
<span class="orch-dashboard__card-title">Quotas</span>
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
@@ -39,7 +39,7 @@ import { AUTH_SERVICE, AuthService } from '../../core/auth';
</nav>
<section class="orch-dashboard__scope-info">
<h2>Your Orchestrator Access</h2>
<h2>Your JobEngine Access</h2>
<ul>
<li>
<strong>View Jobs:</strong>
@@ -51,7 +51,7 @@ import { AUTH_SERVICE, AuthService } from '../../core/auth';
</li>
<li>
<strong>Manage Quotas:</strong>
{{ authService.canManageOrchestratorQuotas() ? 'Granted' : 'Denied' }}
{{ authService.canManageJobEngineQuotas() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Initiate Backfill:</strong>
@@ -167,6 +167,6 @@ import { AUTH_SERVICE, AuthService } from '../../core/auth';
}
`]
})
export class OrchestratorDashboardComponent {
export class JobEngineDashboardComponent {
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
}

View File

@@ -3,18 +3,18 @@ import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Job Detail - Shows details for a specific job.
* JobEngine Job Detail - Shows details for a specific job.
* Requires orch:read scope for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-job-detail',
selector: 'app-jobengine-job-detail',
imports: [RouterLink],
template: `
<div class="orch-job-detail">
<header class="orch-job-detail__header">
<a routerLink="/platform-ops/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<a routerLink="/platform-ops/jobengine/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
<div class="orch-job-detail__actions">
@@ -29,7 +29,7 @@ import { RouterLink } from '@angular/router';
</header>
<div class="orch-job-detail__placeholder">
<p>Job details will be implemented when Orchestrator API contract is finalized.</p>
<p>Job details will be implemented when JobEngine API contract is finalized.</p>
<p class="orch-job-detail__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
</div>
@@ -120,6 +120,6 @@ import { RouterLink } from '@angular/router';
}
`]
})
export class OrchestratorJobDetailComponent {
export class JobEngineJobDetailComponent {
@Input() jobId: string = '';
}

View File

@@ -9,9 +9,9 @@ import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Job Models
* JobEngine Job Models
*/
interface OrchestratorJob {
interface JobEngineJob {
id: string;
type: string;
name: string;
@@ -31,18 +31,18 @@ interface OrchestratorJob {
}
/**
* Orchestrator Jobs List - Shows all orchestrator jobs.
* JobEngine Jobs List - Shows all orchestrator jobs.
* Requires orch:read scope for access.
* (Sprint: SPRINT_20251229_017)
*/
@Component({
selector: 'app-orchestrator-jobs',
selector: 'app-jobengine-jobs',
imports: [FormsModule, RouterLink],
template: `
<div class="orch-jobs">
<header class="orch-jobs__header">
<a routerLink="/orchestrator" class="orch-jobs__back">&larr; Back to Dashboard</a>
<h1 class="orch-jobs__title">Orchestrator Jobs</h1>
<a routerLink="/jobengine" class="orch-jobs__back">&larr; Back to Dashboard</a>
<h1 class="orch-jobs__title">JobEngine Jobs</h1>
<p class="orch-jobs__subtitle">Monitor and manage job execution across the cluster.</p>
</header>
@@ -150,7 +150,7 @@ interface OrchestratorJob {
@if (job.parentJobId) {
<div class="job-parent">
<span class="label">Parent Job:</span>
<a [routerLink]="['/platform-ops/orchestrator/jobs', job.parentJobId]">
<a [routerLink]="['/platform-ops/jobengine/jobs', job.parentJobId]">
{{ job.parentJobId }}
</a>
</div>
@@ -161,7 +161,7 @@ interface OrchestratorJob {
<span class="label">Child Jobs ({{ job.childJobIds.length }}):</span>
<div class="children-list">
@for (childId of job.childJobIds; track childId) {
<a [routerLink]="['/platform-ops/orchestrator/jobs', childId]">{{ childId }}</a>
<a [routerLink]="['/platform-ops/jobengine/jobs', childId]">{{ childId }}</a>
}
</div>
</div>
@@ -190,7 +190,7 @@ interface OrchestratorJob {
}
<a
class="btn btn-secondary"
[routerLink]="['/platform-ops/orchestrator/jobs', job.id]"
[routerLink]="['/platform-ops/jobengine/jobs', job.id]"
(click)="$event.stopPropagation()"
>
View Details
@@ -198,7 +198,7 @@ interface OrchestratorJob {
@if (job.childJobIds.length > 0) {
<a
class="btn btn-secondary"
[routerLink]="['/platform-ops/orchestrator/jobs', job.id, 'dag']"
[routerLink]="['/platform-ops/jobengine/jobs', job.id, 'dag']"
(click)="$event.stopPropagation()"
>
View DAG
@@ -217,7 +217,7 @@ interface OrchestratorJob {
<!-- Dead Letter Link -->
<div class="dead-letter-link">
<a routerLink="/orchestrator/dead-letter">
<a routerLink="/jobengine/dead-letter">
View Dead Letter Queue ({{ stats().deadLetter }} jobs)
</a>
</div>
@@ -543,14 +543,14 @@ interface OrchestratorJob {
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OrchestratorJobsComponent {
export class JobEngineJobsComponent {
searchQuery = '';
statusFilter = '';
typeFilter = '';
readonly expandedJob = signal<string | null>(null);
readonly jobs = signal<OrchestratorJob[]>([
readonly jobs = signal<JobEngineJob[]>([
{
id: 'job-001',
type: 'scan',
@@ -664,7 +664,7 @@ export class OrchestratorJobsComponent {
this.expandedJob.set(this.expandedJob() === jobId ? null : jobId);
}
cancelJob(job: OrchestratorJob): void {
cancelJob(job: JobEngineJob): void {
if (confirm(`Cancel job "${job.name}"?`)) {
this.jobs.update(jobs =>
jobs.map(j =>
@@ -674,7 +674,7 @@ export class OrchestratorJobsComponent {
}
}
retryJob(job: OrchestratorJob): void {
retryJob(job: JobEngineJob): void {
this.jobs.update(jobs =>
jobs.map(j =>
j.id === job.id

View File

@@ -3,23 +3,23 @@ import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Quotas Management - Manage resource quotas.
* JobEngine Quotas Management - Manage resource quotas.
* Requires orch:read + orch:operate scopes for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-quotas',
selector: 'app-jobengine-quotas',
imports: [RouterLink],
template: `
<div class="orch-quotas">
<header class="orch-quotas__header">
<a routerLink="/orchestrator" class="orch-quotas__back">&larr; Back to Dashboard</a>
<h1 class="orch-quotas__title">Orchestrator Quotas</h1>
<a routerLink="/jobengine" class="orch-quotas__back">&larr; Back to Dashboard</a>
<h1 class="orch-quotas__title">JobEngine Quotas</h1>
</header>
<div class="orch-quotas__placeholder">
<p>Quota management will be implemented when Orchestrator API contract is finalized.</p>
<p>Quota management will be implemented when JobEngine API contract is finalized.</p>
<p class="orch-quotas__hint">
This page requires the <code>orch:read</code> and <code>orch:operate</code> scopes.
</p>
@@ -82,4 +82,4 @@ import { RouterLink } from '@angular/router';
}
`]
})
export class OrchestratorQuotasComponent {}
export class JobEngineQuotasComponent {}

View File

@@ -7,40 +7,40 @@ export const OPERATIONS_ROUTES: Routes = [
path: '',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
import('../jobengine/jobengine-dashboard.component').then(
(m) => m.JobEngineDashboardComponent
),
},
{
path: 'orchestrator',
path: 'jobengine',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
import('../jobengine/jobengine-dashboard.component').then(
(m) => m.JobEngineDashboardComponent
),
},
{
path: 'orchestrator/jobs',
path: 'jobengine/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
import('../jobengine/jobengine-jobs.component').then(
(m) => m.JobEngineJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
import('../jobengine/jobengine-job-detail.component').then(
(m) => m.JobEngineJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
path: 'jobengine/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('../orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
import('../jobengine/jobengine-quotas.component').then(
(m) => m.JobEngineQuotasComponent
),
},
{

View File

@@ -47,7 +47,7 @@ interface AffectedItem {
<a routerLink="/releases/approvals">Open impacted approvals</a>
<a routerLink="/releases/versions">Open bundles</a>
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/platform/ops/orchestrator/jobs">Open logs</a>
<a routerLink="/platform/ops/jobengine/jobs">Open logs</a>
</footer>
</div>
`,

View File

@@ -69,7 +69,7 @@ interface NightlyJobRow {
<td class="actions">
<a [routerLink]="['/platform/ops/data-integrity/nightly-ops', row.runId]">View Run</a>
<a routerLink="/platform/ops/scheduler/runs">Open Scheduler</a>
<a routerLink="/platform/ops/orchestrator/jobs">Open Orchestrator</a>
<a routerLink="/platform/ops/jobengine/jobs">Open JobEngine</a>
<a routerLink="/platform/integrations">Open Integration</a>
<a routerLink="/platform/ops/dead-letter">Open DLQ</a>
</td>

View File

@@ -45,7 +45,7 @@ interface Stage {
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/platform/integrations">Integrations</a>
<a routerLink="/security-risk/findings">Security Findings</a>
<a routerLink="/security/findings">Security Findings</a>
</footer>
</div>
`,

View File

@@ -113,10 +113,10 @@ interface OpsCard {
export class PlatformOpsOverviewComponent {
readonly cards: OpsCard[] = [
{
id: 'orchestrator',
title: 'Orchestrator',
id: 'jobengine',
title: 'JobEngine',
description: 'Job execution, queue management, and operational controls.',
route: '/platform-ops/orchestrator',
route: '/platform-ops/jobengine',
icon: '⚡',
},
{

View File

@@ -18,12 +18,11 @@ import {
} from '../../../../core/api/policy-gates.models';
import { POLICY_GATES_API } from '../../../../core/api/policy-gates.client';
import { ProfileSelectorComponent } from '../profile-selector/profile-selector.component';
import { GateSimulationResultsComponent } from '../gate-simulation-results/gate-simulation-results.component';
@Component({
selector: 'app-bundle-simulator',
standalone: true,
imports: [ProfileSelectorComponent, GateSimulationResultsComponent],
imports: [ProfileSelectorComponent],
template: `
<div class="bundle-simulator">
<div class="simulator-header">

View File

@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
/**
* Policy Governance main component with tabbed navigation.
@@ -10,41 +10,39 @@ import { RouterModule } from '@angular/router';
*/
@Component({
selector: 'app-policy-governance',
imports: [RouterModule],
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance">
<header class="governance__header">
<div class="governance__title-group">
<p class="governance__eyebrow">Admin / Policy</p>
<h1 class="governance__title">Policy Governance</h1>
<p class="governance__subtitle">Configure risk budgets, trust weights, staleness rules, sealed mode, and risk profiles.</p>
</div>
</header>
<div class="governance__header">
<p class="governance__eyebrow">Admin / Policy</p>
<h1 class="governance__title">Policy Governance</h1>
<p class="governance__subtitle">Configure risk budgets, trust weights, staleness rules, sealed mode, and risk profiles.</p>
</div>
<nav class="governance__tabs" role="tablist" aria-label="Policy governance sections">
@for (tab of tabs; track tab.id) {
<a
class="governance__tab"
[class.governance__tab--active]="activeTab() === tab.id"
[routerLink]="tab.route"
routerLinkActive="governance__tab--active"
[routerLinkActiveOptions]="{ exact: tab.exact }"
role="tab"
[attr.aria-selected]="activeTab() === tab.id"
(click)="setActiveTab(tab.id)"
>
<span class="governance__tab-icon" [innerHTML]="tab.icon"></span>
<a class="governance__tab"
[routerLink]="tab.route"
routerLinkActive="governance__tab--active"
[routerLinkActiveOptions]="{ exact: tab.exact }"
role="tab">
<span class="governance__tab-label">{{ tab.label }}</span>
@if (tab.badge) {
<span class="governance__tab-badge" [class]="'governance__tab-badge--' + tab.badgeType">{{ tab.badge }}</span>
<span class="governance__tab-badge"
[class.governance__tab-badge--warning]="tab.badgeType === 'warning'"
[class.governance__tab-badge--error]="tab.badgeType === 'error'"
[class.governance__tab-badge--info]="tab.badgeType === 'info'">
{{ tab.badge }}
</span>
}
</a>
}
</nav>
<div class="governance__content">
<router-outlet></router-outlet>
<router-outlet />
</div>
</section>
`,
@@ -135,19 +133,6 @@ import { RouterModule } from '@angular/router';
border-bottom-color: var(--color-status-info);
}
.governance__tab-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.governance__tab-icon :deep(svg) {
width: 18px;
height: 18px;
}
.governance__tab-label {
font-weight: var(--font-weight-medium);
}
@@ -185,102 +170,16 @@ import { RouterModule } from '@angular/router';
`]
})
export class PolicyGovernanceComponent {
protected readonly activeTab = signal<string>('budget');
protected readonly tabs = [
{
id: 'budget',
label: 'Risk Budget',
route: './budget',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'trust',
label: 'Trust Weights',
route: './trust-weights',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'staleness',
label: 'Staleness',
route: './staleness',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'sealed',
label: 'Sealed Mode',
route: './sealed-mode',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'profiles',
label: 'Profiles',
route: './profiles',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'validator',
label: 'Validator',
route: './validator',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'audit',
label: 'Audit Log',
route: './audit',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'conflicts',
label: 'Conflicts',
route: './conflicts',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>`,
badge: '2',
badgeType: 'warning',
},
{
id: 'schema-playground',
label: 'Playground',
route: './schema-playground',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" /></svg>`,
badge: null,
badgeType: null,
},
{
id: 'schema-docs',
label: 'Docs',
route: './schema-docs',
exact: false,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>`,
badge: null,
badgeType: null,
},
{ id: 'budget', label: 'Risk Budget', route: './', exact: true, badge: null, badgeType: null },
{ id: 'trust', label: 'Trust Weights', route: './trust-weights', exact: false, badge: null, badgeType: null },
{ id: 'staleness', label: 'Staleness', route: './staleness', exact: false, badge: null, badgeType: null },
{ id: 'sealed', label: 'Sealed Mode', route: './sealed-mode', exact: false, badge: null, badgeType: null },
{ id: 'profiles', label: 'Profiles', route: './profiles', exact: false, badge: null, badgeType: null },
{ id: 'validator', label: 'Validator', route: './validator', exact: false, badge: null, badgeType: null },
{ id: 'audit', label: 'Audit Log', route: './audit', exact: false, badge: null, badgeType: null },
{ id: 'conflicts', label: 'Conflicts', route: './conflicts', exact: false, badge: '2', badgeType: 'warning' },
{ id: 'schema-playground', label: 'Playground', route: './schema-playground', exact: false, badge: null, badgeType: null },
{ id: 'schema-docs', label: 'Docs', route: './schema-docs', exact: false, badge: null, badgeType: null },
];
protected setActiveTab(tabId: string): void {
this.activeTab.set(tabId);
}
}

View File

@@ -1,20 +1,7 @@
import { Routes } from '@angular/router';
export const POLICY_ROUTES: Routes = [
{
path: '',
title: 'Policy Overview',
data: { breadcrumb: 'Policy Overview' },
loadComponent: () =>
import('../policy-governance/policy-governance.component').then((m) => m.PolicyGovernanceComponent),
},
{
path: 'overview',
title: 'Policy Overview',
data: { breadcrumb: 'Overview' },
loadComponent: () =>
import('../policy-governance/policy-governance.component').then((m) => m.PolicyGovernanceComponent),
},
// Standalone policy views (matched first, outside governance tabs)
{
path: 'baselines',
title: 'Baselines',
@@ -43,52 +30,103 @@ export const POLICY_ROUTES: Routes = [
import('../security/exceptions-page.component').then((m) => m.ExceptionsPageComponent),
},
{
path: 'risk-budget',
title: 'Risk Budget',
data: { breadcrumb: 'Risk Budget' },
loadComponent: () =>
import('../policy-governance/risk-budget-dashboard.component').then((m) => m.RiskBudgetDashboardComponent),
path: 'overview',
redirectTo: '',
pathMatch: 'full',
},
// Policy Governance tabbed layout (catches remaining paths including root)
{
path: 'trust-weights',
title: 'Trust Weights',
data: { breadcrumb: 'Trust Weights' },
path: '',
title: 'Policy Overview',
data: { breadcrumb: 'Policy Overview' },
loadComponent: () =>
import('../policy-governance/trust-weighting.component').then((m) => m.TrustWeightingComponent),
},
{
path: 'staleness',
title: 'Staleness Rules',
data: { breadcrumb: 'Staleness Rules' },
loadComponent: () =>
import('../policy-governance/staleness-config.component').then((m) => m.StalenessConfigComponent),
},
{
path: 'sealed-mode',
title: 'Sealed Mode',
data: { breadcrumb: 'Sealed Mode' },
loadComponent: () =>
import('../policy-governance/sealed-mode-control.component').then((m) => m.SealedModeControlComponent),
},
{
path: 'profiles',
title: 'Profiles',
data: { breadcrumb: 'Profiles' },
loadComponent: () =>
import('../policy-governance/risk-profile-list.component').then((m) => m.RiskProfileListComponent),
},
{
path: 'validator',
title: 'Policy Validator',
data: { breadcrumb: 'Validator' },
loadComponent: () =>
import('../policy-governance/policy-validator.component').then((m) => m.PolicyValidatorComponent),
},
{
path: 'audit',
title: 'Policy Audit',
data: { breadcrumb: 'Policy Audit' },
loadComponent: () =>
import('../policy-governance/governance-audit.component').then((m) => m.GovernanceAuditComponent),
import('../policy-governance/policy-governance.component').then((m) => m.PolicyGovernanceComponent),
children: [
{
path: '',
loadComponent: () =>
import('../policy-governance/risk-budget-dashboard.component').then((m) => m.RiskBudgetDashboardComponent),
},
{
path: 'budget',
loadComponent: () =>
import('../policy-governance/risk-budget-dashboard.component').then((m) => m.RiskBudgetDashboardComponent),
},
{
path: 'budget/config',
loadComponent: () =>
import('../policy-governance/risk-budget-config.component').then((m) => m.RiskBudgetConfigComponent),
},
{
path: 'trust-weights',
loadComponent: () =>
import('../policy-governance/trust-weighting.component').then((m) => m.TrustWeightingComponent),
},
{
path: 'staleness',
loadComponent: () =>
import('../policy-governance/staleness-config.component').then((m) => m.StalenessConfigComponent),
},
{
path: 'sealed-mode',
loadComponent: () =>
import('../policy-governance/sealed-mode-control.component').then((m) => m.SealedModeControlComponent),
},
{
path: 'sealed-mode/overrides',
loadComponent: () =>
import('../policy-governance/sealed-mode-overrides.component').then((m) => m.SealedModeOverridesComponent),
},
{
path: 'profiles',
loadComponent: () =>
import('../policy-governance/risk-profile-list.component').then((m) => m.RiskProfileListComponent),
},
{
path: 'profiles/new',
loadComponent: () =>
import('../policy-governance/risk-profile-editor.component').then((m) => m.RiskProfileEditorComponent),
},
{
path: 'profiles/:profileId',
loadComponent: () =>
import('../policy-governance/risk-profile-editor.component').then((m) => m.RiskProfileEditorComponent),
},
{
path: 'validator',
loadComponent: () =>
import('../policy-governance/policy-validator.component').then((m) => m.PolicyValidatorComponent),
},
{
path: 'audit',
loadComponent: () =>
import('../policy-governance/governance-audit.component').then((m) => m.GovernanceAuditComponent),
},
{
path: 'conflicts',
loadComponent: () =>
import('../policy-governance/policy-conflict-dashboard.component').then((m) => m.PolicyConflictDashboardComponent),
},
{
path: 'conflicts/:conflictId/resolve',
loadComponent: () =>
import('../policy-governance/conflict-resolution-wizard.component').then((m) => m.ConflictResolutionWizardComponent),
},
{
path: 'impact-preview',
loadComponent: () =>
import('../policy-governance/impact-preview.component').then((m) => m.ImpactPreviewComponent),
},
{
path: 'schema-playground',
loadComponent: () =>
import('../policy-governance/schema-playground.component').then((m) => m.SchemaPlaygroundComponent),
},
{
path: 'schema-docs',
loadComponent: () =>
import('../policy-governance/schema-docs.component').then((m) => m.SchemaDocsComponent),
},
],
},
];

View File

@@ -107,7 +107,7 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
<strong>{{ materializationState().title }}</strong>
<p>{{ materializationState().message }}</p>
</div>
<a routerLink="/release-control/setup/environments-paths" class="link-sm">
<a routerLink="/setup/topology/environments" class="link-sm">
Open Release Control setup for inputs and paths ->
</a>
</section>

View File

@@ -166,7 +166,7 @@ type DetailTab =
<div><strong>Passing gates:</strong> {{ gateStatusCounts().passed }}</div>
<div><strong>Target env:</strong> {{ promotion()!.targetEnvironment }}</div>
</div>
<a routerLink="/security-risk/findings" [queryParams]="{ env: promotion()!.targetEnvironment }" class="link-sm">
<a routerLink="/security/findings" [queryParams]="{ env: promotion()!.targetEnvironment }" class="link-sm">
Open findings for target environment ->
</a>
</section>
@@ -180,7 +180,7 @@ type DetailTab =
<div class="contract-gap-row">
<span class="contract-gap">Contract gap: hybrid B/I/R coverage fields are missing.</span>
</div>
<a routerLink="/security-risk/reachability" class="link-sm">Open reachability center -></a>
<a routerLink="/security/lineage" class="link-sm">Open reachability center -></a>
</section>
}
@case ('ops-data') {
@@ -202,14 +202,14 @@ type DetailTab =
<p>
Evidence packet identifiers are not provided in this contract; use canonical Evidence and Audit surfaces for promotion-linked retrieval.
</p>
<a routerLink="/evidence-audit" class="link-sm">Open Evidence and Audit -></a>
<a routerLink="/evidence/overview" class="link-sm">Open Evidence and Audit -></a>
</section>
}
@case ('replay') {
<section class="panel" aria-label="Replay and verify">
<h2>Replay / Verify Decision</h2>
<p>Replay and verification are delegated to Evidence and Audit.</p>
<a routerLink="/evidence-audit/replay" class="link-sm">Open replay and verify -></a>
<a routerLink="/evidence/verify-replay" class="link-sm">Open replay and verify -></a>
</section>
}
@case ('history') {

View File

@@ -14,19 +14,19 @@ import { RouterLink } from '@angular/router';
</header>
<div class="cards">
<a routerLink="/release-control/governance/baselines" class="card">
<a routerLink="/ops/policy/baselines" class="card">
<h2>Policy Baselines</h2>
<p>Environment-scoped baseline definitions and lock rules.</p>
</a>
<a routerLink="/release-control/governance/rules" class="card">
<a routerLink="/ops/policy/rules" class="card">
<h2>Governance Rules</h2>
<p>Rule catalog for release control gate enforcement.</p>
</a>
<a routerLink="/release-control/governance/simulation" class="card">
<a routerLink="/ops/policy/simulation" class="card">
<h2>Policy Simulation</h2>
<p>Dry-run policy evaluations before production rollout.</p>
</a>
<a routerLink="/release-control/governance/exceptions" class="card">
<a routerLink="/ops/policy/exceptions" class="card">
<h2>Exception Workflow</h2>
<p>Exception requests, approvals, and expiry management.</p>
</a>

View File

@@ -14,7 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
</header>
<p class="note">Canonical location: Release Control &gt; Governance.</p>
<a routerLink="/release-control/governance">Back to Governance Hub</a>
<a routerLink="/ops/policy">Back to Governance Hub</a>
</section>
`,
styles: [

View File

@@ -36,7 +36,7 @@ interface EnvironmentNode {
<section class="pipeline">
@for (env of environments; track env.id) {
<a class="pipeline-node" [routerLink]="['/release-control/regions', regionLabel(), 'environments', env.id]">
<a class="pipeline-node" [routerLink]="['/releases/environments', regionLabel(), 'environments', env.id]">
<h2>{{ env.id }}</h2>
<p>{{ env.stage }}</p>
<p>Status: {{ env.status }}</p>

View File

@@ -23,7 +23,7 @@ interface RegionCard {
<div class="cards">
@for (region of regions; track region.id) {
<a class="card" [routerLink]="['/release-control/regions', region.id]">
<a class="card" [routerLink]="['/releases/environments', region.id]">
<h2>{{ region.name }}</h2>
<p>Environments: {{ region.envCount }}</p>
<p>Health: {{ region.health }}</p>

View File

@@ -9,7 +9,7 @@ import { RouterLink } from '@angular/router';
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Bundle Templates</h1>
<p>Template presets for bundle composition, validation gates, and release metadata policy.</p>
</header>
@@ -44,8 +44,8 @@ import { RouterLink } from '@angular/router';
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/release-control/bundles/create">Open Bundle Builder</a>
<a routerLink="/release-control/bundles">Open Bundle Catalog</a>
<a routerLink="/releases/bundles/create">Open Bundle Builder</a>
<a routerLink="/releases/bundles">Open Bundle Catalog</a>
</section>
</section>
`,

View File

@@ -9,7 +9,7 @@ import { RouterLink } from '@angular/router';
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Environments and Promotion Paths</h1>
<p>Release Control-owned environment graph and allowed promotion flows.</p>
</header>
@@ -44,8 +44,8 @@ import { RouterLink } from '@angular/router';
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/release-control/environments">Open Regions and Environments</a>
<a routerLink="/release-control/promotions">Open Promotions</a>
<a routerLink="/releases/environments">Open Regions and Environments</a>
<a routerLink="/releases/approvals">Open Promotions</a>
</section>
</section>
`,

View File

@@ -9,7 +9,7 @@ import { RouterLink } from '@angular/router';
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Targets and Agents</h1>
<p>Release Control deployment execution topology with ownership split to Integrations.</p>
</header>

View File

@@ -9,7 +9,7 @@ import { RouterLink } from '@angular/router';
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Workflows</h1>
<p>Release Control workflow definitions for promotion orchestration and approval sequencing.</p>
</header>
@@ -45,7 +45,7 @@ import { RouterLink } from '@angular/router';
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/administration/workflows">Open legacy workflow editor surface</a>
<a routerLink="/release-control/runs">Open Run Timeline</a>
<a routerLink="/releases/runs">Open Run Timeline</a>
</section>
</section>
`,

View File

@@ -9,7 +9,7 @@ import { RecentReleasesComponent } from './components/recent-releases/recent-rel
import { TranslatePipe } from '../../../core/i18n';
/**
* Release Orchestrator Dashboard
* Release JobEngine Dashboard
* Main landing page showing pipeline status, pending approvals, active deployments, and recent releases.
*
* @see UI-RELEASE-ORCH-01

View File

@@ -5,7 +5,7 @@ export const DASHBOARD_ROUTES: Routes = [
{
path: '',
component: ReleaseDashboardComponent,
title: 'Release Orchestrator Dashboard',
title: 'Release JobEngine Dashboard',
},
{
path: 'environments',

View File

@@ -10,7 +10,7 @@ import type {
} from '../../../core/api/release-dashboard.models';
/**
* Signal-based store for Release Orchestrator Dashboard
* Signal-based store for Release JobEngine Dashboard
* Manages dashboard state including pipeline, approvals, deployments, and releases
*/
@Injectable({ providedIn: 'root' })

View File

@@ -74,7 +74,7 @@ interface AuditEventRow {
template: `
<div class="env-casefile">
<header class="env-header">
<a routerLink="/release-control/regions" class="back-link">Back to Regions & Environments</a>
<a routerLink="/releases/environments" class="back-link">Back to Regions & Environments</a>
<div class="header-row">
<h1>{{ regionLabel() }}/{{ envLabel() }} Environment</h1>
@@ -116,10 +116,10 @@ interface AuditEventRow {
</div>
<div class="quick-links">
<a routerLink="/release-control/bundles">Open Deployed Bundle</a>
<a routerLink="/security-risk/findings">Open Findings</a>
<a routerLink="/releases/bundles">Open Deployed Bundle</a>
<a routerLink="/security/findings">Open Findings</a>
<a [routerLink]="['/platform-ops/data-integrity']" [queryParams]="{ region: regionLabel(), env: envLabel() }">Open Data Integrity</a>
<a routerLink="/release-control/runs">Open Promotion Run</a>
<a routerLink="/releases/runs">Open Promotion Run</a>
</div>
</header>
@@ -169,7 +169,7 @@ interface AuditEventRow {
}
</ul>
<div class="footer-links">
<a routerLink="/security-risk/findings">Open Findings</a>
<a routerLink="/security/findings">Open Findings</a>
<a [routerLink]="['/platform-ops/data-integrity']" [queryParams]="{ region: regionLabel(), env: envLabel() }">
Open Data Integrity
</a>
@@ -223,7 +223,7 @@ interface AuditEventRow {
</table>
<div class="footer-links">
<a routerLink="/release-control/runs">Open last Promotion Run</a>
<a routerLink="/releases/runs">Open last Promotion Run</a>
<a routerLink="/platform-ops/agents">Open agent logs</a>
</div>
</section>
@@ -267,8 +267,8 @@ interface AuditEventRow {
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Trigger SBOM scan/rescan</a>
<a routerLink="/security-risk/findings">Open Findings</a>
<a routerLink="/security-risk/vex">Open VEX/Exceptions</a>
<a routerLink="/security/findings">Open Findings</a>
<a routerLink="/security/vex">Open VEX/Exceptions</a>
</div>
</section>
}
@@ -307,7 +307,7 @@ interface AuditEventRow {
</table>
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Open Reachability Ingest Health</a>
<a routerLink="/security-risk/findings">Open component version details</a>
<a routerLink="/security/findings">Open component version details</a>
</div>
</section>
}
@@ -357,7 +357,7 @@ interface AuditEventRow {
<ul>
<li>
Platform Bundle 1.3.0-rc1 -> prod
<a routerLink="/release-control/approvals">Open Approval</a>
<a routerLink="/releases/approvals">Open Approval</a>
</li>
</ul>
@@ -377,8 +377,8 @@ interface AuditEventRow {
<td>{{ row.bundle }}</td>
<td>{{ row.status }}</td>
<td>
<a routerLink="/release-control/runs">Open Run</a>
<a routerLink="/evidence-audit/evidence">Evidence</a>
<a routerLink="/releases/runs">Open Run</a>
<a routerLink="/evidence/overview">Evidence</a>
</td>
</tr>
}
@@ -387,8 +387,8 @@ interface AuditEventRow {
<p>Diff: proposed vs deployed indicates 2 component digest changes and 1 config snapshot delta.</p>
<div class="footer-links">
<a routerLink="/release-control/releases">Open Releases filtered to this env</a>
<a routerLink="/release-control/approvals">Open Approvals filtered to this env</a>
<a routerLink="/releases/deployments">Open Releases filtered to this env</a>
<a routerLink="/releases/approvals">Open Approvals filtered to this env</a>
</div>
</section>
}
@@ -439,7 +439,7 @@ interface AuditEventRow {
}
</ul>
<div class="footer-links">
<a routerLink="/evidence-audit/evidence/export">Open Evidence Export Center</a>
<a routerLink="/evidence/exports">Open Evidence Export Center</a>
</div>
</section>
}
@@ -780,7 +780,7 @@ export class EnvironmentDetailComponent implements OnInit, OnDestroy {
this.envType.set(this.isProductionEnv(env) ? 'Production' : 'Staging');
this.title.setTitle(`${region}/${env} Environment - StellaOps`);
this.breadcrumbService.setContextCrumbs([
{ label: region, route: `/release-control/regions/${region}` },
{ label: region, route: `/releases/environments/${region}` },
{ label: env },
]);

View File

@@ -10,7 +10,7 @@ import {
} from '../../../../core/api/release-environment.models';
/**
* Environment list component for Release Orchestrator.
* Environment list component for Release JobEngine.
* Sprint: SPRINT_20260110_111_002_FE_environment_management_ui
*/
@Component({
@@ -59,7 +59,7 @@ import {
<div class="environment-card" [class.is-production]="env.isProduction">
<div class="card-header">
<span class="order-badge">#{{ env.order }}</span>
<a [routerLink]="['/release-control/regions', 'global', 'environments', env.id]" class="env-name">
<a [routerLink]="['/releases/environments', 'global', 'environments', env.id]" class="env-name">
{{ env.displayName }}
</a>
@if (env.isProduction) {
@@ -69,8 +69,8 @@ import {
<button class="menu-btn" (click)="toggleMenu(env.id, $event)">...</button>
@if (openMenuId === env.id) {
<div class="dropdown-menu">
<a [routerLink]="['/release-control/regions', 'global', 'environments', env.id]">View Details</a>
<a [routerLink]="['/release-control/regions', 'global', 'environments', env.id, 'settings']">Settings</a>
<a [routerLink]="['/releases/environments', 'global', 'environments', env.id]">View Details</a>
<a [routerLink]="['/releases/environments', 'global', 'environments', env.id, 'settings']">Settings</a>
<hr />
<button class="danger" (click)="confirmDelete(env)">Delete</button>
</div>

View File

@@ -14,7 +14,7 @@ import type {
} from '../../../core/api/release-environment.models';
/**
* Signal-based store for Release Orchestrator Environments
* Signal-based store for Release JobEngine Environments
* Sprint: SPRINT_20260110_111_002_FE_environment_management_ui
*/
@Injectable({ providedIn: 'root' })

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router';
/**
* Environment management routes for Release Orchestrator.
* Environment management routes for Release JobEngine.
* Sprint: SPRINT_20260110_111_002_FE_environment_management_ui
* Updated: SPRINT_20260218_013_FE_ui_v2_rewire_environment_detail_standardization (E8-01 through E8-05)
* — Added canonical breadcrumbs and tab data to list and detail routes.

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router';
/**
* Release management routes for Release Orchestrator.
* Release management routes for Release JobEngine.
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
export const RELEASE_ROUTES: Routes = [

View File

@@ -48,6 +48,85 @@
<button type="button" (click)="applyFilters()">{{ 'ui.actions.refresh' | translate }}</button>
</section>
<section class="risk-dashboard__widgets">
<article class="widget" data-testid="budget-widget">
<header class="widget__header">
<h2>Risk Budget</h2>
</header>
@if (budgetError()) {
<div class="empty empty--error">{{ budgetError() }}</div>
} @else if (budgetSnapshot(); as snapshot) {
<st-budget-kpi-tiles
data-testid="budget-kpis"
[kpis]="budgetKpis()"
[status]="snapshot.status" />
<st-budget-burnup-chart
data-testid="budget-chart"
[data]="snapshot.timeSeries"
[budget]="snapshot.config.totalBudget"
[status]="snapshot.status" />
} @else if (budgetLoading()) {
<div class="empty">Loading budget data...</div>
} @else {
<div class="empty">No budget snapshot available.</div>
}
</article>
<article class="widget" data-testid="verdict-widget">
<header class="widget__header">
<h2>Current Verdict</h2>
</header>
@if (verdictError()) {
<div class="empty empty--error">{{ verdictError() }}</div>
} @else if (verdict(); as verdict) {
<st-verdict-badge
data-testid="verdict-badge"
[level]="verdict.level"
[drivers]="verdict.drivers"
[delta]="verdict.riskDelta?.net"
[showDrivers]="true" />
<st-verdict-why-summary
data-testid="verdict-summary"
[drivers]="verdict.drivers"
(evidenceRequested)="onEvidenceRequested($event)" />
} @else if (verdictLoading()) {
<div class="empty">Loading verdict...</div>
} @else {
<div class="empty">No verdict available.</div>
}
</article>
</section>
<section class="risk-dashboard__diff widget" data-testid="diff-widget">
<header class="widget__header">
<h2>Side-by-Side Risk Diff</h2>
</header>
<st-side-by-side-diff [before]="beforeSnapshot()" [after]="afterSnapshot()" />
</section>
<section class="risk-dashboard__exceptions widget" data-testid="exception-widget">
<header class="widget__header">
<h2>Exception Workflow</h2>
</header>
@if (exceptionsError()) {
<div class="empty empty--error">{{ exceptionsError() }}</div>
}
<st-exception-ledger
[exceptions]="exceptions()"
[ledgerEntries]="exceptionLedgerEntries()"
[canCreate]="true"
[canApprove]="true"
[canRevoke]="true"
(createException)="openCreateExceptionModal()"
(approveException)="onApproveException($event)"
(rejectException)="onRejectException($event)"
(revokeException)="onRevokeException($event)" />
<st-create-exception-modal
[isOpen]="showCreateExceptionModal()"
(closed)="closeCreateExceptionModal()"
(created)="onExceptionCreated($event)" />
</section>
@if (list(); as page) {
<section class="risk-dashboard__table">
<table>
@@ -87,5 +166,4 @@
<div class="empty">{{ 'ui.risk_dashboard.loading_risks' | translate }}</div>
}
}
</section>

View File

@@ -88,6 +88,37 @@
}
}
.risk-dashboard__widgets {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.widget {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: var(--space-4);
}
.widget__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
}
.risk-dashboard__diff,
.risk-dashboard__exceptions {
display: grid;
gap: var(--space-3);
}
.stat {
padding: var(--space-3);
border: 1px solid var(--color-border-primary);
@@ -180,5 +211,6 @@ tr:last-child td {
@include screen-below-md {
.risk-dashboard__header { flex-direction: column; align-items: flex-start; }
.risk-dashboard__widgets { grid-template-columns: 1fr; }
table { display: block; overflow-x: auto; }
}

View File

@@ -1,10 +1,15 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { RiskDashboardComponent } from './risk-dashboard.component';
import { RiskStore } from '../../core/api/risk.store';
import { RiskResultPage, RiskStats } from '../../core/api/risk.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { RiskBudgetStore } from '../../core/services/risk-budget.service';
import { DeltaVerdictStore } from '../../core/services/delta-verdict.service';
import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.client';
import type { ExceptionsResponse } from '../../core/api/exception.contract.models';
class MockRiskStore {
list = signal<RiskResultPage | null>({ items: [], total: 0, page: 1, pageSize: 20 });
@@ -24,16 +29,121 @@ class MockAuthSessionStore {
}
}
class MockRiskBudgetStore {
snapshot = signal({
config: {
id: 'budget-1',
tenantId: 'acme-tenant',
name: 'budget',
totalBudget: 1000,
warningThreshold: 60,
criticalThreshold: 80,
period: 'monthly' as const,
periodStart: '2026-01-01T00:00:00Z',
periodEnd: '2026-02-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
currentRiskPoints: 300,
headroom: 700,
utilizationPercent: 30,
status: 'healthy' as const,
timeSeries: [
{ timestamp: '2026-01-01T00:00:00Z', actual: 100, budget: 1000, headroom: 900 },
{ timestamp: '2026-01-02T00:00:00Z', actual: 300, budget: 1000, headroom: 700 },
],
updatedAt: '2026-01-02T00:00:00Z',
traceId: 'trace-budget',
});
kpis = signal({
headroom: 700,
headroomDelta24h: -10,
unknownsDelta24h: 1,
riskRetired7d: 5,
exceptionsExpiring: 0,
burnRate: 3,
projectedDaysToExceeded: null,
traceId: 'trace-kpis',
});
loading = signal(false);
error = signal<string | null>(null);
refresh = jasmine.createSpy('refresh');
}
class MockDeltaVerdictStore {
currentVerdict = signal({
id: 'verdict-1',
artifactDigest: 'sha256:artifact',
level: 'review' as const,
timestamp: '2026-01-02T00:00:00Z',
policyPackId: 'default',
policyVersion: '1.0.0',
drivers: [],
previousVerdict: { level: 'routine' as const, timestamp: '2026-01-01T00:00:00Z' },
riskDelta: { added: 5, removed: 2, net: 3 },
traceId: 'trace-verdict',
});
loading = signal(false);
error = signal<string | null>(null);
fetchVerdict = jasmine.createSpy('fetchVerdict');
}
const mockExceptionsResponse: ExceptionsResponse = {
items: [
{
schemaVersion: '1.0',
exceptionId: 'exc-1',
tenantId: 'acme-tenant',
name: 'critical-library',
displayName: 'Critical Library Exception',
type: 'vulnerability',
status: 'approved',
severity: 'high',
scope: {
type: 'asset',
vulnIds: ['CVE-2026-0001'],
},
justification: {
text: 'Compensating controls are in place.',
},
timebox: {
startDate: '2026-01-01T00:00:00Z',
endDate: '2026-02-01T00:00:00Z',
},
createdBy: 'ops-user',
createdAt: '2026-01-01T00:00:00Z',
auditTrail: [],
},
],
count: 1,
continuationToken: null,
};
describe('RiskDashboardComponent', () => {
let component: RiskDashboardComponent;
let fixture: ComponentFixture<RiskDashboardComponent>;
let store: MockRiskStore;
let budgetStore: MockRiskBudgetStore;
let verdictStore: MockDeltaVerdictStore;
let exceptionApi: jasmine.SpyObj<ExceptionApi>;
beforeEach(async () => {
exceptionApi = jasmine.createSpyObj<ExceptionApi>('ExceptionApi', [
'listExceptions',
'createException',
'transitionStatus',
]);
exceptionApi.listExceptions.and.returnValue(of(mockExceptionsResponse));
exceptionApi.createException.and.returnValue(of(mockExceptionsResponse.items[0] as any));
exceptionApi.transitionStatus.and.returnValue(of(mockExceptionsResponse.items[0] as any));
await TestBed.configureTestingModule({
imports: [RiskDashboardComponent],
providers: [
{ provide: RiskStore, useClass: MockRiskStore },
{ provide: RiskBudgetStore, useClass: MockRiskBudgetStore },
{ provide: DeltaVerdictStore, useClass: MockDeltaVerdictStore },
{ provide: EXCEPTION_API, useValue: exceptionApi },
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
],
}).compileComponents();
@@ -41,12 +151,31 @@ describe('RiskDashboardComponent', () => {
fixture = TestBed.createComponent(RiskDashboardComponent);
component = fixture.componentInstance;
store = TestBed.inject(RiskStore) as unknown as MockRiskStore;
fixture.detectChanges();
budgetStore = TestBed.inject(RiskBudgetStore) as unknown as MockRiskBudgetStore;
verdictStore = TestBed.inject(DeltaVerdictStore) as unknown as MockDeltaVerdictStore;
});
it('renders without errors and triggers fetches', () => {
it('renders and triggers risk, budget, verdict, and exception loads', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
expect(component).toBeTruthy();
expect(store.fetchList).toHaveBeenCalled();
expect(store.fetchStats).toHaveBeenCalled();
});
expect(budgetStore.refresh).toHaveBeenCalled();
expect(verdictStore.fetchVerdict).toHaveBeenCalled();
expect(exceptionApi.listExceptions).toHaveBeenCalled();
}));
it('renders parity widgets', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('[data-testid="budget-widget"]')).toBeTruthy();
expect(compiled.querySelector('[data-testid="verdict-widget"]')).toBeTruthy();
expect(compiled.querySelector('[data-testid="diff-widget"]')).toBeTruthy();
expect(compiled.querySelector('[data-testid="exception-widget"]')).toBeTruthy();
}));
});

View File

@@ -2,20 +2,52 @@ import { CommonModule } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { RiskStore } from '../../core/api/risk.store';
import { RiskProfile, RiskSeverity } from '../../core/api/risk.models';
import type {
Exception as ExceptionContract,
ExceptionAuditEntry as ExceptionAuditEntryContract,
ExceptionStatus as ExceptionContractStatus,
} from '../../core/api/exception.contract.models';
import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.client';
import type { Exception, ExceptionLedgerEntry } from '../../core/api/exception.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { TranslatePipe } from '../../core/i18n';
import { DeltaVerdictStore } from '../../core/services/delta-verdict.service';
import { RiskBudgetStore } from '../../core/services/risk-budget.service';
import { BudgetBurnupChartComponent } from './components/budget-burnup-chart.component';
import { BudgetKpiTilesComponent } from './components/budget-kpi-tiles.component';
import { CreateExceptionData, CreateExceptionModalComponent } from './components/create-exception-modal.component';
import { SideBySideDiffComponent, type RiskStateSnapshot } from './components/side-by-side-diff.component';
import { ExceptionLedgerComponent } from './components/exception-ledger.component';
import { type EvidenceRequest, VerdictWhySummaryComponent } from './components/verdict-why-summary.component';
import { VerdictBadgeComponent } from './components/verdict-badge.component';
@Component({
selector: 'st-risk-dashboard',
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe],
templateUrl: './risk-dashboard.component.html',
styleUrl: './risk-dashboard.component.scss'
selector: 'st-risk-dashboard',
imports: [
CommonModule,
FormsModule,
RouterLink,
TranslatePipe,
BudgetBurnupChartComponent,
BudgetKpiTilesComponent,
VerdictBadgeComponent,
VerdictWhySummaryComponent,
SideBySideDiffComponent,
ExceptionLedgerComponent,
CreateExceptionModalComponent,
],
templateUrl: './risk-dashboard.component.html',
styleUrl: './risk-dashboard.component.scss',
})
export class RiskDashboardComponent implements OnInit {
private readonly store = inject(RiskStore);
private readonly budgetStore = inject(RiskBudgetStore);
private readonly verdictStore = inject(DeltaVerdictStore);
private readonly exceptionApi = inject<ExceptionApi>(EXCEPTION_API);
private readonly authSession = inject(AuthSessionStore);
readonly list = this.store.list;
@@ -23,17 +55,88 @@ export class RiskDashboardComponent implements OnInit {
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly budgetSnapshot = this.budgetStore.snapshot;
readonly budgetKpis = this.budgetStore.kpis;
readonly budgetLoading = this.budgetStore.loading;
readonly budgetError = this.budgetStore.error;
readonly verdict = this.verdictStore.currentVerdict;
readonly verdictLoading = this.verdictStore.loading;
readonly verdictError = this.verdictStore.error;
readonly exceptions = signal<Exception[]>([]);
readonly exceptionLedgerEntries = signal<ExceptionLedgerEntry[]>([]);
readonly exceptionsLoading = signal(false);
readonly exceptionsError = signal<string | null>(null);
readonly showCreateExceptionModal = signal(false);
readonly severities: RiskSeverity[] = ['critical', 'high', 'medium', 'low', 'info', 'none'];
readonly selectedSeverity = signal<RiskSeverity | ''>('');
readonly search = signal('');
readonly severityCounts = computed(() => this.store.stats()?.countsBySeverity ?? {});
readonly severityCounts = computed<Record<RiskSeverity, number>>(() => {
const counts = this.store.stats()?.countsBySeverity;
return {
critical: counts?.critical ?? 0,
high: counts?.high ?? 0,
medium: counts?.medium ?? 0,
low: counts?.low ?? 0,
info: counts?.info ?? 0,
none: counts?.none ?? 0,
};
});
readonly afterSnapshot = computed<RiskStateSnapshot | undefined>(() => {
const verdict = this.verdict();
if (!verdict) return undefined;
const severity = this.severityCounts();
const budget = this.budgetSnapshot();
const riskScore = this.computeRiskScore();
const activeExceptions = this.exceptions().filter((exception) =>
exception.status === 'approved' || exception.status === 'pending_review').length;
return {
verdict,
riskScore,
criticalCount: severity.critical ?? 0,
highCount: severity.high ?? 0,
mediumCount: severity.medium ?? 0,
lowCount: severity.low ?? 0,
unknownCount: severity.none ?? 0,
exceptionsActive: activeExceptions,
budgetUtilization: Math.round(budget?.utilizationPercent ?? 0),
};
});
readonly beforeSnapshot = computed<RiskStateSnapshot | undefined>(() => {
const after = this.afterSnapshot();
const verdict = this.verdict();
if (!after || !verdict?.previousVerdict) return undefined;
const delta = verdict.riskDelta?.net ?? 0;
return {
...after,
verdict: {
...verdict,
level: verdict.previousVerdict.level,
timestamp: verdict.previousVerdict.timestamp,
},
riskScore: Math.max(0, after.riskScore - delta),
budgetUtilization: Math.max(0, after.budgetUtilization - Math.round(delta / 10)),
};
});
ngOnInit(): void {
const tenant = this.authSession.getActiveTenantId() ?? 'tenant-dev';
this.store.fetchList({ tenantId: tenant, page: 1, pageSize: 20 });
this.store.fetchStats({ tenantId: tenant });
this.budgetStore.refresh({ tenantId: tenant });
this.verdictStore.fetchVerdict('risk-dashboard', { tenantId: tenant, includePrevious: true });
void this.loadExceptions(tenant);
}
applyFilters(): void {
@@ -50,4 +153,215 @@ export class RiskDashboardComponent implements OnInit {
trackRisk(_index: number, risk: RiskProfile): string {
return risk.id;
}
openCreateExceptionModal(): void {
this.showCreateExceptionModal.set(true);
}
closeCreateExceptionModal(): void {
this.showCreateExceptionModal.set(false);
}
async onExceptionCreated(data: CreateExceptionData): Promise<void> {
const tenant = this.authSession.getActiveTenantId() ?? 'tenant-dev';
this.exceptionsError.set(null);
const now = new Date();
const expires = new Date(now);
expires.setDate(expires.getDate() + data.ttlDays);
try {
await firstValueFrom(this.exceptionApi.createException({
schemaVersion: '1.0',
tenantId: tenant,
name: this.normalizeName(data.title),
displayName: data.title,
type: data.type,
status: 'pending_review',
severity: data.severity,
scope: {
type: data.scope.tenantId ? 'tenant' : (data.scope.cves?.length ? 'asset' : 'global'),
tenantId: data.scope.tenantId,
vulnIds: data.scope.cves,
componentPurls: data.scope.packages,
images: data.scope.images,
cves: data.scope.cves,
packages: data.scope.packages,
},
justification: {
text: data.justification,
},
timebox: {
startDate: now.toISOString(),
endDate: expires.toISOString(),
autoRenew: false,
},
labels: {
source: 'risk-dashboard',
},
createdBy: 'risk-dashboard',
createdAt: now.toISOString(),
}, { tenantId: tenant }));
await this.loadExceptions(tenant);
this.showCreateExceptionModal.set(false);
} catch (err) {
this.exceptionsError.set(err instanceof Error ? err.message : 'Failed to create exception');
}
}
onApproveException(exception: Exception): void {
void this.transitionException(exception.id, 'approved');
}
onRejectException(exception: Exception): void {
void this.transitionException(exception.id, 'rejected');
}
onRevokeException(exception: Exception): void {
void this.transitionException(exception.id, 'revoked');
}
onEvidenceRequested(request: EvidenceRequest): void {
if (request.driver.relatedIds && request.driver.relatedIds.length > 0) {
this.search.set(request.driver.relatedIds[0]);
this.applyFilters();
}
}
private async transitionException(exceptionId: string, newStatus: ExceptionContractStatus): Promise<void> {
const tenant = this.authSession.getActiveTenantId() ?? 'tenant-dev';
this.exceptionsError.set(null);
try {
await firstValueFrom(this.exceptionApi.transitionStatus({
exceptionId,
newStatus,
tenantId: tenant,
}));
await this.loadExceptions(tenant);
} catch (err) {
this.exceptionsError.set(err instanceof Error ? err.message : 'Failed to transition exception');
}
}
private async loadExceptions(tenantId: string): Promise<void> {
this.exceptionsLoading.set(true);
this.exceptionsError.set(null);
try {
const response = await firstValueFrom(this.exceptionApi.listExceptions({ tenantId, limit: 50 }));
const mapped = response.items.map((item) => this.mapException(item));
const entries = response.items.flatMap((item) => this.mapLedgerEntries(item));
this.exceptions.set(mapped);
this.exceptionLedgerEntries.set(entries);
} catch (err) {
this.exceptions.set([]);
this.exceptionLedgerEntries.set([]);
this.exceptionsError.set(err instanceof Error ? err.message : 'Failed to load exceptions');
} finally {
this.exceptionsLoading.set(false);
}
}
private mapException(item: ExceptionContract): Exception {
const remainingDays = this.daysUntil(item.timebox.endDate);
const approved = item.approvals?.[0];
return {
id: item.exceptionId,
title: item.displayName ?? item.name,
justification: item.justification.text,
type: item.type ?? 'vulnerability',
status: item.status,
severity: item.severity,
scope: {
tenantId: item.scope.tenantId,
cves: [...(item.scope.vulnIds ?? item.scope.cves ?? [])],
packages: [...(item.scope.componentPurls ?? item.scope.packages ?? [])],
images: [...(item.scope.images ?? [])],
},
timebox: {
startsAt: item.timebox.startDate,
expiresAt: item.timebox.endDate,
remainingDays,
isExpired: remainingDays < 0,
warnDays: 7,
isWarning: remainingDays >= 0 && remainingDays <= 7,
},
workflow: {
state: item.status,
requestedBy: item.createdBy,
requestedAt: item.createdAt,
approvedBy: approved?.approvedBy,
approvedAt: approved?.approvedAt,
requiredApprovers: [],
approvals: (item.approvals ?? []).map((approval) => ({
approver: approval.approvedBy,
decision: 'approved',
at: approval.approvedAt,
comment: approval.comment,
})),
},
auditLog: (item.auditTrail ?? []).map((entry) => ({
id: entry.auditId,
action: this.normalizeAuditAction(entry.action),
actor: entry.actor,
at: entry.timestamp,
})),
findings: [...(item.scope.vulnIds ?? item.scope.cves ?? [])],
tags: Object.entries(item.labels ?? {}).map(([key, value]) => `${key}:${value}`),
createdAt: item.createdAt,
updatedAt: item.updatedAt ?? item.createdAt,
};
}
private mapLedgerEntries(item: ExceptionContract): ExceptionLedgerEntry[] {
return (item.auditTrail ?? []).map((entry) => ({
id: entry.auditId,
exceptionId: item.exceptionId,
eventType: this.mapLedgerEventType(entry),
timestamp: entry.timestamp,
actorId: entry.actor,
actorName: entry.actor,
}));
}
private mapLedgerEventType(entry: ExceptionAuditEntryContract): ExceptionLedgerEntry['eventType'] {
const action = entry.action.toLowerCase();
if (action.includes('approve')) return 'approved';
if (action.includes('reject')) return 'rejected';
if (action.includes('revoke')) return 'revoked';
if (action.includes('expire')) return 'expired';
if (action.includes('create')) return 'created';
return 'modified';
}
private normalizeAuditAction(action: string): Exception['auditLog'][number]['action'] {
const normalized = action.toLowerCase();
if (normalized.includes('create')) return 'created';
if (normalized.includes('approve')) return 'approved';
if (normalized.includes('reject')) return 'rejected';
if (normalized.includes('expire')) return 'expired';
if (normalized.includes('revoke')) return 'revoked';
return 'edited';
}
private normalizeName(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
}
private daysUntil(dateIso: string): number {
const now = Date.now();
const end = Date.parse(dateIso);
if (!Number.isFinite(end)) return 0;
return Math.ceil((end - now) / (1000 * 60 * 60 * 24));
}
private computeRiskScore(): number {
const items = this.list()?.items ?? [];
if (items.length === 0) return 0;
const total = items.reduce((sum, item) => sum + item.score, 0);
return Math.round(total / items.length);
}
}

View File

@@ -1,5 +1,5 @@
/**
* Scheduler/Orchestrator Ops Models (Sprint: SPRINT_20251229_017)
* Scheduler/JobEngine Ops Models (Sprint: SPRINT_20251229_017)
*/
// ============================================
@@ -182,14 +182,14 @@ export interface BackpressureStatus {
}
// ============================================
// Orchestrator Job Models
// JobEngine Job Models
// ============================================
export interface OrchestratorJob {
export interface JobEngineJob {
id: string;
type: string;
name: string;
status: OrchestratorJobStatus;
status: JobEngineJobStatus;
priority: number;
createdAt: string;
startedAt?: string;
@@ -201,13 +201,13 @@ export interface OrchestratorJob {
childJobIds: string[];
input: Record<string, unknown>;
output?: Record<string, unknown>;
error?: OrchestratorJobError;
error?: JobEngineJobError;
retryCount: number;
maxRetries: number;
metadata?: Record<string, string>;
}
export type OrchestratorJobStatus =
export type JobEngineJobStatus =
| 'pending'
| 'queued'
| 'running'
@@ -216,7 +216,7 @@ export type OrchestratorJobStatus =
| 'cancelled'
| 'dead-letter';
export interface OrchestratorJobError {
export interface JobEngineJobError {
code: string;
message: string;
stackTrace?: string;
@@ -272,7 +272,7 @@ export interface JobDagNode {
jobId: string;
name: string;
type: string;
status: OrchestratorJobStatus;
status: JobEngineJobStatus;
duration?: number;
isCritical: boolean;
level: number;

View File

@@ -153,7 +153,7 @@ interface AdvisorySummaryVm {
@if (showConflictBanner()) {
<div class="banner conflict" role="status">
Active advisory conflicts are affecting release decisions. Open
<a routerLink="/security-risk/findings" [queryParams]="{ conflict: 'true' }">filtered findings</a>.
<a routerLink="/security/findings" [queryParams]="{ conflict: 'true' }">filtered findings</a>.
</div>
}
@@ -223,7 +223,7 @@ interface AdvisorySummaryVm {
<a [routerLink]="['/ops/operations/feeds-airgap']" [queryParams]="{ sourceId: row.sourceKey }">
Open mirror ops
</a>
<a [routerLink]="['/security-risk/findings']" [queryParams]="{ sourceId: row.sourceKey }">
<a [routerLink]="['/security/findings']" [queryParams]="{ sourceId: row.sourceKey }">
View impacted findings
</a>
<button type="button" (click)="openDetail(row.sourceKey)">Inspect</button>
@@ -857,7 +857,7 @@ export class AdvisorySourcesComponent implements OnInit {
const decisionRefs = impact?.decisionRefs ?? [];
const impactedRefs: ImpactedDecisionRef[] = decisionRefs.map((ref) => ({
label: ref.label?.trim() || `${ref.decisionType ?? 'Decision'} ${ref.decisionId}`,
route: ref.route?.trim() || '/security-risk/findings',
route: ref.route?.trim() || '/security/findings',
}));
const advisoryStats: AdvisoryStatSummary = {

View File

@@ -75,7 +75,7 @@ import { RemediationApiService, FixTemplate } from './remediation.api';
</div>
} @else {
@for (fix of filteredTemplates(); track fix.id) {
<a [routerLink]="'/security-risk/remediation/' + fix.id" class="fix-card">
<a [routerLink]="'/security/remediation/' + fix.id" class="fix-card">
<div class="fix-card__header">
<span class="fix-card__cve">{{ fix.cveId }}</span>
<span class="fix-card__status" [class]="'status--' + fix.status">{{ fix.status }}</span>

View File

@@ -24,7 +24,7 @@ import { RemediationApiService, FixTemplate } from './remediation.api';
} @else if (fix()) {
<header class="detail-header">
<div class="header-top">
<a routerLink="/security-risk/remediation" class="back-link">Back to Marketplace</a>
<a routerLink="/security/remediation" class="back-link">Back to Marketplace</a>
</div>
<h1 class="detail-title">{{ fix()!.cveId }}</h1>
<div class="detail-meta">

View File

@@ -24,7 +24,7 @@ import { RemediationApiService } from './remediation.api';
template: `
@if (fixCount() > 0) {
<a
routerLink="/security-risk/remediation"
routerLink="/security/remediation"
[queryParams]="{ cve: cveId }"
class="fixes-badge"
[attr.title]="fixCount() + ' verified fix templates available'"

View File

@@ -26,7 +26,7 @@ interface PipelineStep {
template: `
<div class="remediation-submit">
<header class="submit-header">
<a routerLink="/security-risk/remediation" class="back-link">Back to Marketplace</a>
<a routerLink="/security/remediation" class="back-link">Back to Marketplace</a>
<h1 class="submit-title">{{ submission() ? 'Verification Status' : 'Submit Remediation PR' }}</h1>
</header>

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