diff --git a/src/Web/StellaOps.Web/angular.json b/src/Web/StellaOps.Web/angular.json index 5912bf091..db81e7314 100644 --- a/src/Web/StellaOps.Web/angular.json +++ b/src/Web/StellaOps.Web/angular.json @@ -53,11 +53,11 @@ "configurations": { "production": { "budgets": [ - { - "type": "initial", - "maximumWarning": "750kb", - "maximumError": "2mb" - }, + { + "type": "initial", + "maximumWarning": "2200kb", + "maximumError": "2400kb" + }, { "type": "anyComponentStyle", "maximumWarning": "20kb", @@ -104,6 +104,7 @@ "tsConfig": "tsconfig.spec.json", "buildTarget": "stellaops-web:build:development", "runner": "vitest", + "runnerConfig": "vitest.codex.config.ts", "setupFiles": ["src/test-setup.ts"], "exclude": [ "**/*.e2e.spec.ts", @@ -124,6 +125,25 @@ "setupFiles": ["src/test-setup.ts"] } }, + "test-active-surfaces": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.active-surfaces.json", + "buildTarget": "stellaops-web:build:development", + "runner": "vitest", + "runnerConfig": "vitest.active-surfaces.config.ts", + "setupFiles": ["src/test-setup.ts"], + "include": [ + "src/tests/deployments/create-deployment.component.spec.ts", + "src/tests/evidence/evidence-center-hub.component.spec.ts", + "src/tests/graph_reachability_overlay/graph-canvas.component.spec.ts", + "src/tests/graph_reachability_overlay/graph-overlays.component.spec.ts", + "src/tests/release-control/environment-detail-standardization.component.spec.ts", + "src/tests/sprint309/security-vulnerability-detail-page.component.spec.ts", + "src/tests/sprint309/signed-score-ribbon.component.spec.ts" + ] + } + }, "storybook": { "builder": "@storybook/angular:start-storybook", "options": { diff --git a/src/Web/StellaOps.Web/package.json b/src/Web/StellaOps.Web/package.json index 3dd627add..9d7b99b1b 100644 --- a/src/Web/StellaOps.Web/package.json +++ b/src/Web/StellaOps.Web/package.json @@ -9,9 +9,11 @@ "analyze": "ng build --stats-json && npx esbuild-visualizer --metadata dist/stellaops-web/browser/stats.json --open", "analyze:source-map": "ng build --source-map && npx source-map-explorer dist/stellaops-web/browser/*.js", "watch": "ng build --watch --configuration development", - "test": "ng test --watch=false", - "test:topology": "ng run stellaops-web:test-topology --watch=false", - "test:watch": "ng test", + "test": "node ./scripts/run-unit-test-batches.mjs", + "test:direct": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng test --watch=false", + "test:topology": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng run stellaops-web:test-topology --watch=false", + "test:active-surfaces": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng run stellaops-web:test-active-surfaces --watch=false", + "test:watch": "node --max-old-space-size=3072 ./node_modules/@angular/cli/bin/ng test", "test:ci": "npm run test", "test:e2e": "playwright test", "test:e2e:search:live": "node ./scripts/run-live-search-e2e.mjs", diff --git a/src/Web/StellaOps.Web/scripts/run-unit-test-batches.mjs b/src/Web/StellaOps.Web/scripts/run-unit-test-batches.mjs new file mode 100644 index 000000000..949708b08 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/run-unit-test-batches.mjs @@ -0,0 +1,258 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); +const sourceRoot = path.join(projectRoot, 'src'); +const angularJsonPath = path.join(projectRoot, 'angular.json'); +const angularCliPath = path.join(projectRoot, 'node_modules', '@angular', 'cli', 'bin', 'ng'); +const defaultBatchSize = 12; + +async function main() { + const forwardedArgs = process.argv.slice(2); + const parsed = parseArgs(forwardedArgs); + + if (parsed.direct) { + process.exitCode = await runAngularTests(parsed.forwardArgs); + return; + } + + const specFiles = await resolveDefaultSpecFiles(); + if (specFiles.length === 0) { + console.error('No Web spec files matched the default Angular test target.'); + process.exitCode = 1; + return; + } + + const batches = chunk(specFiles, parsed.batchSize ?? defaultBatchSize); + if (parsed.printBatch) { + const batchIndex = parsed.printBatch - 1; + const batch = batches[batchIndex]; + if (!batch) { + console.error(`Batch ${parsed.printBatch} is out of range. Total batches: ${batches.length}.`); + process.exitCode = 1; + return; + } + + console.log(batch.join('\n')); + return; + } + + const batchFrom = Math.max(1, parsed.batchFrom ?? 1); + const batchTo = Math.min(batches.length, parsed.batchTo ?? batches.length); + const selectedBatches = batches.slice(batchFrom - 1, batchTo); + + console.log( + `Running ${specFiles.length} default Web specs in ${selectedBatches.length} deterministic batch(es)` + + ` (batch range ${batchFrom}-${batchTo} of ${batches.length}).`, + ); + + for (const [index, batch] of selectedBatches.entries()) { + const batchNumber = batchFrom + index; + const label = `Batch ${batchNumber}/${batches.length}`; + console.log(`\n${label}: ${batch.length} file(s)`); + const code = await runAngularTests([ + ...parsed.forwardArgs, + ...batch.flatMap((spec) => ['--include', spec]), + ]); + + if (code !== 0) { + console.error(`${label} failed.`); + process.exitCode = code; + return; + } + } + + console.log('\nAll default Web test batches passed.'); +} + +function parseArgs(args) { + let batchSize; + let batchFrom; + let batchTo; + let printBatch; + const forwardArgs = []; + let hasInclude = false; + let hasWatch = false; + let direct = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--no-batch') { + direct = true; + continue; + } + + if (arg.startsWith('--batch-size=')) { + batchSize = Number.parseInt(arg.split('=')[1] ?? '', 10); + continue; + } + + if (arg === '--batch-size') { + batchSize = Number.parseInt(args[index + 1] ?? '', 10); + index += 1; + continue; + } + + if (arg.startsWith('--batch-from=')) { + batchFrom = Number.parseInt(arg.split('=')[1] ?? '', 10); + continue; + } + + if (arg === '--batch-from') { + batchFrom = Number.parseInt(args[index + 1] ?? '', 10); + index += 1; + continue; + } + + if (arg.startsWith('--batch-to=')) { + batchTo = Number.parseInt(arg.split('=')[1] ?? '', 10); + continue; + } + + if (arg === '--batch-to') { + batchTo = Number.parseInt(args[index + 1] ?? '', 10); + index += 1; + continue; + } + + if (arg.startsWith('--print-batch=')) { + printBatch = Number.parseInt(arg.split('=')[1] ?? '', 10); + continue; + } + + if (arg === '--print-batch') { + printBatch = Number.parseInt(args[index + 1] ?? '', 10); + index += 1; + continue; + } + + if (arg === '--include' || arg.startsWith('--include=')) { + hasInclude = true; + } + + if (arg === '--watch' || arg === '--watch=true' || arg === '--watch=false' || arg.startsWith('--watch=')) { + hasWatch = true; + } + + forwardArgs.push(arg); + } + + if (!hasWatch) { + forwardArgs.unshift('--watch=false'); + } + + return { + batchSize: Number.isFinite(batchSize) && batchSize > 0 ? batchSize : undefined, + batchFrom: Number.isFinite(batchFrom) && batchFrom > 0 ? batchFrom : undefined, + batchTo: Number.isFinite(batchTo) && batchTo > 0 ? batchTo : undefined, + printBatch: Number.isFinite(printBatch) && printBatch > 0 ? printBatch : undefined, + direct: direct || hasInclude || hasWatch && !forwardArgs.includes('--watch=false'), + forwardArgs, + }; +} + +async function resolveDefaultSpecFiles() { + const angularJson = JSON.parse(await readFile(angularJsonPath, 'utf8')); + const testOptions = + angularJson.projects?.['stellaops-web']?.architect?.test?.options ?? + angularJson.projects?.['stellaops-web']?.targets?.test?.options; + + if (!testOptions) { + throw new Error('Unable to resolve stellaops-web test options from angular.json.'); + } + + const excludePatterns = (testOptions.exclude ?? []).map(normalizePattern); + const includePatterns = (testOptions.include ?? ['src/**/*.spec.ts']).map(normalizePattern); + const discovered = await walkSpecs(sourceRoot); + + return discovered + .map((file) => path.relative(projectRoot, file).replaceAll('\\', '/')) + .filter((relativePath) => includePatterns.some((pattern) => matchesGlob(relativePath, pattern))) + .filter((relativePath) => !excludePatterns.some((pattern) => matchesGlob(relativePath, pattern))) + .sort((left, right) => left.localeCompare(right)); +} + +async function walkSpecs(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const results = []; + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + results.push(...await walkSpecs(fullPath)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.spec.ts')) { + results.push(fullPath); + } + } + + return results; +} + +function normalizePattern(pattern) { + return pattern.replaceAll('\\', '/'); +} + +function matchesGlob(value, pattern) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*\//g, '::DOUBLE_STAR_DIR::') + .replace(/\*\*/g, '::DOUBLE_STAR::') + .replace(/\*/g, '[^/]*') + .replace(/::DOUBLE_STAR_DIR::/g, '(?:[^/]+/)*') + .replace(/::DOUBLE_STAR::/g, '.*'); + + return new RegExp(`^${escaped}$`).test(value); +} + +function chunk(items, size) { + const batches = []; + for (let index = 0; index < items.length; index += size) { + batches.push(items.slice(index, index + size)); + } + return batches; +} + +function runAngularTests(args) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [angularCliPath, 'test', ...args], { + cwd: projectRoot, + stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: mergeNodeOptions(process.env.NODE_OPTIONS, '--max-old-space-size=3072'), + }, + }); + + child.on('exit', (code, signal) => { + if (signal) { + console.error(`Angular test process terminated by signal ${signal}.`); + resolve(1); + return; + } + + resolve(code ?? 1); + }); + }); +} + +function mergeNodeOptions(existing, requiredFlag) { + if (!existing || !existing.trim()) { + return requiredFlag; + } + + if (existing.includes(requiredFlag)) { + return existing; + } + + return `${existing} ${requiredFlag}`.trim(); +} + +await main(); diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 393f07c44..3d02efb21 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -40,23 +40,21 @@ import { NOTIFY_API, NOTIFY_API_BASE_URL, NotifyApiHttpClient, - MockNotifyClient, } from './core/api/notify.client'; import { EXCEPTION_API, EXCEPTION_API_BASE_URL, ExceptionApiHttpClient, - MockExceptionApiService, } from './core/api/exception.client'; -import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; +import { VULNERABILITY_API } from './core/api/vulnerability.client'; import { VULNERABILITY_API_BASE_URL, VULNERABILITY_QUERY_API_BASE_URL, VulnerabilityHttpClient, } from './core/api/vulnerability-http.client'; -import { RISK_API, MockRiskApi } from './core/api/risk.client'; +import { RISK_API } from './core/api/risk.client'; import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; -import { SCRIPTS_API, MockScriptsClient } from './core/api/scripts.client'; +import { SCRIPTS_API, ScriptsHttpClient } from './core/api/scripts.client'; import { AppConfigService } from './core/config/app-config.service'; import { I18nService } from './core/i18n'; import { DoctorTrendService } from './core/doctor/doctor-trend.service'; @@ -78,38 +76,32 @@ import { ADVISORY_AI_API, ADVISORY_AI_API_BASE_URL, AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, } from './core/api/advisory-ai.client'; import { ADVISORY_API, ADVISORY_API_BASE_URL, AdvisoryApiHttpClient, - MockAdvisoryApiService, } from './core/api/advisories.client'; import { VEX_EVIDENCE_API, VEX_EVIDENCE_API_BASE_URL, VexEvidenceHttpClient, - MockVexEvidenceClient, } from './core/api/vex-evidence.client'; import { VEX_DECISIONS_API, VEX_DECISIONS_API_BASE_URL, VexDecisionsHttpClient, - MockVexDecisionsClient, } from './core/api/vex-decisions.client'; -import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; +import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient } from './core/api/vex-hub.client'; import { AUDIT_BUNDLES_API, AUDIT_BUNDLES_API_BASE_URL, AuditBundlesHttpClient, - MockAuditBundlesClient, } from './core/api/audit-bundles.client'; import { POLICY_EXCEPTIONS_API, POLICY_EXCEPTIONS_API_BASE_URL, PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, } from './core/api/policy-exceptions.client'; import { POLICY_EVIDENCE_API, @@ -119,35 +111,29 @@ import { ORCHESTRATOR_API, JOBENGINE_API_BASE_URL, OrchestratorHttpClient, - MockJobEngineClient, } from './core/api/jobengine.client'; import { ORCHESTRATOR_CONTROL_API, JobEngineControlHttpClient, - MockJobEngineControlClient, } from './core/api/jobengine-control.client'; import { FIRST_SIGNAL_API, FirstSignalHttpClient, - MockFirstSignalClient, } from './core/api/first-signal.client'; import { EXCEPTION_EVENTS_API, EXCEPTION_EVENTS_API_BASE_URL, ExceptionEventsHttpClient, - MockExceptionEventsApiService, } from './core/api/exception-events.client'; import { EVIDENCE_PACK_API, EVIDENCE_PACK_API_BASE_URL, EvidencePackHttpClient, - MockEvidencePackClient, } from './core/api/evidence-pack.client'; import { AI_RUNS_API, AI_RUNS_API_BASE_URL, AiRunsHttpClient, - MockAiRunsClient, } from './core/api/ai-runs.client'; import { RELEASE_DASHBOARD_API, @@ -191,17 +177,14 @@ import { NOTIFIER_API, NOTIFIER_API_BASE_URL, NotifierApiHttpClient, - MockNotifierClient, } from './core/api/notifier.client'; import { POLICY_ENGINE_API, PolicyEngineHttpClient, - MockPolicyEngineApi, } from './core/api/policy-engine.client'; import { TRUST_API, TrustHttpService, - MockTrustApiService, } from './core/api/trust.client'; import { VULN_ANNOTATION_API, @@ -211,36 +194,30 @@ import { AUTHORITY_ADMIN_API, AUTHORITY_ADMIN_API_BASE_URL, AuthorityAdminHttpClient, - MockAuthorityAdminClient, } from './core/api/authority-admin.client'; import { SECURITY_FINDINGS_API, SECURITY_FINDINGS_API_BASE_URL, SecurityFindingsHttpClient, - MockSecurityFindingsClient, } from './core/api/security-findings.client'; import { SECURITY_OVERVIEW_API, SecurityOverviewHttpClient, - MockSecurityOverviewClient, } from './core/api/security-overview.client'; import { CONSOLE_VULN_API, ConsoleVulnHttpClient, - MockConsoleVulnClient, } from './core/api/console-vuln.client'; import { REACHABILITY_API, ReachabilityClient, - MockReachabilityApi, } from './core/api/reachability.client'; import { SCHEDULER_API, SCHEDULER_API_BASE_URL, SchedulerHttpClient, - MockSchedulerClient, } from './core/api/scheduler.client'; -import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; +import { AnalyticsHttpClient } from './core/api/analytics.client'; import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; @@ -284,7 +261,6 @@ import { IDENTITY_PROVIDER_API, IDENTITY_PROVIDER_API_BASE_URL, IdentityProviderApiHttpClient, - MockIdentityProviderClient, } from './core/api/identity-provider.client'; function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { @@ -429,7 +405,6 @@ export const appConfig: ApplicationConfig = { }, }, RiskHttpClient, - MockRiskApi, { provide: RISK_API, useExisting: RiskHttpClient, @@ -452,7 +427,6 @@ export const appConfig: ApplicationConfig = { }, }, VulnerabilityHttpClient, - MockVulnerabilityApiService, { provide: VULNERABILITY_API, useExisting: VulnerabilityHttpClient, @@ -484,7 +458,6 @@ export const appConfig: ApplicationConfig = { }, }, AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, { provide: ADVISORY_AI_API, useExisting: AdvisoryAiApiHttpClient, @@ -498,7 +471,6 @@ export const appConfig: ApplicationConfig = { }, }, AdvisoryApiHttpClient, - MockAdvisoryApiService, { provide: ADVISORY_API, useExisting: AdvisoryApiHttpClient, @@ -538,13 +510,11 @@ export const appConfig: ApplicationConfig = { }, }, VexHubApiHttpClient, - MockVexHubClient, { provide: VEX_HUB_API, useExisting: VexHubApiHttpClient, }, VexEvidenceHttpClient, - MockVexEvidenceClient, { provide: VEX_EVIDENCE_API, useExisting: VexEvidenceHttpClient, @@ -558,7 +528,6 @@ export const appConfig: ApplicationConfig = { }, }, VexDecisionsHttpClient, - MockVexDecisionsClient, { provide: VEX_DECISIONS_API, useExisting: VexDecisionsHttpClient, @@ -572,7 +541,6 @@ export const appConfig: ApplicationConfig = { }, }, AuditBundlesHttpClient, - MockAuditBundlesClient, { provide: AUDIT_BUNDLES_API, useExisting: AuditBundlesHttpClient, @@ -586,7 +554,6 @@ export const appConfig: ApplicationConfig = { }, }, PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, { provide: POLICY_EXCEPTIONS_API, useExisting: PolicyExceptionsHttpClient, @@ -605,19 +572,16 @@ export const appConfig: ApplicationConfig = { }, }, OrchestratorHttpClient, - MockJobEngineClient, { provide: ORCHESTRATOR_API, useExisting: OrchestratorHttpClient, }, JobEngineControlHttpClient, - MockJobEngineControlClient, { provide: ORCHESTRATOR_CONTROL_API, useExisting: JobEngineControlHttpClient, }, FirstSignalHttpClient, - MockFirstSignalClient, { provide: FIRST_SIGNAL_API, useExisting: FirstSignalHttpClient, @@ -631,7 +595,6 @@ export const appConfig: ApplicationConfig = { }, }, ExceptionEventsHttpClient, - MockExceptionEventsApiService, { provide: EXCEPTION_EVENTS_API, useExisting: ExceptionEventsHttpClient, @@ -650,7 +613,6 @@ export const appConfig: ApplicationConfig = { }, }, ExceptionApiHttpClient, - MockExceptionApiService, { provide: EXCEPTION_API, useExisting: ExceptionApiHttpClient, @@ -669,7 +631,6 @@ export const appConfig: ApplicationConfig = { }, }, EvidencePackHttpClient, - MockEvidencePackClient, { provide: EVIDENCE_PACK_API, useExisting: EvidencePackHttpClient, @@ -683,7 +644,6 @@ export const appConfig: ApplicationConfig = { }, }, AiRunsHttpClient, - MockAiRunsClient, { provide: AI_RUNS_API, useExisting: AiRunsHttpClient, @@ -701,7 +661,6 @@ export const appConfig: ApplicationConfig = { useValue: DEFAULT_EVENT_SOURCE_FACTORY, }, NotifyApiHttpClient, - MockNotifyClient, { provide: NOTIFY_API, useExisting: NotifyApiHttpClient, @@ -801,21 +760,18 @@ export const appConfig: ApplicationConfig = { }, }, NotifierApiHttpClient, - MockNotifierClient, { provide: NOTIFIER_API, useExisting: NotifierApiHttpClient, }, // Policy Engine API PolicyEngineHttpClient, - MockPolicyEngineApi, { provide: POLICY_ENGINE_API, useExisting: PolicyEngineHttpClient, }, // Trust API TrustHttpService, - MockTrustApiService, { provide: TRUST_API, useExisting: TrustHttpService, @@ -838,7 +794,6 @@ export const appConfig: ApplicationConfig = { useValue: '/console/admin', }, AuthorityAdminHttpClient, - MockAuthorityAdminClient, { provide: AUTHORITY_ADMIN_API, useExisting: AuthorityAdminHttpClient, @@ -853,14 +808,12 @@ export const appConfig: ApplicationConfig = { }, }, SecurityFindingsHttpClient, - MockSecurityFindingsClient, { provide: SECURITY_FINDINGS_API, useExisting: SecurityFindingsHttpClient, }, // Security Overview API (aggregated security dashboard data) SecurityOverviewHttpClient, - MockSecurityOverviewClient, { provide: SECURITY_OVERVIEW_API, useExisting: SecurityOverviewHttpClient, @@ -880,21 +833,18 @@ export const appConfig: ApplicationConfig = { }, }, SchedulerHttpClient, - MockSchedulerClient, { provide: SCHEDULER_API, useExisting: SchedulerHttpClient, }, // Console Vuln API ConsoleVulnHttpClient, - MockConsoleVulnClient, { provide: CONSOLE_VULN_API, useExisting: ConsoleVulnHttpClient, }, // Reachability API ReachabilityClient, - MockReachabilityApi, { provide: REACHABILITY_API, useExisting: ReachabilityClient, @@ -1134,17 +1084,16 @@ export const appConfig: ApplicationConfig = { }, }, IdentityProviderApiHttpClient, - MockIdentityProviderClient, { provide: IDENTITY_PROVIDER_API, useExisting: IdentityProviderApiHttpClient, }, - // Scripts API (mock client — no backend persistence yet) - MockScriptsClient, + // Scripts API (HTTP client) + ScriptsHttpClient, { provide: SCRIPTS_API, - useExisting: MockScriptsClient, + useExisting: ScriptsHttpClient, }, // Doctor background services — started from AppComponent to avoid diff --git a/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts index 35d0b6eae..69f252bdd 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts @@ -41,7 +41,7 @@ describe('ApprovalHttpClient', () => { const req = httpMock.expectOne( (request) => request.method === 'GET' && - request.url === '/api/v1/release-orchestrator/approvals' && + request.url === '/api/release-orchestrator/approvals' && request.params.get('environment') === 'prod' && !request.params.has('status') ); @@ -87,12 +87,11 @@ describe('ApprovalHttpClient', () => { const requests = httpMock.match((request) => request.method === 'POST' && - (request.url === '/api/v1/approvals/apr-001/decision' || - request.url === '/api/v1/approvals/apr-002/decision') + (request.url === '/api/release-orchestrator/approvals/apr-001/approve' || + request.url === '/api/release-orchestrator/approvals/apr-002/approve') ); expect(requests.length).toBe(2); - expect(requests.every((request) => request.request.body.action === 'approve')).toBeTrue(); expect(requests.every((request) => request.request.body.comment === 'Ship it')).toBeTrue(); for (const request of requests) { diff --git a/src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts b/src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts index c52fd7a06..ebc74f4ae 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/deployment.client.ts @@ -4,7 +4,7 @@ */ import { Injectable, InjectionToken, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of, BehaviorSubject, interval, map } from 'rxjs'; +import { Observable, of, BehaviorSubject, interval, map, switchMap } from 'rxjs'; import type { DeploymentSummary, Deployment, @@ -20,9 +20,27 @@ export interface DeploymentFilter { releases?: string[]; } +export interface PromotionStage { + name: string; + environmentId: string; +} + +export interface CreateDeploymentRequest { + releaseId: string; + environmentId: string; + environmentName?: string | null; + strategy: string; + strategyConfig?: unknown; + packageType?: string | null; + packageRefId?: string | null; + packageRefName?: string | null; + promotionStages?: PromotionStage[]; +} + export interface DeploymentApi { getDeployments(filter?: DeploymentFilter): Observable; getDeployment(id: string): Observable; + createDeployment(request: CreateDeploymentRequest): Observable; getDeploymentLogs(deploymentId: string, targetId?: string): Observable; getDeploymentEvents(deploymentId: string): Observable; getDeploymentMetrics(deploymentId: string): Observable; @@ -46,26 +64,64 @@ export class DeploymentHttpClient implements DeploymentApi { if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(','); if (filter?.environments?.length) params['environments'] = filter.environments.join(','); if (filter?.releases?.length) params['releases'] = filter.releases.join(','); - return this.http.get(this.baseUrl, { params }); + return this.http.get<{ items?: BackendDeploymentSummary[] }>(this.baseUrl, { params }).pipe( + map((response) => (response.items ?? []).map((item) => this.mapSummary(item))), + ); } getDeployment(id: string): Observable { - return this.http.get(`${this.baseUrl}/${id}`); + return this.http.get(`${this.baseUrl}/${id}`).pipe( + map((response) => this.mapDetail(response)), + ); + } + + createDeployment(request: CreateDeploymentRequest): Observable { + return this.http.post(this.baseUrl, request).pipe( + map((response) => this.mapDetail(response)), + ); } getDeploymentLogs(deploymentId: string, targetId?: string): Observable { const url = targetId ? `${this.baseUrl}/${deploymentId}/targets/${targetId}/logs` : `${this.baseUrl}/${deploymentId}/logs`; - return this.http.get(url); + return this.http.get<{ entries?: BackendLogEntry[] }>(url).pipe( + map((response) => (response.entries ?? []).map((entry) => ({ + timestamp: entry.timestamp, + level: entry.level, + source: entry.source, + targetId: entry.targetId ?? null, + message: entry.message, + }))), + ); } getDeploymentEvents(deploymentId: string): Observable { - return this.http.get(`${this.baseUrl}/${deploymentId}/events`); + return this.http.get<{ events?: BackendDeploymentEvent[] }>(`${this.baseUrl}/${deploymentId}/events`).pipe( + map((response) => (response.events ?? []).map((event) => ({ + id: event.id, + type: event.type, + targetId: event.targetId ?? null, + targetName: event.targetName ?? null, + message: event.message, + timestamp: event.timestamp, + }))), + ); } getDeploymentMetrics(deploymentId: string): Observable { - return this.http.get(`${this.baseUrl}/${deploymentId}/metrics`); + return this.http.get<{ metrics: BackendDeploymentMetrics }>(`${this.baseUrl}/${deploymentId}/metrics`).pipe( + map((response) => ({ + totalDuration: response.metrics.totalDuration, + averageTargetDuration: response.metrics.averageTargetDuration, + successRate: response.metrics.successRate, + rollbackCount: response.metrics.rollbackCount, + imagesPulled: response.metrics.imagesPulled, + containersStarted: response.metrics.containersStarted, + containersRemoved: response.metrics.containersRemoved, + healthChecksPerformed: response.metrics.healthChecksPerformed, + })), + ); } pause(deploymentId: string): Observable { @@ -89,9 +145,121 @@ export class DeploymentHttpClient implements DeploymentApi { } subscribeToUpdates(deploymentId: string): Observable { - // In production, this would use SignalR - return interval(2000).pipe(map(() => null as unknown as Deployment)); + return interval(2000).pipe(switchMap(() => this.getDeployment(deploymentId))); } + + private mapSummary(item: BackendDeploymentSummary): DeploymentSummary { + return { + id: item.id, + releaseId: item.releaseId, + releaseName: item.releaseName, + releaseVersion: item.releaseVersion, + environmentId: item.environmentId, + environmentName: item.environmentName, + status: item.status, + strategy: item.strategy, + progress: item.progress, + startedAt: item.startedAt, + completedAt: item.completedAt ?? null, + initiatedBy: item.initiatedBy, + targetCount: item.targetCount, + completedTargets: item.completedTargets, + failedTargets: item.failedTargets, + }; + } + + private mapDetail(item: BackendDeploymentDetail): Deployment { + return { + ...this.mapSummary(item), + targets: (item.targets ?? []).map((target) => ({ + id: target.id, + name: target.name, + type: target.type, + status: target.status, + progress: target.progress, + startedAt: target.startedAt ?? null, + completedAt: target.completedAt ?? null, + duration: target.duration ?? null, + agentId: target.agentId, + error: target.error ?? null, + previousVersion: target.previousVersion ?? null, + })), + currentStep: item.currentStep ?? null, + canPause: item.canPause, + canResume: item.canResume, + canCancel: item.canCancel, + canRollback: item.canRollback, + }; + } +} + +interface BackendDeploymentSummary { + id: string; + releaseId: string; + releaseName: string; + releaseVersion: string; + environmentId: string; + environmentName: string; + status: DeploymentSummary['status']; + strategy: DeploymentSummary['strategy']; + progress: number; + startedAt: string; + completedAt?: string | null; + initiatedBy: string; + targetCount: number; + completedTargets: number; + failedTargets: number; +} + +interface BackendDeploymentTarget { + id: string; + name: string; + type: DeploymentTarget['type']; + status: DeploymentTarget['status']; + progress: number; + startedAt?: string | null; + completedAt?: string | null; + duration?: number | null; + agentId: string; + error?: string | null; + previousVersion?: string | null; +} + +interface BackendDeploymentDetail extends BackendDeploymentSummary { + targets?: BackendDeploymentTarget[]; + currentStep?: string | null; + canPause: boolean; + canResume: boolean; + canCancel: boolean; + canRollback: boolean; +} + +interface BackendDeploymentEvent { + id: string; + type: DeploymentEvent['type']; + targetId?: string | null; + targetName?: string | null; + message: string; + timestamp: string; +} + +interface BackendLogEntry { + timestamp: string; + level: LogEntry['level']; + source: string; + targetId?: string | null; + message: string; +} + +interface BackendDeploymentMetrics { + totalDuration: number; + averageTargetDuration: number; + successRate: number; + rollbackCount: number; + imagesPulled: number; + containersStarted: number; + containersRemoved: number; + healthChecksPerformed: number; } @Injectable({ providedIn: 'root' }) @@ -213,6 +381,35 @@ export class MockDeploymentClient implements DeploymentApi { }); } + createDeployment(request: CreateDeploymentRequest): Observable { + const created: Deployment = { + id: `dep-${Date.now()}`, + releaseId: request.releaseId, + releaseName: request.packageRefName ?? request.releaseId, + releaseVersion: request.packageRefName ?? request.packageRefId ?? 'version-1', + environmentId: request.environmentId, + environmentName: request.environmentName ?? request.environmentId, + status: 'pending', + strategy: (request.strategy === 'blue_green' || request.strategy === 'canary' || request.strategy === 'all_at_once' + ? request.strategy + : 'rolling'), + progress: 0, + startedAt: new Date().toISOString(), + completedAt: null, + initiatedBy: 'mock-operator', + targetCount: 0, + completedTargets: 0, + failedTargets: 0, + targets: [], + currentStep: 'Queued for rollout', + canPause: false, + canResume: false, + canCancel: true, + canRollback: false, + }; + return of(created); + } + getDeploymentLogs(deploymentId: string, targetId?: string): Observable { const baseLogs: LogEntry[] = [ { timestamp: new Date(Date.now() - 280000).toISOString(), level: 'info', source: 'jobengine', targetId: null, message: 'Deployment started' }, diff --git a/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts b/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts index a334079e7..78967d98f 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { Inject, Injectable, InjectionToken } from '@angular/core'; import { Observable, of, throwError } from 'rxjs'; import { map, catchError, delay } from 'rxjs/operators'; @@ -210,6 +210,14 @@ export class ExportCenterHttpClient implements ExportCenterApi { } private mapError(err: unknown, traceId: string): Error { + if (err instanceof HttpErrorResponse) { + const payloadMessage = + typeof err.error === 'object' && err.error && 'message' in err.error + ? String((err.error as { message?: unknown }).message ?? '') + : ''; + const detail = payloadMessage || err.message || err.statusText || 'Unknown error'; + return new Error(`[${traceId}] Export Center error: ${detail}`); + } if (err instanceof Error) { return new Error(`[${traceId}] Export Center error: ${err.message}`); } diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-evidence.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-evidence.client.ts index e6a2c2146..2639a6af2 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-evidence.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-evidence.client.ts @@ -4,7 +4,8 @@ */ import { Injectable, InjectionToken, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of, delay } from 'rxjs'; +import { Observable, of, delay, forkJoin } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; import type { EvidencePacketSummary, EvidencePacketDetail, @@ -26,6 +27,41 @@ export interface ReleaseEvidenceApi { export const RELEASE_EVIDENCE_API = new InjectionToken('RELEASE_EVIDENCE_API'); +interface ReleaseEvidencePacketDto { + id: string; + releaseId: string; + type: string; + description: string; + hash: string; + algorithm: string; + sizeBytes: number; + status: string; + createdBy: string; + createdAt: string; + verifiedAt?: string | null; +} + +interface ReleaseEvidenceListResponseDto { + items: ReleaseEvidencePacketDto[]; + totalCount: number; + page: number; + pageSize: number; +} + +interface ReleaseEvidenceTimelineEventDto { + id: string; + evidenceId: string; + eventType: string; + actor: string; + message: string; + timestamp: string; +} + +interface ReleaseEvidenceTimelineResponseDto { + evidenceId: string; + events: ReleaseEvidenceTimelineEventDto[]; +} + @Injectable({ providedIn: 'root' }) export class ReleaseEvidenceHttpClient implements ReleaseEvidenceApi { private readonly http = inject(HttpClient); @@ -34,15 +70,29 @@ export class ReleaseEvidenceHttpClient implements ReleaseEvidenceApi { getEvidencePackets(filter?: EvidenceFilter): Observable { const params: Record = {}; if (filter?.search) params['search'] = filter.search; - if (filter?.signatureStatuses?.length) params['signatureStatuses'] = filter.signatureStatuses.join(','); - if (filter?.environmentId) params['environmentId'] = filter.environmentId; if (filter?.dateFrom) params['dateFrom'] = filter.dateFrom; if (filter?.dateTo) params['dateTo'] = filter.dateTo; - return this.http.get(this.baseUrl, { params }); + return this.http.get(this.baseUrl, { params }).pipe( + map((response) => ({ + items: (response.items ?? []).map((packet) => this.toEvidencePacketSummary(packet)), + total: response.totalCount ?? 0, + page: response.page ?? 1, + pageSize: response.pageSize ?? 20, + })), + ); } getEvidencePacket(id: string): Observable { - return this.http.get(`${this.baseUrl}/${id}`); + return this.http.get(`${this.baseUrl}/${id}`).pipe( + switchMap((packet) => + forkJoin({ + verification: this.verifyEvidence(id), + timeline: this.getTimeline(id), + }).pipe( + map(({ verification, timeline }) => this.toEvidencePacketDetail(packet, verification, timeline)), + ), + ), + ); } verifyEvidence(id: string): Observable { @@ -61,7 +111,150 @@ export class ReleaseEvidenceHttpClient implements ReleaseEvidenceApi { } getTimeline(id: string): Observable { - return this.http.get(`${this.baseUrl}/${id}/timeline`); + return this.http.get(`${this.baseUrl}/${id}/timeline`).pipe( + map((response) => (response.events ?? []).map((event) => this.toTimelineEvent(event))), + ); + } + + private toEvidencePacketSummary(packet: ReleaseEvidencePacketDto): EvidencePacketSummary { + const releaseVersion = this.extractReleaseVersion(packet.description); + const environmentName = this.extractEnvironmentName(packet.description); + const signatureStatus = packet.verifiedAt + ? 'valid' + : packet.status.toLowerCase() === 'pending' + ? 'unsigned' + : 'unsigned'; + + return { + id: packet.id, + deploymentId: packet.id, + releaseId: packet.releaseId, + releaseName: packet.releaseId, + releaseVersion, + environmentId: environmentName.toLowerCase(), + environmentName, + status: packet.status.toLowerCase() === 'pending' + ? 'pending' + : packet.status.toLowerCase() === 'failed' + ? 'failed' + : 'complete', + signatureStatus, + contentHash: packet.hash, + signedAt: packet.verifiedAt ?? null, + signedBy: packet.createdBy, + createdAt: packet.createdAt, + size: packet.sizeBytes, + contentTypes: [packet.type], + }; + } + + private toEvidencePacketDetail( + packet: ReleaseEvidencePacketDto, + verification: VerificationResult, + timeline: EvidenceTimelineEvent[], + ): EvidencePacketDetail { + const summary = this.toEvidencePacketSummary(packet); + const environmentId = summary.environmentId || 'unknown'; + const environmentName = summary.environmentName || 'Unknown'; + + return { + ...summary, + content: { + metadata: { + deploymentId: summary.deploymentId, + releaseId: summary.releaseId, + environmentId, + startedAt: summary.createdAt, + completedAt: packet.verifiedAt ?? packet.createdAt, + initiatedBy: packet.createdBy, + outcome: summary.status === 'failed' ? 'failure' : summary.status === 'pending' ? 'pending' : 'success', + }, + release: { + name: summary.releaseName, + version: summary.releaseVersion, + components: [], + }, + workflow: { + id: `wf-${packet.id}`, + name: 'Release evidence packet', + version: 1, + stepsExecuted: timeline.length, + stepsFailed: summary.status === 'failed' ? 1 : 0, + }, + targets: environmentName + ? [ + { + id: environmentId, + name: environmentName, + type: 'environment', + outcome: summary.status, + duration: 0, + }, + ] + : [], + approvals: [], + gateResults: [ + { + gateId: `${packet.id}-integrity`, + gateName: 'Evidence Integrity', + status: verification.valid ? 'passed' : 'failed', + evaluatedAt: verification.verifiedAt, + }, + ], + artifacts: [ + { + name: `${packet.id}.${packet.type.replace(/[^a-z0-9]+/gi, '-')}`, + type: packet.type, + digest: packet.hash, + size: packet.sizeBytes, + }, + ], + }, + signature: packet.verifiedAt + ? { + algorithm: packet.algorithm, + keyId: 'release-evidence-store', + signature: packet.hash, + signedAt: packet.verifiedAt, + signedBy: packet.createdBy, + certificate: null, + } + : null, + verificationResult: verification, + }; + } + + private toTimelineEvent(event: ReleaseEvidenceTimelineEventDto): EvidenceTimelineEvent { + return { + id: event.id, + type: this.mapTimelineType(event.eventType), + timestamp: event.timestamp, + actor: event.actor, + details: event.message, + }; + } + + private mapTimelineType(eventType: string): EvidenceTimelineEvent['type'] { + const normalized = eventType.trim().toLowerCase(); + if (normalized === 'signed') return 'signed'; + if (normalized === 'verified') return 'verified'; + if (normalized === 'exported') return 'exported'; + if (normalized === 'viewed') return 'viewed'; + return 'created'; + } + + private extractReleaseVersion(description: string): string { + const match = description.match(/\bv[0-9][0-9a-z.\-]*/i); + return match?.[0] ?? 'unknown'; + } + + private extractEnvironmentName(description: string): string { + const normalized = description.toLowerCase(); + if (normalized.includes('production')) return 'Production'; + if (normalized.includes('staging')) return 'Staging'; + if (normalized.includes('qa')) return 'QA'; + if (normalized.includes('dev')) return 'Development'; + return 'Unknown'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts index cbc069602..193a26a5a 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts @@ -1,50 +1,74 @@ /** * Scheduler API Client - * Provides schedule CRUD operations and impact preview. + * + * Aligned with the Scheduler WebService API contracts: + * - ScheduleContracts.cs (ScheduleCreateRequest, ScheduleUpdateRequest, ScheduleResponse, ScheduleCollectionResponse) + * - RunContracts.cs (RunCreateRequest, RunResponse, RunCollectionResponse, ImpactPreviewRequest/Response) + * - Enums.cs (ScheduleMode, RunState, RunTrigger, SelectorScope) */ import { Injectable, InjectionToken, Inject } from '@angular/core'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Observable, of } from 'rxjs'; -import { delay, map, switchMap } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import { StellaOpsHeaders } from '../http/stella-ops-headers'; import type { Schedule, + ScheduleMode, + ScheduleSelector, + ScheduleLimits, ScheduleImpactPreview, - ScheduleTaskType, - RetryPolicy, + ImpactPreviewSample, + SchedulerRun, + SchedulerRunStatus, + SchedulerRunTrigger, + SchedulerRunStats, } from '../../features/scheduler-ops/scheduler-ops.models'; // ============================================================================ -// DTOs +// DTOs (match backend request shapes) // ============================================================================ export interface CreateScheduleDto { name: string; - description: string; cronExpression: string; timezone: string; enabled: boolean; - taskType: ScheduleTaskType; - taskConfig?: Record; - tags?: string[]; - retryPolicy?: RetryPolicy; - concurrencyLimit?: number; + mode: ScheduleMode; + selection: ScheduleSelector; + limits?: ScheduleLimits; } export type UpdateScheduleDto = Partial; -interface SchedulerScheduleEnvelope { +// ============================================================================ +// Backend response envelope types +// ============================================================================ + +interface BackendScheduleEnvelope { readonly schedule?: Record; readonly summary?: Record | null; } -interface SchedulerScheduleCollectionResponse { - readonly schedules?: readonly SchedulerScheduleEnvelope[]; +interface BackendScheduleCollectionResponse { + readonly schedules?: readonly BackendScheduleEnvelope[]; } -interface SchedulerRunsPreviewResponse { +interface BackendRunEnvelope { + readonly run?: Record; +} + +interface BackendRunCollectionResponse { + readonly runs?: readonly Record[]; + readonly nextCursor?: string | null; +} + +interface BackendImpactPreviewResponse { readonly total?: number; + readonly usageOnly?: boolean; + readonly generatedAt?: string; + readonly snapshotId?: string | null; + readonly sample?: readonly Record[]; } // ============================================================================ @@ -60,7 +84,22 @@ export interface SchedulerApi { pauseSchedule(id: string): Observable; resumeSchedule(id: string): Observable; triggerSchedule(id: string): Observable; - previewImpact(schedule: CreateScheduleDto): Observable; + previewImpact(selector?: ScheduleSelector): Observable; + listRuns(options?: RunListOptions): Observable; + cancelRun(runId: string): Observable; + retryRun(runId: string): Observable; +} + +export interface RunListOptions { + scheduleId?: string; + state?: string; + limit?: number; + cursor?: string; +} + +export interface RunListResult { + runs: SchedulerRun[]; + nextCursor?: string; } export const SCHEDULER_API = new InjectionToken('SCHEDULER_API'); @@ -78,55 +117,43 @@ export class SchedulerHttpClient implements SchedulerApi { private readonly authSession: AuthSessionStore, ) {} + // --- Schedule endpoints --- + listSchedules(): Observable { - return this.http.get(`${this.baseUrl}/schedules/`, { + const params = new HttpParams().set('includeDisabled', 'false'); + return this.http.get(`${this.baseUrl}/schedules/`, { headers: this.buildHeaders(), + params, }).pipe( map((response) => this.mapScheduleList(response)), ); } getSchedule(id: string): Observable { - return this.http.get(`${this.baseUrl}/schedules/${id}`, { + return this.http.get(`${this.baseUrl}/schedules/${id}`, { headers: this.buildHeaders(), }).pipe( map((response) => this.mapSchedule(response)), ); } - createSchedule(schedule: CreateScheduleDto): Observable { - const payload = this.toCreateRequest(schedule); - return this.http.post(`${this.baseUrl}/schedules/`, payload, { + createSchedule(dto: CreateScheduleDto): Observable { + return this.http.post(`${this.baseUrl}/schedules/`, dto, { headers: this.buildHeaders(), }).pipe( map((response) => this.mapSchedule(response)), ); } - updateSchedule(id: string, schedule: UpdateScheduleDto): Observable { - const headers = this.buildHeaders(); - const payload = this.toUpdateRequest(schedule); - - return this.http.patch(`${this.baseUrl}/schedules/${id}`, payload, { - headers, + updateSchedule(id: string, dto: UpdateScheduleDto): Observable { + return this.http.patch(`${this.baseUrl}/schedules/${id}`, dto, { + headers: this.buildHeaders(), }).pipe( - switchMap((response) => { - if (schedule.enabled === undefined) { - return of(response); - } - - const toggle$ = schedule.enabled - ? this.http.post(`${this.baseUrl}/schedules/${id}/resume`, {}, { headers }) - : this.http.post(`${this.baseUrl}/schedules/${id}/pause`, {}, { headers }); - - return toggle$.pipe(map(() => response)); - }), map((response) => this.mapSchedule(response)), ); } deleteSchedule(id: string): Observable { - // Compatibility fallback: pausing removes the item from default list responses. return this.http.post(`${this.baseUrl}/schedules/${id}/pause`, {}, { headers: this.buildHeaders(), }); @@ -156,36 +183,74 @@ export class SchedulerHttpClient implements SchedulerApi { }); } - previewImpact(_schedule: CreateScheduleDto): Observable { - return this.http.post(`${this.baseUrl}/runs/preview`, { - selector: { - scope: 'all-images', - }, - usageOnly: true, - sampleSize: 10, - }, { + // --- Run endpoints --- + + listRuns(options?: RunListOptions): Observable { + let params = new HttpParams(); + if (options?.scheduleId) { + params = params.set('scheduleId', options.scheduleId); + } + if (options?.state) { + params = params.set('state', options.state); + } + if (options?.limit) { + params = params.set('limit', String(options.limit)); + } + if (options?.cursor) { + params = params.set('cursor', options.cursor); + } + + return this.http.get(`${this.baseUrl}/runs/`, { headers: this.buildHeaders(), + params, }).pipe( map((response) => { - const total = Number.isFinite(response?.total) ? Number(response.total) : 0; - const warnings = total > 1000 - ? [`Preview includes ${total} impacted records; consider a narrower selector.`] - : []; - + const rawRuns = Array.isArray(response?.runs) ? response.runs : []; return { - scheduleId: 'preview', - proposedChange: 'update', - affectedRuns: total, - nextRunTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - estimatedLoad: Math.min(100, Math.max(5, total > 0 ? Math.round(total / 20) : 5)), - conflicts: [], - warnings, - } satisfies ScheduleImpactPreview; + runs: rawRuns.map((r) => this.mapRun(r)), + nextCursor: response?.nextCursor ?? undefined, + }; }), ); } - private mapScheduleList(payload: SchedulerScheduleCollectionResponse | Schedule[]): Schedule[] { + cancelRun(runId: string): Observable { + return this.http.post(`${this.baseUrl}/runs/${runId}/cancel`, {}, { + headers: this.buildHeaders(), + }).pipe( + map((response) => this.mapRun(response?.run ?? response as Record)), + ); + } + + retryRun(runId: string): Observable { + return this.http.post(`${this.baseUrl}/runs/${runId}/retry`, {}, { + headers: this.buildHeaders(), + }).pipe( + map((response) => this.mapRun(response?.run ?? response as Record)), + ); + } + + // --- Impact preview --- + + previewImpact(selector?: ScheduleSelector): Observable { + const body: Record = { + selector: selector ?? { scope: 'all-images' }, + usageOnly: true, + sampleSize: 10, + }; + + return this.http.post(`${this.baseUrl}/runs/preview`, body, { + headers: this.buildHeaders(), + }).pipe( + map((response) => this.mapImpactPreview(response)), + ); + } + + // ============================================================================ + // Schedule mapping + // ============================================================================ + + private mapScheduleList(payload: BackendScheduleCollectionResponse | Schedule[]): Schedule[] { if (Array.isArray(payload)) { return payload.map((entry) => this.mapSchedule(entry)); } @@ -194,11 +259,12 @@ export class SchedulerHttpClient implements SchedulerApi { return entries.map((entry) => this.mapSchedule(entry)); } - private mapSchedule(payload: SchedulerScheduleEnvelope | Schedule): Schedule { - const envelope = payload as SchedulerScheduleEnvelope; + private mapSchedule(payload: BackendScheduleEnvelope | Schedule | Record): Schedule { + const envelope = payload as BackendScheduleEnvelope; const schedule = (envelope?.schedule ?? payload) as Record; const summary = envelope?.summary as Record | null | undefined; const limits = this.asRecord(schedule?.['limits']); + const selection = this.asRecord(schedule?.['selection']); const recentRuns = Array.isArray(summary?.['recentRuns']) ? summary['recentRuns'] as readonly Record[] @@ -207,91 +273,152 @@ export class SchedulerHttpClient implements SchedulerApi { ? this.readString(recentRuns[0], 'completedAt') : undefined; - const maxJobs = this.readNumber(limits, 'maxJobs'); - const maxRetries = maxJobs > 0 - ? Math.min(10, Math.max(1, Math.round(maxJobs / 10))) - : 3; - return { id: this.readString(schedule, 'id') || `sch-${Date.now()}`, name: this.readString(schedule, 'name') || 'Unnamed schedule', - description: this.readString(schedule, 'description') || '', cronExpression: this.readString(schedule, 'cronExpression') || '0 6 * * *', timezone: this.readString(schedule, 'timezone') || 'UTC', enabled: this.readBoolean(schedule, 'enabled', true), - taskType: this.inferTaskType(this.readString(schedule, 'mode')), - taskConfig: {}, + mode: this.normalizeMode(this.readString(schedule, 'mode')), + selection: { + scope: (this.readString(selection, 'scope') || 'all-images') as ScheduleSelector['scope'], + namespaces: this.readStringArray(selection, 'namespaces'), + repositories: this.readStringArray(selection, 'repositories'), + digests: this.readStringArray(selection, 'digests'), + includeTags: this.readStringArray(selection, 'includeTags'), + }, + limits: { + maxJobs: this.readNumberOrUndefined(limits, 'maxJobs'), + ratePerSecond: this.readNumberOrUndefined(limits, 'ratePerSecond'), + parallelism: this.readNumberOrUndefined(limits, 'parallelism'), + burst: this.readNumberOrUndefined(limits, 'burst'), + }, lastRunAt, - nextRunAt: undefined, createdAt: this.readString(schedule, 'createdAt') || new Date().toISOString(), updatedAt: this.readString(schedule, 'updatedAt') || new Date().toISOString(), createdBy: this.readString(schedule, 'createdBy') || 'system', - tags: [], - retryPolicy: { - maxRetries, - backoffMultiplier: 2, - initialDelayMs: 1000, - maxDelayMs: 60000, - }, - concurrencyLimit: Math.max(1, this.readNumber(limits, 'parallelism') || 1), }; } - private toCreateRequest(schedule: CreateScheduleDto): Record { + private normalizeMode(mode: string): ScheduleMode { + const lower = (mode || '').toLowerCase().replace(/_/g, '-'); + if (lower === 'content-refresh' || lower === 'contentrefresh') { + return 'content-refresh'; + } + return 'analysis-only'; + } + + // ============================================================================ + // Run mapping + // ============================================================================ + + private mapRun(raw: Record): SchedulerRun { + const stats = this.asRecord(raw['stats']); + const candidates = this.readNumber(stats, 'candidates'); + const completed = this.readNumber(stats, 'completed'); + + const startedAt = this.readString(raw, 'startedAt') || undefined; + const finishedAt = this.readString(raw, 'finishedAt') || undefined; + + let durationMs: number | undefined; + if (startedAt && finishedAt) { + const diff = Date.parse(finishedAt) - Date.parse(startedAt); + if (Number.isFinite(diff) && diff >= 0) { + durationMs = diff; + } + } + + const progress = candidates > 0 + ? Math.round((completed / candidates) * 100) + : 0; + return { - name: schedule.name, - cronExpression: schedule.cronExpression, - timezone: schedule.timezone, - enabled: schedule.enabled, - mode: this.toSchedulerMode(schedule.taskType), - selection: { - scope: 'all-images', - }, - limits: { - parallelism: schedule.concurrencyLimit ?? 1, - }, + id: this.readString(raw, 'id') || `run-${Date.now()}`, + scheduleId: this.readString(raw, 'scheduleId') || undefined, + scheduleName: this.readString(raw, 'scheduleId') || 'Ad-hoc run', + status: this.mapRunState(this.readString(raw, 'state')), + triggeredAt: this.readString(raw, 'createdAt') || new Date().toISOString(), + startedAt, + completedAt: finishedAt, + durationMs, + triggeredBy: this.mapRunTrigger(this.readString(raw, 'trigger')), + progress, + itemsProcessed: completed, + itemsTotal: candidates, + error: this.readString(raw, 'error') || undefined, + retryOf: this.readString(raw, 'retryOf') || undefined, + stats: stats ? { + candidates: this.readNumber(stats, 'candidates'), + deduped: this.readNumber(stats, 'deduped'), + queued: this.readNumber(stats, 'queued'), + completed: this.readNumber(stats, 'completed'), + deltas: this.readNumber(stats, 'deltas'), + newCriticals: this.readNumber(stats, 'newCriticals'), + newHigh: this.readNumber(stats, 'newHigh'), + newMedium: this.readNumber(stats, 'newMedium'), + newLow: this.readNumber(stats, 'newLow'), + } : undefined, }; } - private toUpdateRequest(schedule: UpdateScheduleDto): Record { - const request: Record = {}; - - if (schedule.name !== undefined) { - request['name'] = schedule.name; - } - if (schedule.cronExpression !== undefined) { - request['cronExpression'] = schedule.cronExpression; - } - if (schedule.timezone !== undefined) { - request['timezone'] = schedule.timezone; - } - if (schedule.taskType !== undefined) { - request['mode'] = this.toSchedulerMode(schedule.taskType); - } - if (schedule.concurrencyLimit !== undefined) { - request['limits'] = { parallelism: schedule.concurrencyLimit }; - } - - return request; - } - - private toSchedulerMode(taskType: ScheduleTaskType): string { - switch (taskType) { - case 'scan': - case 'cleanup': - case 'custom': - return 'analysis-only'; - default: - return 'content-refresh'; + private mapRunState(state: string): SchedulerRunStatus { + switch ((state || '').toLowerCase()) { + case 'planning': return 'pending'; + case 'queued': return 'queued'; + case 'running': return 'running'; + case 'completed': return 'completed'; + case 'error': return 'failed'; + case 'cancelled': return 'cancelled'; + default: return 'pending'; } } - private inferTaskType(mode: string): ScheduleTaskType { - return mode.toLowerCase() === 'content-refresh' - ? 'vulnerability-sync' - : 'scan'; + private mapRunTrigger(trigger: string): SchedulerRunTrigger { + switch ((trigger || '').toLowerCase()) { + case 'cron': return 'schedule'; + case 'manual': return 'manual'; + case 'conselier': + case 'excitor': + return 'automated'; + default: return 'manual'; + } } + // ============================================================================ + // Impact preview mapping + // ============================================================================ + + private mapImpactPreview(response: BackendImpactPreviewResponse): ScheduleImpactPreview { + const total = Number.isFinite(response?.total) ? Number(response.total) : 0; + const warnings = total > 1000 + ? [`Preview includes ${total} impacted records; consider a narrower selector.`] + : []; + + const sample: ImpactPreviewSample[] = Array.isArray(response?.sample) + ? response.sample.map((s) => ({ + imageDigest: this.readString(s as Record, 'imageDigest'), + registry: this.readString(s as Record, 'registry'), + repository: this.readString(s as Record, 'repository'), + namespaces: this.readStringArray(s as Record, 'namespaces'), + tags: this.readStringArray(s as Record, 'tags'), + usedByEntrypoint: this.readBoolean(s as Record, 'usedByEntrypoint', false), + })) + : []; + + return { + total, + usageOnly: response?.usageOnly ?? true, + generatedAt: response?.generatedAt ?? new Date().toISOString(), + snapshotId: response?.snapshotId ?? undefined, + sample, + warnings, + }; + } + + // ============================================================================ + // Helpers + // ============================================================================ + private readString(source: Record | null | undefined, key: string): string { const value = source?.[key]; return typeof value === 'string' ? value : ''; @@ -299,24 +426,38 @@ export class SchedulerHttpClient implements SchedulerApi { private readNumber(source: Record | null | undefined, key: string): number { const value = source?.[key]; - if (typeof value === 'number') { return value; } - if (typeof value === 'string') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } - return 0; } + private readNumberOrUndefined(source: Record | null | undefined, key: string): number | undefined { + const value = source?.[key]; + if (typeof value === 'number') { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + private readBoolean(source: Record | null | undefined, key: string, fallback: boolean): boolean { const value = source?.[key]; return typeof value === 'boolean' ? value : fallback; } + private readStringArray(source: Record | null | undefined, key: string): string[] { + const value = source?.[key]; + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + } + private asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null; } @@ -330,72 +471,3 @@ export class SchedulerHttpClient implements SchedulerApi { return new HttpHeaders(headers); } } - -// ============================================================================ -// Mock Implementation -// ============================================================================ - -@Injectable({ providedIn: 'root' }) -export class MockSchedulerClient implements SchedulerApi { - private schedules: Schedule[] = [ - { id: 'sch-1', name: 'Nightly Vulnerability Sync', description: 'Synchronize vulnerability feeds from upstream sources', cronExpression: '0 2 * * *', timezone: 'UTC', enabled: true, taskType: 'vulnerability-sync', taskConfig: { sources: ['osv', 'nvd'] }, lastRunAt: '2026-02-16T02:00:00Z', nextRunAt: '2026-02-17T02:00:00Z', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-02-15T00:00:00Z', createdBy: 'admin', tags: ['security', 'nightly'], retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: 1 }, - { id: 'sch-2', name: 'SBOM Refresh', description: 'Re-scan all registered artifacts for SBOM updates', cronExpression: '0 4 * * 0', timezone: 'UTC', enabled: true, taskType: 'sbom-refresh', taskConfig: { scope: 'all' }, lastRunAt: '2026-02-09T04:00:00Z', nextRunAt: '2026-02-16T04:00:00Z', createdAt: '2026-01-05T00:00:00Z', updatedAt: '2026-02-09T00:00:00Z', createdBy: 'admin', tags: ['sbom', 'weekly'], retryPolicy: { maxRetries: 2, backoffMultiplier: 2, initialDelayMs: 10000, maxDelayMs: 120000 }, concurrencyLimit: 2 }, - { id: 'sch-3', name: 'Advisory Update Check', description: 'Check for new security advisories from configured sources', cronExpression: '0 */6 * * *', timezone: 'UTC', enabled: true, taskType: 'advisory-update', taskConfig: { sources: ['cisa', 'mitre'] }, lastRunAt: '2026-02-16T00:00:00Z', nextRunAt: '2026-02-16T06:00:00Z', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z', createdBy: 'admin', tags: ['security', 'advisories'], retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: 1 }, - { id: 'sch-4', name: 'Evidence Export', description: 'Export evidence bundles to configured destinations', cronExpression: '0 6 1 * *', timezone: 'UTC', enabled: false, taskType: 'export', taskConfig: { destination: 's3', format: 'bundle' }, lastRunAt: '2026-02-01T06:00:00Z', nextRunAt: undefined, createdAt: '2026-01-15T00:00:00Z', updatedAt: '2026-02-15T10:00:00Z', createdBy: 'admin', tags: ['evidence', 'monthly'], retryPolicy: { maxRetries: 2, backoffMultiplier: 2, initialDelayMs: 15000, maxDelayMs: 300000 }, concurrencyLimit: 1 }, - ]; - - listSchedules(): Observable { - return of([...this.schedules]).pipe(delay(300)); - } - - getSchedule(id: string): Observable { - const s = this.schedules.find(s => s.id === id); - return of(s ?? this.schedules[0]).pipe(delay(200)); - } - - createSchedule(dto: CreateScheduleDto): Observable { - const s: Schedule = { ...dto, id: `sch-${Date.now()}`, taskConfig: dto.taskConfig ?? {}, lastRunAt: undefined, nextRunAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdBy: 'admin', tags: dto.tags ?? [], retryPolicy: dto.retryPolicy ?? { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: dto.concurrencyLimit ?? 1 }; - this.schedules.push(s); - return of(s).pipe(delay(300)); - } - - updateSchedule(id: string, dto: UpdateScheduleDto): Observable { - const idx = this.schedules.findIndex(s => s.id === id); - if (idx >= 0) { Object.assign(this.schedules[idx], dto, { updatedAt: new Date().toISOString() }); } - return of(this.schedules[idx] ?? this.schedules[0]).pipe(delay(300)); - } - - deleteSchedule(id: string): Observable { - this.schedules = this.schedules.filter(s => s.id !== id); - return of(void 0).pipe(delay(200)); - } - - pauseSchedule(id: string): Observable { - const s = this.schedules.find(s => s.id === id); - if (s) s.enabled = false; - return of(void 0).pipe(delay(200)); - } - - resumeSchedule(id: string): Observable { - const s = this.schedules.find(s => s.id === id); - if (s) s.enabled = true; - return of(void 0).pipe(delay(200)); - } - - triggerSchedule(_id: string): Observable { - return of(void 0).pipe(delay(200)); - } - - previewImpact(schedule: CreateScheduleDto): Observable { - return of({ - scheduleId: 'preview', - proposedChange: 'enable' as const, - affectedRuns: 0, - nextRunTime: new Date().toISOString(), - estimatedLoad: 0.15, - conflicts: [], - warnings: schedule.concurrencyLimit && schedule.concurrencyLimit > 3 ? ['High concurrency limit may impact other schedules'] : [], - }).pipe(delay(200)); - } -} - diff --git a/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts b/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts index e23472e05..a8499c661 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts @@ -31,9 +31,9 @@ export interface FindingDto { reachable: boolean | null; reachabilityConfidence?: number; vexStatus: string; - releaseId: string; - releaseVersion: string; - delta: string; + releaseId?: string; + releaseVersion?: string; + delta?: string; environments: string[]; firstSeen: string; } @@ -59,10 +59,15 @@ export interface SignedScoreProvenanceLinkDto { } export interface SignedScoreVerifyDto { - replaySuccessRatio: number; - medianVerifyTimeMs: number; - symbolCoverage: number; + replaySuccessRatio?: number | null; + medianVerifyTimeMs?: number | null; + symbolCoverage?: number | null; verifiedAt?: string; + valid?: boolean; + manifestValid?: boolean; + ledgerValid?: boolean; + canonicalInputHashValid?: boolean; + errorMessage?: string | null; } export type SignedScoreGateStatus = 'pass' | 'warn' | 'block'; @@ -83,7 +88,7 @@ export interface SignedScoreDto { factors: SignedScoreFactorDto[]; provenanceLinks: SignedScoreProvenanceLinkDto[]; verify?: SignedScoreVerifyDto; - gate: SignedScoreGateDto; + gate?: SignedScoreGateDto; } export interface DeployedEnvironmentDto { @@ -102,9 +107,10 @@ export interface GateImpactDto { export interface VulnerabilityDetailDto extends FindingDetailDto { cveId: string; findingId?: string; + proofSubjectId?: string; cvssVector?: string; - epss: number; - exploitedInWild: boolean; + epss?: number | null; + exploitedInWild?: boolean | null; fixedIn?: string; vexJustification?: string | null; deployedEnvironments: DeployedEnvironmentDto[]; @@ -135,15 +141,15 @@ interface SecurityFindingProjectionDto { cveId: string; severity: string; packageName: string; - componentName: string; - releaseId: string; - releaseName: string; - environment: string; - region: string; - reachable: boolean; - reachabilityScore: number; - effectiveDisposition: string; - vexStatus: string; + componentName?: string; + releaseId?: string; + releaseName?: string; + environment?: string; + region?: string; + reachable?: boolean | null; + reachabilityScore?: number; + effectiveDisposition?: string; + vexStatus?: string; updatedAt: string; } @@ -225,12 +231,7 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { }) .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))), - ), - ), + catchError((error: unknown) => throwError(() => this.normalizeVulnerabilityError(error, id))), ); } @@ -247,15 +248,15 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { return { id: row.findingId, package: row.packageName, - version: row.componentName || 'n/a', + version: row.componentName || '', severity: this.mapSeverity(row.severity), cvss: Math.round((Math.max(0, row.reachabilityScore ?? 0) / 10) * 10) / 10, - reachable: row.reachable, + reachable: this.toNullableBoolean(row.reachable), reachabilityConfidence: row.reachabilityScore, vexStatus: row.vexStatus || row.effectiveDisposition || 'none', releaseId: row.releaseId, releaseVersion: row.releaseName, - delta: 'carried', + delta: undefined, environments: row.environment ? [row.environment] : [], firstSeen: row.updatedAt, }; @@ -266,14 +267,14 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { findingId: row?.findingId ?? fallbackId, cveId: row?.cveId ?? fallbackId, severity: row?.severity ?? 'medium', - packageName: row?.packageName ?? 'unknown', - componentName: row?.componentName ?? 'unknown', - releaseId: row?.releaseId ?? '', - releaseName: row?.releaseName ?? '', + packageName: row?.packageName ?? '', + componentName: row?.componentName ?? '', + releaseId: row?.releaseId ?? undefined, + releaseName: row?.releaseName ?? undefined, environment: row?.environment ?? '', region: row?.region ?? '', - reachable: row?.reachable ?? true, - reachabilityScore: row?.reachabilityScore ?? 0, + reachable: row?.reachable ?? null, + reachabilityScore: row?.reachabilityScore ?? undefined, effectiveDisposition: row?.effectiveDisposition ?? 'unknown', vexStatus: row?.vex?.status ?? row?.effectiveDisposition ?? 'none', updatedAt: row?.updatedAt ?? new Date().toISOString(), @@ -295,17 +296,17 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { 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 ?? '', + packageName: row?.packageName ?? row?.package ?? '', + componentName: row?.componentName ?? row?.version ?? '', + releaseId: row?.releaseId ?? row?.release?.id ?? undefined, + releaseName: row?.releaseVersion ?? row?.releaseName ?? row?.release?.name ?? undefined, environment: row?.environment ?? '', region: row?.region ?? '', - reachable: row?.reachable ?? row?.reachability?.reachable ?? true, - reachabilityScore: row?.reachabilityScore ?? row?.reachability?.confidence ?? 0, + reachable: row?.reachable ?? row?.reachability?.reachable ?? null, + reachabilityScore: row?.reachabilityConfidence ?? row?.reachabilityScore ?? row?.reachability?.confidence ?? undefined, effectiveDisposition: row?.effectiveDisposition ?? row?.vex?.status ?? 'unknown', vexStatus: row?.vexStatus ?? row?.vex?.status ?? 'none', - updatedAt: row?.updatedAt ?? new Date().toISOString(), + updatedAt: row?.firstSeen ?? row?.updatedAt ?? new Date().toISOString(), }, fallbackId, ); @@ -319,112 +320,55 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { id: cveId, cveId, findingId: row?.findingId ?? finding.id, + proofSubjectId: typeof row?.proofSubjectId === 'string' ? row.proofSubjectId : undefined, 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), + epss: this.normalizeProbabilityOrNull(row?.epss), + exploitedInWild: this.toNullableBoolean(row?.exploitedInWild ?? row?.kevListed), description, references: this.toStringArray(row?.references, []), affectedVersions, fixedVersions, - fixedIn: fixedVersions[0], + fixedIn: typeof row?.fixedIn === 'string' ? row.fixedIn : fixedVersions[0], vexJustification: typeof row?.vexJustification === 'string' ? row.vexJustification : null, - deployedEnvironments: this.toEnvironmentList(row?.deployedEnvironments), + deployedEnvironments: this.toEnvironmentList(row?.deployedEnvironments, row?.environments, row?.releaseVersion ?? row?.releaseName, row?.releaseId), gateImpacts: this.toGateImpacts(row?.gateImpacts), witnessPath: this.toStringArray(row?.witnessPath, []), - signedScore: this.toSignedScore(row?.signedScore, cveId), + signedScore: this.toSignedScore(row?.signedScore), }; } - 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.'; + private toEnvironmentList( + value: unknown, + environments?: unknown, + releaseVersion?: unknown, + releaseId?: unknown, + ): DeployedEnvironmentDto[] { + if (Array.isArray(value)) { + return value + .map((entry) => { + const item = entry as Record; + return { + name: String(item['name'] ?? item['environment'] ?? ''), + version: String(item['version'] ?? item['releaseVersion'] ?? ''), + deployedAt: typeof item['deployedAt'] === 'string' ? item['deployedAt'] : undefined, + releaseId: typeof item['releaseId'] === 'string' ? item['releaseId'] : undefined, + } satisfies DeployedEnvironmentDto; + }) + .filter((entry) => entry.name.length > 0); + } - 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)) { + if (!Array.isArray(environments)) { return []; } - return value - .map((entry) => { - const item = entry as Record; - 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); + return environments + .map((entry) => String(entry)) + .filter((entry) => entry.length > 0) + .map((entry) => ({ + name: entry, + version: typeof releaseVersion === 'string' ? releaseVersion : '', + releaseId: typeof releaseId === 'string' ? releaseId : undefined, + } satisfies DeployedEnvironmentDto)); } private toGateImpacts(value: unknown): GateImpactDto[] { @@ -448,38 +392,40 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { .filter((entry) => entry.gateType.length > 0); } - private toSignedScore(value: unknown, cveId: string): SignedScoreDto | undefined { + private toSignedScore(value: unknown): SignedScoreDto | undefined { if (!value || typeof value !== 'object') { return undefined; } const source = value as Record; - 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.'; + const actual = this.normalizePercentageOrNull(source['score']); + const policyVersion = typeof source['policyVersion'] === 'string' ? source['policyVersion'] : undefined; + const computedAt = typeof source['computedAt'] === 'string' ? source['computedAt'] : undefined; + if (actual === null || !policyVersion || !computedAt) { + return undefined; + } + + const threshold = this.normalizePercentageOrNull(source['threshold']); + const gateStatus = this.toGateStatus(source['gateStatus']); + const gate = threshold !== null && gateStatus + ? { + status: gateStatus, + threshold, + actual, + reason: typeof source['gateReason'] === 'string' ? source['gateReason'] : '', + } satisfies SignedScoreGateDto + : undefined; return { score: actual, - policyVersion: String(source['policyVersion'] ?? 'ews.v1.2'), - computedAt: String(source['computedAt'] ?? new Date().toISOString()), + policyVersion, + computedAt, 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), + provenanceLinks: this.toProvenanceLinks(source['provenanceLinks']), verify: this.toVerifySummary(source['verify']), - gate: { - status: gateStatus, - threshold, - actual, - reason, - }, + gate, }; } @@ -502,11 +448,9 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { .filter((factor) => Number.isFinite(factor.weight) && Number.isFinite(factor.raw) && Number.isFinite(factor.weighted)); } - private toProvenanceLinks(value: unknown, id: string): SignedScoreProvenanceLinkDto[] { + private toProvenanceLinks(value: unknown): SignedScoreProvenanceLinkDto[] { if (!Array.isArray(value)) { - return [ - { label: 'Replay history', href: `/api/v1/scans/${encodeURIComponent(id)}/score/history` }, - ]; + return []; } return value @@ -526,22 +470,53 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { } const item = value as Record; + const replaySuccessRatio = this.normalizeProbabilityOrNull(item['replaySuccessRatio']); + const medianVerifyTimeMs = this.toRoundedIntegerOrNull(item['medianVerifyTimeMs']); + const symbolCoverage = this.normalizePercentageOrNull(item['symbolCoverage']); + const verifiedAt = typeof item['verifiedAt'] === 'string' + ? item['verifiedAt'] + : typeof item['verifiedAtUtc'] === 'string' + ? item['verifiedAtUtc'] + : undefined; + const valid = this.toNullableBoolean(item['valid']); + const manifestValid = this.toNullableBoolean(item['manifestValid']); + const ledgerValid = this.toNullableBoolean(item['ledgerValid']); + const canonicalInputHashValid = this.toNullableBoolean(item['canonicalInputHashValid']); + const errorMessage = typeof item['errorMessage'] === 'string' ? item['errorMessage'] : null; + + if ( + replaySuccessRatio === null && + medianVerifyTimeMs === null && + symbolCoverage === null && + valid === null && + manifestValid === null && + ledgerValid === null && + canonicalInputHashValid === null && + !verifiedAt && + !errorMessage + ) { + return undefined; + } + 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, + replaySuccessRatio, + medianVerifyTimeMs, + symbolCoverage, + verifiedAt, + valid: valid ?? undefined, + manifestValid: manifestValid ?? undefined, + ledgerValid: ledgerValid ?? undefined, + canonicalInputHashValid: canonicalInputHashValid ?? undefined, + errorMessage, }; } - private toGateStatus(value: unknown, actual: number, threshold: number): SignedScoreGateStatus { + private toGateStatus(value: unknown): SignedScoreGateStatus | undefined { 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'; + return undefined; } private toStringArray(value: unknown, fallback: string[]): string[] { @@ -551,22 +526,31 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { return value.map((entry) => String(entry)).filter((entry) => entry.length > 0); } - private normalizeProbability(value: unknown, fallback: number): number { + private normalizeProbabilityOrNull(value: unknown): number | null { const candidate = Number(value); if (!Number.isFinite(candidate)) { - return Number(fallback.toFixed(2)); + return null; } return Number(Math.min(1, Math.max(0, candidate)).toFixed(2)); } - private normalizePercentage(value: unknown, fallback: number): number { + private normalizePercentageOrNull(value: unknown): number | null { const candidate = Number(value); if (!Number.isFinite(candidate)) { - return Math.round(fallback); + return null; } return Math.round(Math.min(100, Math.max(0, candidate))); } + private toRoundedIntegerOrNull(value: unknown): number | null { + const candidate = Number(value); + if (!Number.isFinite(candidate)) { + return null; + } + + return Math.max(0, Math.round(candidate)); + } + private mapSeverity(value: string): FindingDto['severity'] { const normalized = (value ?? '').toUpperCase(); if (normalized === 'CRITICAL' || normalized === 'HIGH' || normalized === 'MEDIUM' || normalized === 'LOW') { @@ -575,18 +559,18 @@ 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); + private toNullableBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') { + return value; } - return (hash >>> 0) / 4294967295; + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + + return null; } private normalizeVulnerabilityError(error: unknown, vulnerabilityId: string): Error { @@ -741,4 +725,3 @@ export class MockSecurityFindingsClient implements SecurityFindingsApi { return of(detail).pipe(delay(220)); } } - diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts index 0d20ec247..8e9adec1d 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.spec.ts @@ -54,9 +54,9 @@ describe('PlatformContextStore', () => { regions: ['us-east'], environments: ['dev'], timeWindow: '24h', + stage: 'all', }); expect(req.request.body.tenantId).toBeUndefined(); - expect(req.request.body.stage).toBeUndefined(); req.flush({ tenantId: 'demo-prod', @@ -90,9 +90,9 @@ describe('PlatformContextStore', () => { regions: [], environments: ['dev'], timeWindow: '24h', + stage: 'all', }); expect(req.request.body.tenantId).toBeUndefined(); - expect(req.request.body.stage).toBeUndefined(); req.flush({ tenantId: 'demo-prod', diff --git a/src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts b/src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts index 2d6c8c97c..f46553b9d 100644 --- a/src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts @@ -9,7 +9,7 @@ */ import { HttpBackend, HttpClient } from '@angular/common/http'; -import { Injectable, computed, signal } from '@angular/core'; +import { Inject, Injectable, computed, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import fallbackBgBg from '../../../i18n/bg-BG.common.json'; @@ -57,7 +57,7 @@ export class I18nService { /** Whether translations are loaded */ readonly isLoaded = computed(() => Object.keys(this._translations()).length > 0); - constructor(httpBackend: HttpBackend) { + constructor(@Inject(HttpBackend) httpBackend: HttpBackend) { // Use raw HttpClient to avoid DI cycles with interceptors that might // depend on config/auth services not yet initialized. this.http = new HttpClient(httpBackend); diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.service.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.service.ts index 567c8827c..689db550b 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.service.ts @@ -106,7 +106,7 @@ export class NavigationService { const _ = this.activeRoute(); // Subscribe to route changes this._mobileMenuOpen.set(false); this._activeDropdown.set(null); - }, { allowSignalWrites: true }); + }); } // ------------------------------------------------------------------------- @@ -265,3 +265,4 @@ export class NavigationService { } } } + diff --git a/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts index 846e108a5..c6c0850bf 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts @@ -1,4 +1,4 @@ -import { signal } from '@angular/core'; +import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; @@ -7,11 +7,39 @@ import { BehaviorSubject, of } from 'rxjs'; import { CompareViewComponent } from '../../features/compare/components/compare-view/compare-view.component'; import { FindingsContainerComponent } from '../../features/findings/container/findings-container.component'; +import { FindingsListComponent } from '../../features/findings/findings-list.component'; import { CompareService } from '../../features/compare/services/compare.service'; import { SECURITY_FINDINGS_API } from '../api/security-findings.client'; +import { FindingsViewToggleComponent } from '../../shared/components/findings-view-toggle/findings-view-toggle.component'; import { ViewPreferenceService, FindingsViewMode } from '../services/view-preference.service'; import { MockScoringApi, SCORING_API } from '../services/scoring.service'; +@Component({ + selector: 'stella-findings-view-toggle', + standalone: true, + template: '', +}) +class StubFindingsViewToggleComponent {} + +@Component({ + selector: 'stella-compare-view', + standalone: true, + template: 'Comparing: {{ currentId() }}', +}) +class StubCompareViewComponent { + readonly currentId = input(null); +} + +@Component({ + selector: 'app-findings-list', + standalone: true, + template: '@for (finding of findings(); track finding.id) {
{{ finding.advisoryId }}
}', +}) +class StubFindingsListComponent { + readonly findings = input>([]); + readonly findingSelect = output(); +} + describe('FindingsContainerComponent', () => { let fixture: ComponentFixture; let queryParamMap$: BehaviorSubject>; @@ -101,7 +129,24 @@ describe('FindingsContainerComponent', () => { }, }, ], - }).compileComponents(); + }) + .overrideComponent(FindingsContainerComponent, { + remove: { + imports: [ + CompareViewComponent, + FindingsListComponent, + FindingsViewToggleComponent, + ], + }, + add: { + imports: [ + StubCompareViewComponent, + StubFindingsListComponent, + StubFindingsViewToggleComponent, + ], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(FindingsContainerComponent); fixture.detectChanges(); @@ -144,7 +189,7 @@ describe('FindingsContainerComponent', () => { }); it('passes the resolved scan id into the embedded compare view', () => { - const compareView = fixture.debugElement.query(By.directive(CompareViewComponent)); + const compareView = fixture.debugElement.query(By.directive(StubCompareViewComponent)); expect(compareView).not.toBeNull(); expect(compareView.componentInstance.currentId()).toBe('test-scan-123'); }); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts index fb33384ad..3b3723c7a 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts @@ -1,11 +1,22 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RouterLink, provideRouter } from '@angular/router'; import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { AUTH_SERVICE, type AuthService } from '../auth/auth.service'; import { PlatformContextStore } from '../context/platform-context.store'; +import { PageActionService } from '../services/page-action.service'; +import { VULNERABILITY_API, type VulnerabilityApi } from '../api/vulnerability.client'; import { DashboardV3Component } from '../../features/dashboard-v3/dashboard-v3.component'; +import { + SourceManagementApi, + type SourceStatusResponse, +} from '../../features/integrations/advisory-vex-sources/source-management.api'; import { MissionActivityPageComponent } from '../../features/mission-control/mission-activity-page.component'; import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component'; +import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service'; +import { StellaPreferencesService } from '../../shared/components/stella-helper/stella-preferences.service'; function routerLinksFor(component: T): RouterLink[] { const fixture = TestBed.createComponent(component as never); @@ -14,6 +25,113 @@ function routerLinksFor(component: T): RouterLink[] { } describe('Mission scope-preserving links', () => { + const originalResizeObserver = globalThis.ResizeObserver; + + const vulnerabilityApiStub: Pick = { + getStats: jasmine.createSpy('getStats').and.returnValue( + of({ + total: 6, + bySeverity: { critical: 2, high: 3, medium: 1, low: 0, unknown: 0 }, + byStatus: { open: 4, fixed: 1, wont_fix: 0, in_progress: 1, excepted: 0 }, + withExceptions: 1, + criticalOpen: 2, + computedAt: '2026-04-05T00:00:00Z', + traceId: 'trace-dashboard', + }) + ), + }; + + const sourceStatusResponse: SourceStatusResponse = { + sources: [ + { + sourceId: 'nvd', + enabled: true, + lastCheck: { + sourceId: 'nvd', + status: 'healthy', + checkedAt: '2026-04-05T00:00:00Z', + latency: '80ms', + possibleReasons: [], + remediationSteps: [], + isHealthy: true, + }, + }, + ], + }; + + const sourceManagementApiStub: Pick = { + getStatus: jasmine.createSpy('getStatus').and.returnValue(of(sourceStatusResponse)), + }; + + const authServiceStub: AuthService = { + isAuthenticated: signal(true), + user: signal({ + id: 'user-1', + email: 'operator@example.com', + name: 'Operator', + tenantId: 'acme-tenant', + tenantName: 'Acme Tenant', + roles: ['operator'], + scopes: [], + }), + scopes: signal([]), + hasScope: () => false, + hasAllScopes: () => false, + hasAnyScope: () => false, + canViewGraph: () => false, + canEditGraph: () => false, + canExportGraph: () => false, + canSimulate: () => false, + canViewOrchestrator: () => false, + canOperateOrchestrator: () => false, + canManageJobEngineQuotas: () => false, + canInitiateBackfill: () => false, + canViewPolicies: () => false, + canAuthorPolicies: () => false, + canEditPolicies: () => false, + canReviewPolicies: () => false, + canApprovePolicies: () => false, + canOperatePolicies: () => false, + canActivatePolicies: () => false, + canSimulatePolicies: () => false, + canPublishPolicies: () => false, + canAuditPolicies: () => false, + }; + + const pageActionStub: Pick = { + set: jasmine.createSpy('set'), + clear: jasmine.createSpy('clear'), + }; + + const helperContextStub: Pick = { + setScope: jasmine.createSpy('setScope'), + clearScope: jasmine.createSpy('clearScope'), + }; + + const preferencesStub: Pick = { + isBannerDismissed: () => true, + dismissBanner: jasmine.createSpy('dismissBanner'), + }; + + beforeAll(() => { + class MockResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + + (globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = + MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterAll(() => { + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + Reflect.deleteProperty(globalThis as object, 'ResizeObserver'); + } + }); + beforeEach(() => { TestBed.configureTestingModule({ imports: [DashboardV3Component, MissionAlertsPageComponent, MissionActivityPageComponent], @@ -23,15 +141,18 @@ describe('Mission scope-preserving links', () => { provide: PlatformContextStore, useValue: { initialize: () => undefined, - selectedRegions: () => ['us-east'], - timeWindow: () => '7d', + initialized: signal(true), + selectedRegions: signal(['us-east']), + timeWindow: signal('7d'), + error: signal(null), + tenantId: signal('acme-tenant'), setRegions: () => undefined, setTimeWindow: () => undefined, - regions: () => [ + regions: signal([ { regionId: 'eu-west', displayName: 'EU West' }, { regionId: 'us-east', displayName: 'US East' }, - ], - environments: () => [ + ]), + environments: signal([ { environmentId: 'dev', regionId: 'eu-west', @@ -44,9 +165,15 @@ describe('Mission scope-preserving links', () => { environmentType: 'staging', displayName: 'Staging US East', }, - ], + ]), }, }, + { provide: VULNERABILITY_API, useValue: vulnerabilityApiStub }, + { provide: SourceManagementApi, useValue: sourceManagementApiStub }, + { provide: AUTH_SERVICE, useValue: authServiceStub }, + { provide: PageActionService, useValue: pageActionStub }, + { provide: StellaHelperContextService, useValue: helperContextStub }, + { provide: StellaPreferencesService, useValue: preferencesStub }, ], }); }); @@ -66,9 +193,20 @@ describe('Mission scope-preserving links', () => { }); it('marks dashboard mission navigation links to merge the active query scope', () => { - const links = routerLinksFor(DashboardV3Component); + const fixture = TestBed.createComponent(DashboardV3Component); + fixture.detectChanges(); + fixture.detectChanges(); - expect(links.length).toBeGreaterThan(0); - expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + const directives = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => ({ + text: (debugElement.nativeElement.textContent ?? '').trim(), + link: debugElement.injector.get(RouterLink), + })); + const mergedLinks = directives.filter((item) => item.link.queryParamsHandling === 'merge'); + + expect(mergedLinks.length).toBeGreaterThanOrEqual(5); + expect(mergedLinks.every((item) => item.link.queryParamsHandling === 'merge')).toBeTrue(); + expect(mergedLinks.some((item) => item.text.includes('View Findings'))).toBeTrue(); + expect(mergedLinks.some((item) => item.text.includes('Manage Sources'))).toBeTrue(); + expect(mergedLinks.some((item) => item.text.includes('Open all'))).toBeTrue(); }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts index 889fb6cf9..a64a2dc48 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts @@ -127,6 +127,7 @@ const mockContextStore = { contextVersion: signal(0), regionSummary: () => 'US East', environmentSummary: () => 'Staging', + stage: () => 'steady', selectedRegions: () => ['us-east'], selectedEnvironments: () => ['stage'], regions: () => [ @@ -185,6 +186,74 @@ const mockTopologyDataService = { const mockHttpClient = { get: jasmine.createSpy('get').and.callFake((url: string) => { switch (url) { + case '/api/v2/topology/layout': + return of({ + nodes: [ + { + id: 'region-us-east', + label: 'US East', + kind: 'region', + parentNodeId: null, + x: 0, + y: 0, + width: 240, + height: 120, + regionId: 'us-east', + hostCount: 1, + targetCount: 1, + isFrozen: false, + promotionPathCount: 1, + deployingCount: 0, + pendingCount: 0, + failedCount: 0, + totalDeployments: 1, + }, + { + id: 'env-stage', + label: 'Staging', + kind: 'environment', + parentNodeId: 'region-us-east', + x: 32, + y: 48, + width: 180, + height: 72, + environmentId: 'stage', + regionId: 'us-east', + environmentType: 'steady', + healthStatus: 'healthy', + hostCount: 1, + targetCount: 1, + isFrozen: false, + promotionPathCount: 1, + deployingCount: 0, + pendingCount: 0, + failedCount: 0, + totalDeployments: 1, + }, + ], + edges: [ + { + id: 'path-dev-stage', + sourceNodeId: 'env-stage', + targetNodeId: 'env-stage', + sections: [ + { + startPoint: { x: 32, y: 48 }, + endPoint: { x: 180, y: 72 }, + bendPoints: [], + }, + ], + requiredApprovals: 1, + }, + ], + metadata: { + regionCount: 1, + environmentCount: 1, + promotionPathCount: 1, + canvasWidth: 640, + canvasHeight: 320, + }, + }); case '/api/v1/release-orchestrator/releases/activity': return of({ items: [ @@ -230,6 +299,39 @@ const mockHttpClient = { }, ], }); + case '/api/v2/topology/targets': + return of({ + items: [ + { + targetId: 'target-stage', + name: 'api-stage', + regionId: 'us-east', + environmentId: 'stage', + targetType: 'vm', + healthStatus: 'healthy', + hostId: 'host-stage', + agentId: 'agent-stage', + componentVersionId: 'component-stage', + lastSyncAt: '2026-03-10T00:00:00Z', + }, + ], + }); + case '/api/v2/topology/hosts': + return of({ + items: [ + { + hostId: 'host-stage', + hostName: 'host-stage', + regionId: 'us-east', + environmentId: 'stage', + runtimeType: 'containerd', + status: 'healthy', + targetCount: 1, + agentId: 'agent-stage', + lastSeenAt: '2026-03-10T00:00:00Z', + }, + ], + }); default: return of({ items: [] }); } @@ -250,6 +352,11 @@ function configureTestingModule(component: Type): void { { provide: ActivatedRoute, useValue: { + snapshot: { + queryParamMap: queryParamMap$.value, + paramMap: paramMap$.value, + params: { environmentId: 'stage' }, + }, data: routeData$.asObservable(), queryParamMap: queryParamMap$.asObservable(), paramMap: paramMap$.asObservable(), @@ -281,12 +388,12 @@ describe('Topology scope-preserving links', () => { it('marks topology page links to merge the active query scope', () => { const cases: Array<{ component: Type; routeData?: Record; expectedMinCount: number }> = [ { component: TopologyOverviewPageComponent, expectedMinCount: 4 }, - { component: TopologyGraphPageComponent, expectedMinCount: 1 }, - { component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 }, + { component: TopologyGraphPageComponent, expectedMinCount: 0 }, + { component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 0 }, { component: TopologyTargetsPageComponent, expectedMinCount: 3 }, - { component: TopologyHostsPageComponent, expectedMinCount: 3 }, + { component: TopologyHostsPageComponent, expectedMinCount: 0 }, { component: TopologyAgentsPageComponent, expectedMinCount: 3 }, - { component: EnvironmentPosturePageComponent, expectedMinCount: 3 }, + { component: EnvironmentPosturePageComponent, expectedMinCount: 0 }, ]; for (const testCase of cases) { @@ -294,9 +401,10 @@ describe('Topology scope-preserving links', () => { routeData$.next(testCase.routeData ?? {}); const links = routerLinksFor(testCase.component); + const mergedLinks = links.filter((link) => link.queryParamsHandling === 'merge'); expect(links.length).toBeGreaterThanOrEqual(testCase.expectedMinCount); - expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + expect(mergedLinks.length).toBeGreaterThanOrEqual(testCase.expectedMinCount); } }); diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts index e9d33679a..6e2635886 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts @@ -599,7 +599,7 @@ export class ApprovalsInboxComponent { constructor() { effect(() => { this.helperCtx.setScope('approvals-inbox', this.helperContexts()); - }, { allowSignalWrites: true }); + }); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('approvals-inbox')); this.route.queryParamMap.subscribe((params) => { @@ -862,3 +862,4 @@ export class ApprovalsInboxComponent { }); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index a9cc082eb..f87d7684e 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -402,7 +402,7 @@ export class AuditLogDashboardComponent implements OnInit { constructor() { effect(() => { this.helperCtx.setScope('audit-log-dashboard', this.helperContexts()); - }, { allowSignalWrites: true }); + }); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard')); } @@ -505,3 +505,4 @@ export class AuditLogDashboardComponent implements OnInit { return events.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index f28c1fcfb..e651aea12 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -1867,7 +1867,7 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy { effect(() => { this.helperCtx.setScope('dashboard-v3', this.helperContexts()); - }, { allowSignalWrites: true }); + }); } ngOnInit(): void { @@ -2219,3 +2219,4 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy { .join(' '); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts index a78020726..cf0d4f53c 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts @@ -10,7 +10,6 @@ import { ExportDialogComponent } from './components/export-dialog/export-dialog. import { AppConfigService } from '../../core/config/app-config.service'; import { PageActionService } from '../../core/services/page-action.service'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; -import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component'; const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [ { id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, @@ -29,7 +28,6 @@ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [ CheckResultComponent, ExportDialogComponent, StellaPageTabsComponent, - PageActionOutletComponent, ], templateUrl: './doctor-dashboard.component.html', styleUrl: './doctor-dashboard.component.scss' diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts index 24df791ae..fb114e2f3 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts @@ -5,16 +5,19 @@ * Browse all evidence packets with search and filters. */ -import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, computed, inject, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component'; +import { EvidenceStore } from '../release-orchestrator/evidence/evidence.store'; +import { AUDIT_BUNDLES_API } from '../../core/api/audit-bundles.client'; import { EvidencePacketDrawerComponent, type EvidenceContentItem, type EvidencePacketSummary, } from '../../shared/overlays/evidence-packet-drawer/evidence-packet-drawer.component'; +import type { EvidencePacketDetail as ReleaseEvidencePacketDetail } from '../../core/api/release-evidence.models'; interface EvidencePacket { id: string; @@ -77,6 +80,20 @@ interface EvidencePacket { + @if (loading()) { +
Loading evidence packets...
+ } + + @if (error(); as errorMessage) { +
{{ errorMessage }}
+ } + + @if (feedback(); as feedbackMessage) { +
+ {{ feedbackMessage }} +
+ } +
@@ -138,7 +155,7 @@ interface EvidencePacket { View Packet @@ -286,23 +303,43 @@ interface EvidencePacket { .empty-state { padding: 1.5rem !important; } + + .notice { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .notice--error { + border-color: var(--color-status-error-border, var(--color-severity-high)); + background: var(--color-status-error-bg, rgba(239, 68, 68, 0.08)); + color: var(--color-status-error-text, var(--color-severity-high)); + } `] }) export class EvidenceCenterPageComponent { + private readonly store = inject(EvidenceStore); + private readonly auditBundlesApi = inject(AUDIT_BUNDLES_API); + readonly searchQuery = signal(''); typeFilter = signal(''); verificationFilter = signal(''); readonly drawerOpen = signal(false); + readonly feedback = signal(null); + readonly feedbackTone = signal<'info' | 'error'>('info'); - packets = signal([ - { id: 'EVD-2026-045', type: 'promotion', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'QA', createdAt: '2h ago', signed: true, verified: true, containsProofChain: true }, - { id: 'EVD-2026-044', type: 'scan', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'Dev', createdAt: '3h ago', signed: true, verified: true, containsProofChain: false }, - { id: 'EVD-2026-043', type: 'deployment', bundleDigest: 'sha256:6bb1...', releaseVersion: 'v1.2.4', environment: 'Staging', createdAt: '6h ago', signed: true, verified: true, containsProofChain: true }, - { id: 'EVD-2026-042', type: 'attestation', bundleDigest: 'sha256:5cc1...', releaseVersion: 'v1.2.3', environment: 'Prod', createdAt: '1d ago', signed: true, verified: true, containsProofChain: true }, - { id: 'EVD-2026-041', type: 'exception', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', createdAt: '2d ago', signed: true, verified: false, containsProofChain: false }, - ]); + readonly loading = this.store.loading; + readonly error = this.store.error; - filteredPackets = computed(() => { + readonly packets = computed(() => + this.store.packets().map((packet) => this.toPacket(packet)), + ); + + readonly filteredPackets = computed(() => { let result = this.packets(); const query = this.searchQuery().toLowerCase(); const type = this.typeFilter(); @@ -325,8 +362,32 @@ export class EvidenceCenterPageComponent { } return result; }); - readonly drawerPacket = signal(this.toPacketSummary(this.packets()[0]!)); - readonly drawerContents = signal(this.buildPacketContents(this.packets()[0]!)); + readonly drawerPacket = signal({ + evidenceId: '', + type: 'release', + subject: 'No packet selected', + subjectDigest: '', + signed: false, + verified: false, + createdAt: '', + contentCount: 0, + totalSize: '0 B', + }); + readonly drawerContents = signal([]); + + constructor() { + this.store.loadPackets(); + + effect(() => { + const detail = this.store.selectedPacket(); + if (!detail || this.drawerPacket().evidenceId !== detail.id) { + return; + } + + this.drawerPacket.set(this.toPacketSummary(this.toPacket(detail), detail)); + this.drawerContents.set(this.buildPacketContents(detail)); + }); + } filterByType(event: Event): void { const select = event.target as HTMLSelectElement; @@ -339,8 +400,9 @@ export class EvidenceCenterPageComponent { } openPacketDrawer(packet: EvidencePacket): void { + this.store.loadPacket(packet.id); this.drawerPacket.set(this.toPacketSummary(packet)); - this.drawerContents.set(this.buildPacketContents(packet)); + this.drawerContents.set(this.buildPacketContents()); this.drawerOpen.set(true); } @@ -349,7 +411,10 @@ export class EvidenceCenterPageComponent { } verifyPacket(packet: EvidencePacket): void { - console.log('Verify packet:', packet.id); + this.feedback.set(null); + this.store.verifyEvidence(packet.id); + this.feedbackTone.set('info'); + this.feedback.set(`Verification requested for ${packet.id}.`); } verifyPacketById(packetId: string): void { @@ -360,7 +425,10 @@ export class EvidenceCenterPageComponent { } downloadPacket(packet: EvidencePacket): void { - console.log('Download packet:', packet.id); + this.feedback.set(null); + this.store.downloadRaw(packet.id); + this.feedbackTone.set('info'); + this.feedback.set(`Raw evidence download started for ${packet.id}.`); } downloadPacketById(packetId: string): void { @@ -371,10 +439,67 @@ export class EvidenceCenterPageComponent { } exportAuditBundle(): void { - console.log('Export audit bundle'); + const packet = this.filteredPackets()[0]; + if (!packet) { + this.feedbackTone.set('error'); + this.feedback.set('No evidence packets are available to seed an audit bundle export.'); + return; + } + + this.feedback.set(null); + this.auditBundlesApi.createBundle({ + subject: { + type: 'OTHER', + name: packet.id, + digest: { sha256: packet.bundleDigest }, + }, + contents: { + vulnReports: true, + sbom: true, + vex: true, + policyEvals: true, + attestations: true, + }, + }).subscribe({ + next: (job) => { + this.feedbackTone.set('info'); + this.feedback.set(`Audit bundle ${job.bundleId} queued for ${packet.id}.`); + }, + error: () => { + this.feedbackTone.set('error'); + this.feedback.set('Failed to create an audit bundle for the current evidence view.'); + }, + }); } - private toPacketSummary(packet: EvidencePacket): EvidencePacketSummary { + private toPacket(source: { + id: string; + contentHash: string; + releaseVersion: string; + environmentName: string; + createdAt: string; + signatureStatus: string; + contentTypes: string[]; + }): EvidencePacket { + const type = this.mapContentType(source.contentTypes[0]); + const signed = source.signatureStatus !== 'unsigned'; + const verified = source.signatureStatus === 'valid'; + return { + id: source.id, + type, + bundleDigest: source.contentHash, + releaseVersion: source.releaseVersion === 'unknown' ? undefined : source.releaseVersion, + environment: source.environmentName === 'Unknown' ? undefined : source.environmentName, + createdAt: this.formatTimestamp(source.createdAt), + signed, + verified, + containsProofChain: source.contentTypes.some((item) => + ['attestation', 'policy-decision', 'deployment-log', 'scan-result'].includes(item), + ), + }; + } + + private toPacketSummary(packet: EvidencePacket, detail?: ReleaseEvidencePacketDetail): EvidencePacketSummary { return { evidenceId: packet.id, type: this.mapPacketType(packet.type), @@ -383,33 +508,30 @@ export class EvidenceCenterPageComponent { signed: packet.signed, verified: packet.verified, createdAt: packet.createdAt, - contentCount: 3, - totalSize: '12 KB', - signedBy: 'ops-signing-key-2026', + contentCount: detail?.content.artifacts.length ?? this.drawerContents().length ?? 0, + totalSize: this.formatBytes(detail?.size ?? 0), + signedBy: detail?.signedBy ?? 'release-evidence-store', verifiedAt: packet.verified ? packet.createdAt : undefined, rekorEntry: packet.verified ? '184921' : undefined, }; } - private buildPacketContents(packet: EvidencePacket): EvidenceContentItem[] { + private buildPacketContents(detail?: ReleaseEvidencePacketDetail): EvidenceContentItem[] { + if (detail) { + return detail.content.artifacts.map((artifact) => ({ + name: artifact.name, + type: artifact.type, + digest: artifact.digest, + size: this.formatBytes(artifact.size), + })); + } + return [ { - name: `${packet.id}-manifest.json`, + name: 'Loading artifacts...', type: 'manifest', - digest: `${packet.bundleDigest}-manifest`, - size: '2.1 KB', - }, - { - name: `${packet.id}-policy.json`, - type: 'policy', - digest: `${packet.bundleDigest}-policy`, - size: '3.7 KB', - }, - { - name: `${packet.id}-attestation.dsse`, - type: 'attestation', - digest: `${packet.bundleDigest}-attestation`, - size: '6.2 KB', + digest: 'sha256:pending', + size: '0 B', }, ]; } @@ -430,4 +552,24 @@ export class EvidenceCenterPageComponent { return 'release'; } } + + private mapContentType(type: string | undefined): EvidencePacket['type'] { + const normalized = (type ?? '').toLowerCase(); + if (normalized.includes('scan')) return 'scan'; + if (normalized.includes('policy')) return 'exception'; + if (normalized.includes('deploy')) return 'deployment'; + if (normalized.includes('attestation')) return 'attestation'; + return 'promotion'; + } + + private formatTimestamp(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + + private formatBytes(sizeBytes: number): string { + if (!sizeBytes || sizeBytes < 1024) return `${sizeBytes || 0} B`; + if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`; + return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts index 19611db2d..7941b6c67 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts @@ -34,7 +34,7 @@ const PACKET_TABS: StellaPageTab[] = [ template: `
@@ -178,7 +178,7 @@ const PACKET_TABS: StellaPageTab[] = [
} } - + `, styles: [` diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts index 8db05111b..1faa50ebb 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts @@ -147,7 +147,7 @@ export class EvidencePageComponent { this.advisoryId.set(id); this.loadEvidence(id); } - }, { allowSignalWrites: true }); + }); } private loadEvidence(advisoryId: string): void { @@ -195,3 +195,4 @@ export class EvidencePageComponent { }); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-detail.component.ts index 0df6159e3..d5ef3f4e4 100644 --- a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-detail.component.ts @@ -99,9 +99,7 @@ export class ExceptionDetailComponent { this.labelEntries.set(this.mapLabels(exception.labels ?? {})); this.transitionComment.set(''); this.error.set(null); - }, - { allowSignalWrites: true } - ); + }); } addLabel(): void { @@ -208,3 +206,4 @@ export class ExceptionDetailComponent { return aKeys.every((key, idx) => key === bKeys[idx] && a[key] === b[key]); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/graph/graph-canvas.component.ts b/src/Web/StellaOps.Web/src/app/features/graph/graph-canvas.component.ts index f6ec38503..f2350a08a 100644 --- a/src/Web/StellaOps.Web/src/app/features/graph/graph-canvas.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/graph/graph-canvas.component.ts @@ -16,7 +16,12 @@ import { signal, } from '@angular/core'; -import type { GraphOverlayState, ReachabilityOverlayData } from './graph-overlays.component'; +import type { + AocOverlayData, + GraphOverlayState, + PolicyOverlayData, + VexOverlayData, +} from './graph-overlays.component'; export interface CanvasNode { readonly id: string; @@ -61,6 +66,11 @@ interface ViewportBounds { maxY: number; } +interface CanvasOverlayIndicator { + stroke: string; + summary: string; +} + const NODE_WIDTH = 160; const NODE_HEIGHT = 48; const LAYER_SPACING = 180; @@ -241,21 +251,21 @@ const VIEWPORT_PADDING = 100; role="button" [attr.aria-label]="getNodeAriaLabel(node)" > - @if (getReachabilityData(node.id); as reach) { + @if (getOverlayIndicator(node.id); as indicator) { - {{ reach.latticeState }} {{ reach.status }} ({{ (reach.confidence * 100).toFixed(0) }}%) - {{ reach.observedAt }} + {{ indicator.summary }} } @@ -602,7 +612,7 @@ const VIEWPORT_PADDING = 100; transition: filter 0.15s ease, stroke 0.15s ease; } - .reachability-halo { + .overlay-halo { pointer-events: none; } @@ -1176,30 +1186,37 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy } } - getReachabilityData(nodeId: string): ReachabilityOverlayData | null { - if (!this.overlayState) return null; - return this.overlayState.reachability.get(nodeId) ?? null; - } - - getReachabilityHaloStroke(latticeState: ReachabilityOverlayData['latticeState']): string { - switch (latticeState) { - case 'SR': - return 'var(--color-status-success)'; - case 'SU': - return 'var(--color-status-success)'; - case 'RO': - return 'var(--color-status-info-text)'; - case 'RU': - return 'var(--color-status-info)'; - case 'CR': - return 'var(--color-status-warning)'; - case 'CU': - return 'var(--color-severity-high)'; - case 'X': - return 'var(--color-text-muted)'; - default: - return 'var(--color-status-warning)'; + getOverlayIndicator(nodeId: string): CanvasOverlayIndicator | null { + if (!this.overlayState) { + return null; } + + const policy = this.overlayState.policy.get(nodeId); + const vex = this.overlayState.vex.get(nodeId); + const aoc = this.overlayState.aoc.get(nodeId); + + if (policy) { + return { + stroke: this.getPolicyStroke(policy), + summary: `Policy ${policy.badge}${policy.verdictAt ? ` at ${policy.verdictAt}` : ''}`, + }; + } + + if (vex) { + return { + stroke: this.getVexStroke(vex), + summary: `VEX ${vex.state}${vex.lastUpdated ? ` updated ${vex.lastUpdated}` : ''}`, + }; + } + + if (aoc) { + return { + stroke: this.getAocStroke(aoc), + summary: `AOC ${aoc.status}${aoc.lastVerified ? ` verified ${aoc.lastVerified}` : ''}`, + }; + } + + return null; } getMinimapNodeColor(node: LayoutNode): string { @@ -1233,4 +1250,49 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy const selectedId = this.selectedNodeId; return selectedId !== null && (edge.source === selectedId || edge.target === selectedId); } + + private getPolicyStroke(policy: PolicyOverlayData): string { + switch (policy.badge) { + case 'pass': + return '#16a34a'; + case 'warn': + return '#d97706'; + case 'fail': + return '#dc2626'; + case 'waived': + return '#0284c7'; + default: + return '#94a3b8'; + } + } + + private getVexStroke(vex: VexOverlayData): string { + switch (vex.state) { + case 'not_affected': + return '#16a34a'; + case 'fixed': + return '#0284c7'; + case 'under_investigation': + return '#d97706'; + case 'affected': + return '#dc2626'; + default: + return '#94a3b8'; + } + } + + private getAocStroke(aoc: AocOverlayData): string { + switch (aoc.status) { + case 'pass': + return '#16a34a'; + case 'warn': + return '#d97706'; + case 'fail': + return '#dc2626'; + case 'pending': + return '#0284c7'; + default: + return '#94a3b8'; + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.html b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.html index 1b550570c..cc97d4e7c 100644 --- a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.html +++ b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.html @@ -122,13 +122,9 @@
diff --git a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts index 734bbd84e..8eabd64a8 100644 --- a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts @@ -26,6 +26,7 @@ import { GRAPH_PLATFORM_API } from '../../core/api/graph-platform.client'; import { GraphEdge as PlatformGraphEdge, GraphNode as PlatformGraphNode, + GraphOverlays as PlatformGraphOverlays, GraphTileResponse, } from '../../core/api/graph-platform.models'; import { GraphCanvasComponent, CanvasNode, CanvasEdge } from './graph-canvas.component'; @@ -100,13 +101,8 @@ export class GraphExplorerComponent implements OnInit { readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all'); // Overlay state + readonly availableOverlays = signal(null); readonly overlayState = signal(null); - readonly simulationMode = signal(false); - readonly pathViewState = signal<{ enabled: boolean; type: string }>({ enabled: false, type: 'shortest' }); - readonly timeTravelState = signal<{ enabled: boolean; snapshot: string }>({ enabled: false, snapshot: 'current' }); - - // Computed: node IDs for overlay component - readonly nodeIds = computed(() => this.filteredNodes().map(n => n.id)); // Computed: filtered nodes readonly filteredNodes = computed(() => { @@ -304,6 +300,8 @@ export class GraphExplorerComponent implements OnInit { if (!graphId) { this.nodes.set([]); this.edges.set([]); + this.availableOverlays.set(null); + this.overlayState.set(null); this.loading.set(false); return; } @@ -316,6 +314,8 @@ export class GraphExplorerComponent implements OnInit { error: () => { this.nodes.set([]); this.edges.set([]); + this.availableOverlays.set(null); + this.overlayState.set(null); this.loading.set(false); this.showMessage('Unable to load graph tile data.', 'error'); }, @@ -324,6 +324,8 @@ export class GraphExplorerComponent implements OnInit { error: () => { this.nodes.set([]); this.edges.set([]); + this.availableOverlays.set(null); + this.overlayState.set(null); this.loading.set(false); this.showMessage('Unable to load graph metadata.', 'error'); }, @@ -439,36 +441,16 @@ export class GraphExplorerComponent implements OnInit { trackByNode = (_: number, item: GraphNode) => item.id; // Overlay handlers - onOverlayStateChange(state: GraphOverlayState): void { + onOverlayStateChange(state: GraphOverlayState | null): void { this.overlayState.set(state); } - onSimulationModeChange(enabled: boolean): void { - this.simulationMode.set(enabled); - this.showMessage(enabled ? 'Simulation mode enabled' : 'Simulation mode disabled', 'info'); - } - - onPathViewChange(state: { enabled: boolean; type: string }): void { - this.pathViewState.set(state); - if (state.enabled) { - this.showMessage(`Path view enabled: ${state.type}`, 'info'); - } - } - - onTimeTravelChange(state: { enabled: boolean; snapshot: string }): void { - this.timeTravelState.set(state); - if (state.enabled && state.snapshot !== 'current') { - this.showMessage(`Viewing snapshot: ${state.snapshot}`, 'info'); - } - } - - onShowDiffRequest(snapshot: string): void { - this.showMessage(`Loading diff for ${snapshot}...`, 'info'); - } - private applyTile(tile: GraphTileResponse): void { this.nodes.set(tile.nodes.map((node) => this.mapNode(node))); this.edges.set(tile.edges.map((edge) => this.mapEdge(edge))); + const overlays = this.mapOverlays(tile.overlays); + this.availableOverlays.set(overlays); + this.overlayState.set(overlays); } private mapNode(node: PlatformGraphNode): GraphNode { @@ -513,6 +495,47 @@ export class GraphExplorerComponent implements OnInit { return undefined; } + private mapOverlays(overlays?: PlatformGraphOverlays): GraphOverlayState | null { + if (!overlays) { + return null; + } + + return { + policy: new Map( + (overlays.policy ?? []).map((overlay) => [ + overlay.nodeId, + { + nodeId: overlay.nodeId, + badge: overlay.badge, + policyId: overlay.policyId, + verdictAt: overlay.verdictAt, + }, + ]), + ), + vex: new Map( + (overlays.vex ?? []).map((overlay) => [ + overlay.nodeId, + { + nodeId: overlay.nodeId, + state: overlay.state, + statementId: overlay.statementId, + lastUpdated: overlay.lastUpdated, + }, + ]), + ), + aoc: new Map( + (overlays.aoc ?? []).map((overlay) => [ + overlay.nodeId, + { + nodeId: overlay.nodeId, + status: overlay.status, + lastVerified: overlay.lastVerified, + }, + ]), + ), + }; + } + private showMessage(text: string, type: 'success' | 'error' | 'info'): void { this.message.set(text); this.messageType.set(type); diff --git a/src/Web/StellaOps.Web/src/app/features/graph/graph-overlays.component.ts b/src/Web/StellaOps.Web/src/app/features/graph/graph-overlays.component.ts index 9c071a569..80c08b0f3 100644 --- a/src/Web/StellaOps.Web/src/app/features/graph/graph-overlays.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/graph/graph-overlays.component.ts @@ -1,4 +1,4 @@ - +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -11,1296 +11,409 @@ import { signal, } from '@angular/core'; -export type OverlayType = 'policy' | 'evidence' | 'license' | 'exposure' | 'reachability'; +export type OverlayType = 'policy' | 'vex' | 'aoc'; export interface OverlayConfig { type: OverlayType; - enabled: boolean; label: string; - icon: string; color: string; } export interface PolicyOverlayData { nodeId: string; - policyStatus: 'pass' | 'warn' | 'block' | 'unknown'; - policyName?: string; - violations?: string[]; - gateBlocked?: boolean; + badge: 'pass' | 'warn' | 'fail' | 'waived'; + policyId: string; + verdictAt?: string; } -export interface EvidenceOverlayData { +export interface VexOverlayData { nodeId: string; - hasEvidence: boolean; - evidenceType?: 'sbom' | 'attestation' | 'signature' | 'provenance'; - confidence?: number; - sources?: string[]; + state: 'not_affected' | 'fixed' | 'under_investigation' | 'affected'; + statementId: string; + lastUpdated?: string; } -export interface LicenseOverlayData { +export interface AocOverlayData { nodeId: string; - license?: string; - licenseFamily?: 'permissive' | 'copyleft' | 'proprietary' | 'unknown'; - compatible: boolean; - conflictsWith?: string[]; -} - -export interface ExposureOverlayData { - nodeId: string; - exposureLevel: 'internet' | 'internal' | 'isolated' | 'unknown'; - reachable: boolean; - attackPaths?: number; - riskScore?: number; -} - -export interface ReachabilityOverlayData { - nodeId: string; - latticeState: 'SR' | 'SU' | 'RO' | 'RU' | 'CR' | 'CU' | 'X'; - status: 'reachable' | 'unreachable' | 'unknown'; - confidence: number; - observedAt: string; + status: 'pass' | 'fail' | 'warn' | 'pending'; + lastVerified?: string; } export interface GraphOverlayState { policy: Map; - evidence: Map; - license: Map; - exposure: Map; - reachability: Map; + vex: Map; + aoc: Map; } -type SnapshotKey = 'current' | '1d' | '7d' | '30d'; - -interface SnapshotEvent { - label: string; - description: string; -} - -// Mock overlay data generators -function stableHash(input: string): number { - let hash = 2166136261; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -function fraction(hash: number): number { - return (hash % 1000) / 999; -} - -function generateMockPolicyData(nodeIds: string[]): Map { - const data = new Map(); - const statuses: PolicyOverlayData['policyStatus'][] = ['pass', 'warn', 'block', 'unknown']; - - for (const nodeId of nodeIds) { - const hash = stableHash(`policy:${nodeId}`); - const status = statuses[hash % statuses.length]; - const policyNumber = hash % 100; - data.set(nodeId, { - nodeId, - policyStatus: status, - policyName: status === 'pass' ? undefined : `policy-${policyNumber}`, - violations: status === 'block' ? ['Vulnerable dependency detected', 'Missing attestation'] : undefined, - gateBlocked: status === 'block', - }); - } - return data; -} - -function generateMockEvidenceData(nodeIds: string[]): Map { - const data = new Map(); - const types: EvidenceOverlayData['evidenceType'][] = ['sbom', 'attestation', 'signature', 'provenance']; - - for (const nodeId of nodeIds) { - const hash = stableHash(`evidence:${nodeId}`); - const hasEvidence = (hash % 10) >= 3; - data.set(nodeId, { - nodeId, - hasEvidence, - evidenceType: hasEvidence ? types[hash % types.length] : undefined, - confidence: hasEvidence ? Math.round(60 + fraction(hash) * 40) : undefined, - sources: hasEvidence ? ['scanner', 'registry'] : undefined, - }); - } - return data; -} - -function generateMockLicenseData(nodeIds: string[]): Map { - const data = new Map(); - const licenses = ['MIT', 'Apache-2.0', 'GPL-3.0', 'BSD-3-Clause', 'LGPL-2.1', 'Proprietary']; - - for (const nodeId of nodeIds) { - const hash = stableHash(`license:${nodeId}`); - const license = licenses[hash % licenses.length]; - const family = license.includes('GPL') ? 'copyleft' : - license === 'Proprietary' ? 'proprietary' : 'permissive'; - const compatible = family !== 'copyleft' || (hash % 2) === 0; - - data.set(nodeId, { - nodeId, - license, - licenseFamily: family, - compatible, - conflictsWith: compatible ? undefined : ['Project uses MIT license'], - }); - } - return data; -} - -function generateMockExposureData(nodeIds: string[]): Map { - const data = new Map(); - const levels: ExposureOverlayData['exposureLevel'][] = ['internet', 'internal', 'isolated', 'unknown']; - - for (const nodeId of nodeIds) { - const hash = stableHash(`exposure:${nodeId}`); - const level = levels[hash % levels.length]; - data.set(nodeId, { - nodeId, - exposureLevel: level, - reachable: level === 'internet' || level === 'internal', - attackPaths: level === 'internet' ? 1 + (hash % 5) : 0, - riskScore: level === 'internet' ? Math.round(60 + fraction(hash) * 40) : - level === 'internal' ? Math.round(20 + fraction(hash) * 30) : 0, - }); - } - return data; -} - -function generateMockReachabilityData(nodeIds: string[], snapshot: SnapshotKey): Map { - const data = new Map(); - const snapshotDays: Record = { current: 0, '1d': 1, '7d': 7, '30d': 30 }; - const days = snapshotDays[snapshot] ?? 0; - const base = Date.parse('2025-12-12T00:00:00Z'); - const observedAt = new Date(base - days * 24 * 60 * 60 * 1000).toISOString(); - const latticeStates: ReachabilityOverlayData['latticeState'][] = ['SR', 'SU', 'RO', 'RU', 'CR', 'CU', 'X']; - - for (const nodeId of nodeIds) { - const hash = stableHash(`reach:${nodeId}:${snapshot}`); - const latticeState = latticeStates[hash % latticeStates.length]; - const status: ReachabilityOverlayData['status'] = - latticeState === 'X' - ? 'unknown' - : latticeState === 'SR' || latticeState === 'RO' || latticeState === 'CR' - ? 'reachable' - : 'unreachable'; - const confidence = Number((0.45 + fraction(hash) * 0.5).toFixed(2)); - - data.set(nodeId, { - nodeId, - latticeState, - status, - confidence, - observedAt, - }); - } - - return data; -} +const OVERLAY_CONFIGS: readonly OverlayConfig[] = [ + { type: 'policy', label: 'Policy', color: '#b45309' }, + { type: 'vex', label: 'VEX', color: '#0284c7' }, + { type: 'aoc', label: 'AOC', color: '#15803d' }, +]; @Component({ - selector: 'app-graph-overlays', - imports: [], - template: ` -
- -
- @for (config of overlayConfigs(); track config.type) { - - } -
+ selector: 'app-graph-overlays', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

Overlays

+

Live policy, VEX, and assurance overlays from the graph service.

+
- -
- - @if (simulationMode()) { - SIMULATING - } -
- - - @if (hasActiveOverlays()) { -
- @if (isOverlayEnabled('policy')) { -
-

Policy Status

-
-
- - Pass -
-
- - Warning -
-
- - Blocked -
-
- - Unknown -
-
-
- } - - @if (isOverlayEnabled('evidence')) { -
-

Evidence

-
-
- - High confidence -
-
- - Medium confidence -
-
- - No evidence -
-
-
- } - - @if (isOverlayEnabled('license')) { -
-

License

-
-
- - Permissive -
-
- - Copyleft -
-
- - Conflict -
-
-
- } - - @if (isOverlayEnabled('exposure')) { -
-

Exposure

-
-
- - Internet -
-
- - Internal -
-
- - Isolated -
-
-
- } - - @if (isOverlayEnabled('reachability')) { -
-

Reachability Lattice

-
-
- - SR - Strong reachable -
-
- - SU - Strong unreachable -
-
- - RO - Reachable observed -
-
- - RU - Unreachable observed -
-
- - CR - Conditionally reachable -
-
- - CU - Conditionally unreachable -
-
- - X - Unknown -
-
-
- } -
- } - - - @if (selectedNodeId && hasActiveOverlays()) { -
-

Overlay Details

- - @if (isOverlayEnabled('policy') && getPolicyData(selectedNodeId)) { -
-
- - Policy - - {{ getPolicyData(selectedNodeId)!.policyStatus }} - -
- @if (getPolicyData(selectedNodeId)!.violations?.length) { -
    - @for (v of getPolicyData(selectedNodeId)!.violations; track v) { -
  • {{ v }}
  • - } -
- } -
- } - - @if (isOverlayEnabled('evidence') && getEvidenceData(selectedNodeId)) { -
-
- - Evidence - @if (getEvidenceData(selectedNodeId)!.hasEvidence) { - - {{ getEvidenceData(selectedNodeId)!.confidence }}% - - } @else { - None - } -
- @if (getEvidenceData(selectedNodeId)!.evidenceType) { -
- Type: {{ getEvidenceData(selectedNodeId)!.evidenceType }} -
- } -
- } - - @if (isOverlayEnabled('license') && getLicenseData(selectedNodeId)) { -
-
- - License - - {{ getLicenseData(selectedNodeId)!.license }} - -
- @if (!getLicenseData(selectedNodeId)!.compatible) { -
- Conflicts with: {{ getLicenseData(selectedNodeId)!.conflictsWith?.join(', ') }} -
- } -
- } - - @if (isOverlayEnabled('exposure') && getExposureData(selectedNodeId)) { -
-
- - Exposure - - {{ getExposureData(selectedNodeId)!.exposureLevel }} - -
- @if (getExposureData(selectedNodeId)!.attackPaths) { -
- Attack paths: {{ getExposureData(selectedNodeId)!.attackPaths }} -
- } - @if (getExposureData(selectedNodeId)!.riskScore) { -
- Risk score: {{ getExposureData(selectedNodeId)!.riskScore }} -
- } -
- } - - @if (isOverlayEnabled('reachability') && getReachabilityData(selectedNodeId)) { -
-
- - Reachability - - {{ getReachabilityData(selectedNodeId)!.status }} - -
-
- Lattice state: {{ getReachabilityData(selectedNodeId)!.latticeState }} -
-
- Confidence: {{ (getReachabilityData(selectedNodeId)!.confidence * 100).toFixed(0) }}% -
-
- Observed: {{ getReachabilityData(selectedNodeId)!.observedAt }} -
-
- } -
- } - - -
- - @if (pathViewEnabled()) { -
- - - -
- } -
- - -
- - @if (timeTravelEnabled() || isOverlayEnabled('reachability')) { -
-
- - {{ snapshotLabel() }} -
-
- {{ activeSnapshotEvent().label }} - {{ activeSnapshotEvent().description }} -
- - @if (timeTravelEnabled()) { - - } -
- } -
-
+ {{ config.label }} + + } + + } @else { +
No live overlays are available for the current graph.
+ } + + @if (activeConfigs().length > 0) { +
+ @if (isEnabled('policy')) { +
+

Policy

+

Pass

+

Warn

+

Fail

+
+ } + + @if (isEnabled('vex')) { +
+

VEX

+

Not affected

+

Under investigation

+

Affected

+
+ } + + @if (isEnabled('aoc')) { +
+

AOC

+

Pass

+

Warn

+

Fail

+

Pending

+
+ } +
+ } + + @if (selectedNodeId) { +
+

Selected Node

+ + @if (getPolicyData(selectedNodeId); as policy) { +
+
+ Policy + {{ policy.badge }} +
+

{{ policy.policyId }}

+ @if (policy.verdictAt) { +

Verdict at {{ formatTimestamp(policy.verdictAt) }}

+ } +
+ } + + @if (getVexData(selectedNodeId); as vex) { +
+
+ VEX + {{ formatLabel(vex.state) }} +
+

{{ vex.statementId }}

+ @if (vex.lastUpdated) { +

Updated {{ formatTimestamp(vex.lastUpdated) }}

+ } +
+ } + + @if (getAocData(selectedNodeId); as aoc) { +
+
+ AOC + {{ formatLabel(aoc.status) }} +
+ @if (aoc.lastVerified) { +

Verified {{ formatTimestamp(aoc.lastVerified) }}

+ } @else { +

No verification timestamp is available.

+ } +
+ } + + @if (!getPolicyData(selectedNodeId) && !getVexData(selectedNodeId) && !getAocData(selectedNodeId)) { +
No active overlay data is available for the selected node.
+ } +
+ } + `, - styles: [` + styles: [` .graph-overlays { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem; - background: var(--color-surface-primary); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - } - - /* Overlay toggle bar */ - .overlay-bar { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - } - - .overlay-toggle { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - border: 1px solid rgba(212, 201, 168, 0.3); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.8125rem; - cursor: pointer; - transition: all 0.15s ease; - position: relative; - - &:hover { - border-color: var(--color-text-secondary); - color: var(--color-text-primary); - } - - &--active { - border-color: var(--color-text-secondary); - background: color-mix(in srgb, var(--color-text-secondary) 10%, var(--color-surface-primary)); - color: var(--color-text-secondary); - } - - &:focus-visible { - outline: 2px solid var(--color-text-secondary); - outline-offset: 2px; - } - } - - .overlay-toggle__icon { - display: flex; - align-items: center; - justify-content: center; - } - - .overlay-toggle__indicator { - position: absolute; - bottom: -2px; - left: 50%; - transform: translateX(-50%); - width: 8px; - height: 8px; - border-radius: var(--radius-full); - background: var(--color-text-secondary); - } - - /* Simulation toggle */ - .simulation-toggle { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0; - border-top: 1px solid var(--color-surface-tertiary); - } - - .simulation-toggle__label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - color: var(--color-text-secondary); - cursor: pointer; - - input { - width: 16px; - height: 16px; - cursor: pointer; - } - } - - .simulation-badge { - padding: 0.125rem 0.5rem; - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - border-radius: var(--radius-full); - font-size: 0.625rem; - font-weight: var(--font-weight-semibold); - letter-spacing: 0.05em; - animation: pulse 2s infinite; - } - - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } - } - - /* Legend */ - .overlay-legend { - display: flex; - flex-wrap: wrap; - gap: 1rem; - padding: 0.75rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-lg); - } - - .legend-section { - flex: 1; - min-width: 140px; - } - - .legend-section__title { - margin: 0 0 0.5rem; - font-size: 0.6875rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .legend-items { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .legend-item { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; - color: var(--color-text-secondary); - } - - .legend-dot { - width: 10px; - height: 10px; - border-radius: var(--radius-full); - - &--pass { background: var(--color-status-success); } - &--warn { background: var(--color-status-warning); } - &--block { background: var(--color-status-error); } - &--unknown { background: var(--color-text-muted); } - - &--evidence-high { background: var(--color-status-info); } - &--evidence-medium { background: var(--color-status-info-border); } - &--evidence-none { background: rgba(212, 201, 168, 0.5); } - - &--license-permissive { background: var(--color-status-success); } - &--license-copyleft { background: var(--color-status-warning); } - &--license-conflict { background: var(--color-status-error); } - - &--exposure-internet { background: var(--color-status-error); } - &--exposure-internal { background: var(--color-status-warning); } - &--exposure-isolated { background: var(--color-status-success); } - - &--lattice-sr { background: var(--color-status-success); } - &--lattice-su { background: var(--color-status-success); } - &--lattice-ro { background: var(--color-status-info-text); } - &--lattice-ru { background: var(--color-status-info); } - &--lattice-cr { background: var(--color-status-warning); } - &--lattice-cu { background: var(--color-severity-high); } - &--lattice-x { background: var(--color-text-muted); } - } - - /* Overlay details */ - .overlay-details { - padding: 0.75rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-lg); - } - - .overlay-details__title { - margin: 0 0 0.75rem; - font-size: 0.75rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .overlay-detail-card { - padding: 0.625rem; - background: var(--color-surface-primary); - border: 1px solid rgba(212, 201, 168, 0.3); - border-radius: var(--radius-md); - margin-bottom: 0.5rem; - - &:last-child { - margin-bottom: 0; - } - } - - .overlay-detail-card__header { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .overlay-detail-card__icon { - display: flex; - align-items: center; - justify-content: center; - } - - .overlay-detail-card__label { - flex: 1; - font-size: 0.8125rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-primary); - } - - .overlay-detail-card__list { - margin: 0.5rem 0 0; - padding-left: 1.25rem; - font-size: 0.75rem; - color: var(--color-text-secondary); - - li { - margin-bottom: 0.25rem; - } - } - - .overlay-detail-card__info { - margin-top: 0.375rem; - font-size: 0.75rem; - color: var(--color-text-secondary); - } - - .overlay-detail-card__warning { - margin-top: 0.375rem; - font-size: 0.75rem; - color: var(--color-status-error); - } - - .status-badge { - padding: 0.125rem 0.5rem; - border-radius: var(--radius-full); - font-size: 0.6875rem; - font-weight: var(--font-weight-medium); - text-transform: capitalize; - background: var(--color-surface-tertiary); - color: var(--color-text-secondary); - - &--pass { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - } - - &--warn { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - } - - &--block { - background: var(--color-status-error-bg); - color: var(--color-status-error); - } - - &--none { - background: var(--color-surface-tertiary); - color: var(--color-text-muted); - } - } - - .confidence-badge { - padding: 0.125rem 0.5rem; - background: var(--color-status-info-bg); - color: var(--color-status-info-text); - border-radius: var(--radius-full); - font-size: 0.6875rem; - font-weight: var(--font-weight-medium); - } - - /* Path view */ - .path-view-section { - padding-top: 0.75rem; - border-top: 1px solid var(--color-surface-tertiary); - } - - .path-view-btn { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - border: 1px solid rgba(212, 201, 168, 0.3); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.8125rem; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - border-color: var(--color-brand-primary); - color: var(--color-text-primary); - } - - &--active { - border-color: var(--color-brand-primary); - background: var(--color-status-excepted-bg); - color: var(--color-text-link); - } - } - - .path-view-options { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 0.5rem; - padding: 0.5rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-md); - - label { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; - color: var(--color-text-secondary); - cursor: pointer; - - input { - width: 14px; - height: 14px; - cursor: pointer; - } - } - } - - /* Time travel */ - .time-travel-section { - padding-top: 0.75rem; - border-top: 1px solid var(--color-surface-tertiary); - } - - .time-travel-btn { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - border: 1px solid rgba(212, 201, 168, 0.3); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.8125rem; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - border-color: var(--color-status-excepted); - color: var(--color-text-primary); - } - - &--active { - border-color: var(--color-status-excepted); - background: var(--color-status-excepted-bg); - color: var(--color-status-excepted); - } - } - - .time-travel-options { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.5rem; - padding: 0.5rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-md); - } - - .time-slider { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .time-slider input[type='range'] { - width: 120px; - } - - .time-slider__label { - font-size: 0.75rem; - color: var(--color-text-secondary); - white-space: nowrap; - } - - .time-timeline { display: grid; - gap: 0.125rem; - min-width: 180px; - font-size: 0.72rem; - color: var(--color-text-secondary); - padding: 0.25rem 0.5rem; - border: 1px dashed rgba(212, 201, 168, 0.35); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--color-surface-primary) 70%, transparent); + gap: 0.8rem; + padding: 0.9rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } - .time-timeline strong { - font-size: 0.74rem; + .graph-overlays__header h3, + .graph-overlays__legend-group h4, + .graph-overlays__details h4, + .graph-overlays__card header { + margin: 0; + } + + .graph-overlays__header p, + .graph-overlays__secondary, + .graph-overlays__card p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.78rem; + } + + .graph-overlays__toggles { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + } + + .graph-overlays__toggle { + border: 1px solid color-mix(in srgb, var(--overlay-color) 45%, var(--color-border-primary)); + border-radius: var(--radius-full); + background: var(--color-surface-secondary); color: var(--color-text-primary); - } - - .time-travel-select { - padding: 0.375rem 0.5rem; - border: 1px solid rgba(212, 201, 168, 0.3); - border-radius: var(--radius-md); - font-size: 0.75rem; - background: var(--color-surface-primary); + padding: 0.3rem 0.75rem; cursor: pointer; - - &:focus { - outline: none; - border-color: var(--color-status-excepted); - } + font-size: 0.75rem; } - .diff-btn { - padding: 0.375rem 0.625rem; - border: 1px solid rgba(212, 201, 168, 0.3); + .graph-overlays__toggle--active { + background: color-mix(in srgb, var(--overlay-color) 12%, var(--color-surface-primary)); + } + + .graph-overlays__legend { + display: grid; + gap: 0.7rem; + } + + .graph-overlays__legend-group { + display: grid; + gap: 0.28rem; + } + + .graph-overlays__legend-group p { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 0.76rem; + } + + .graph-overlays__dot { + inline-size: 0.72rem; + block-size: 0.72rem; + border-radius: 999px; + display: inline-block; + } + + .graph-overlays__dot--policy-pass, + .graph-overlays__badge--pass, + .graph-overlays__dot--vex-not-affected, + .graph-overlays__dot--aoc-pass { + background: rgba(22, 163, 74, 0.16); + color: #166534; + border-color: rgba(22, 163, 74, 0.35); + } + + .graph-overlays__dot--policy-warn, + .graph-overlays__badge--warn, + .graph-overlays__dot--vex-under-review, + .graph-overlays__dot--aoc-warn, + .graph-overlays__badge--under_investigation { + background: rgba(217, 119, 6, 0.16); + color: #92400e; + border-color: rgba(217, 119, 6, 0.35); + } + + .graph-overlays__dot--policy-fail, + .graph-overlays__badge--fail, + .graph-overlays__dot--vex-affected, + .graph-overlays__dot--aoc-fail, + .graph-overlays__badge--affected { + background: rgba(220, 38, 38, 0.14); + color: #991b1b; + border-color: rgba(220, 38, 38, 0.35); + } + + .graph-overlays__dot--aoc-pending, + .graph-overlays__badge--pending, + .graph-overlays__badge--fixed, + .graph-overlays__badge--waived { + background: rgba(2, 132, 199, 0.14); + color: #075985; + border-color: rgba(2, 132, 199, 0.35); + } + + .graph-overlays__details { + display: grid; + gap: 0.6rem; + } + + .graph-overlays__card { + display: grid; + gap: 0.35rem; + padding: 0.7rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); + background: var(--color-surface-secondary); + } + + .graph-overlays__card header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; + } + + .graph-overlays__badge { + border: 1px solid transparent; + border-radius: var(--radius-full); + padding: 0.12rem 0.45rem; + font-size: 0.72rem; + text-transform: uppercase; + font-weight: var(--font-weight-semibold); + } + + .graph-overlays__empty { + border: 1px dashed var(--color-border-primary); + border-radius: var(--radius-md); + padding: 0.7rem; color: var(--color-text-secondary); - font-size: 0.75rem; - cursor: pointer; - transition: all 0.15s ease; - - &:hover:not(:disabled) { - border-color: var(--color-status-excepted); - color: var(--color-status-excepted); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - - /* Reduced motion */ - @media (prefers-reduced-motion: reduce) { - .simulation-badge, - .overlay-toggle, - .path-view-btn, - .time-travel-btn, - .diff-btn { - animation: none; - transition: none; - } + font-size: 0.78rem; } `], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class GraphOverlaysComponent implements OnChanges { - @Input() nodeIds: string[] = []; + @Input() overlayState: GraphOverlayState | null = null; @Input() selectedNodeId: string | null = null; - @Output() overlayStateChange = new EventEmitter(); - @Output() simulationModeChange = new EventEmitter(); - @Output() pathViewChange = new EventEmitter<{ enabled: boolean; type: string }>(); - @Output() timeTravelChange = new EventEmitter<{ enabled: boolean; snapshot: string }>(); - @Output() showDiffRequest = new EventEmitter(); + @Output() overlayStateChange = new EventEmitter(); - // SVG icon builder - private readonly svgAttrs = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; - - // Overlay configurations - readonly overlayConfigs = signal([ - { type: 'policy', enabled: false, label: 'Policy', icon: ``, color: 'var(--color-brand-primary)' }, - { type: 'evidence', enabled: false, label: 'Evidence', icon: ``, color: 'var(--color-status-info)' }, - { type: 'license', enabled: false, label: 'License', icon: ``, color: 'var(--color-status-success)' }, - { type: 'exposure', enabled: false, label: 'Exposure', icon: ``, color: 'var(--color-status-error)' }, - { type: 'reachability', enabled: false, label: 'Reachability', icon: ``, color: 'var(--color-status-success)' }, - ]); - - // Overlay data - readonly overlayState = signal({ - policy: new Map(), - evidence: new Map(), - license: new Map(), - exposure: new Map(), - reachability: new Map(), + private readonly enabledState = signal>({ + policy: true, + vex: true, + aoc: true, }); - // Mode toggles - readonly simulationMode = signal(false); - readonly pathViewEnabled = signal(false); - readonly pathType = signal<'shortest' | 'attack' | 'dependency'>('shortest'); - readonly timeTravelEnabled = signal(false); - readonly selectedSnapshot = signal('current'); - private readonly snapshotOrder: readonly SnapshotKey[] = ['current', '1d', '7d', '30d']; - private readonly snapshotEvents: Record = { - current: { - label: 'Current snapshot', - description: 'Latest deterministic reachability lattice from the active scan window.', - }, - '1d': { - label: '1 day ago', - description: 'Short-term regression check after recent package and policy updates.', - }, - '7d': { - label: '7 days ago', - description: 'Weekly operational baseline used for release gate drift comparison.', - }, - '30d': { - label: '30 days ago', - description: 'Long-window historical baseline for replay and audit evidence review.', - }, - }; + readonly availableConfigs = computed(() => + OVERLAY_CONFIGS.filter((config) => this.hasOverlayData(config.type)), + ); - readonly snapshotIndex = computed(() => { - const idx = this.snapshotOrder.indexOf(this.selectedSnapshot()); - return idx === -1 ? 0 : idx; - }); - - readonly snapshotLabel = computed(() => { - switch (this.selectedSnapshot()) { - case '1d': - return '1 day ago'; - case '7d': - return '7 days ago'; - case '30d': - return '30 days ago'; - default: - return 'Current'; - } - }); - - readonly activeSnapshotEvent = computed(() => this.snapshotEvents[this.selectedSnapshot()]); - - // Computed - readonly hasActiveOverlays = computed(() => - this.overlayConfigs().some(c => c.enabled) + readonly activeConfigs = computed(() => + this.availableConfigs().filter((config) => this.isEnabled(config.type)), ); ngOnChanges(changes: SimpleChanges): void { - if (changes['nodeIds']) { - this.regenerateOverlayData(); - this.overlayStateChange.emit(this.overlayState()); + if (changes['overlayState']) { + const current = this.enabledState(); + this.enabledState.set({ + policy: this.hasOverlayData('policy') ? current.policy : false, + vex: this.hasOverlayData('vex') ? current.vex : false, + aoc: this.hasOverlayData('aoc') ? current.aoc : false, + }); + this.emitOverlayState(); } } + isEnabled(type: OverlayType): boolean { + return this.enabledState()[type]; + } + toggleOverlay(type: OverlayType): void { - const configs = this.overlayConfigs(); - const updated = configs.map(c => - c.type === type ? { ...c, enabled: !c.enabled } : c - ); - this.overlayConfigs.set(updated); - - const enabled = updated.find(c => c.type === type)?.enabled ?? false; - if (enabled) { - this.regenerateOverlayDataForType(type); - } else { - this.clearOverlayDataForType(type); + if (!this.hasOverlayData(type)) { + return; } - this.overlayStateChange.emit(this.overlayState()); + this.enabledState.update((current) => ({ + ...current, + [type]: !current[type], + })); + this.emitOverlayState(); } - isOverlayEnabled(type: OverlayType): boolean { - return this.overlayConfigs().find(c => c.type === type)?.enabled ?? false; - } - - toggleSimulation(): void { - this.simulationMode.set(!this.simulationMode()); - this.simulationModeChange.emit(this.simulationMode()); - } - - togglePathView(): void { - this.pathViewEnabled.set(!this.pathViewEnabled()); - this.pathViewChange.emit({ - enabled: this.pathViewEnabled(), - type: this.pathType(), - }); - } - - setPathType(type: 'shortest' | 'attack' | 'dependency'): void { - this.pathType.set(type); - this.pathViewChange.emit({ - enabled: this.pathViewEnabled(), - type, - }); - } - - toggleTimeTravel(): void { - this.timeTravelEnabled.set(!this.timeTravelEnabled()); - this.timeTravelChange.emit({ - enabled: this.timeTravelEnabled(), - snapshot: this.selectedSnapshot(), - }); - } - - setSnapshot(snapshot: string): void { - const normalizedSnapshot = this.normalizeSnapshot(snapshot); - this.selectedSnapshot.set(normalizedSnapshot); - this.timeTravelChange.emit({ - enabled: this.timeTravelEnabled(), - snapshot: normalizedSnapshot, - }); - - if (this.isOverlayEnabled('reachability')) { - this.regenerateOverlayDataForType('reachability'); - this.overlayStateChange.emit(this.overlayState()); + getPolicyData(nodeId: string): PolicyOverlayData | null { + if (!this.isEnabled('policy')) { + return null; } + + return this.overlayState?.policy.get(nodeId) ?? null; } - setSnapshotByIndex(index: number): void { - const clamped = Math.max(0, Math.min(index, this.snapshotOrder.length - 1)); - this.setSnapshot(this.snapshotOrder[clamped]); - } - - showDiff(): void { - this.showDiffRequest.emit(this.selectedSnapshot()); - } - - // Data getters - getPolicyData(nodeId: string): PolicyOverlayData | undefined { - return this.overlayState().policy.get(nodeId); - } - - getEvidenceData(nodeId: string): EvidenceOverlayData | undefined { - return this.overlayState().evidence.get(nodeId); - } - - getLicenseData(nodeId: string): LicenseOverlayData | undefined { - return this.overlayState().license.get(nodeId); - } - - getExposureData(nodeId: string): ExposureOverlayData | undefined { - return this.overlayState().exposure.get(nodeId); - } - - getReachabilityData(nodeId: string): ReachabilityOverlayData | undefined { - return this.overlayState().reachability.get(nodeId); - } - - private regenerateOverlayData(): void { - const nodeIds = this.nodeIds; - const enabled = new Set(this.overlayConfigs().filter((c) => c.enabled).map((c) => c.type)); - const state: GraphOverlayState = { - policy: enabled.has('policy') ? generateMockPolicyData(nodeIds) : new Map(), - evidence: enabled.has('evidence') ? generateMockEvidenceData(nodeIds) : new Map(), - license: enabled.has('license') ? generateMockLicenseData(nodeIds) : new Map(), - exposure: enabled.has('exposure') ? generateMockExposureData(nodeIds) : new Map(), - reachability: enabled.has('reachability') - ? generateMockReachabilityData(nodeIds, this.selectedSnapshot()) - : new Map(), - }; - this.overlayState.set(state); - } - - private regenerateOverlayDataForType(type: OverlayType): void { - const nodeIds = this.nodeIds; - const current = this.overlayState(); - - switch (type) { - case 'policy': - this.overlayState.set({ ...current, policy: generateMockPolicyData(nodeIds) }); - break; - case 'evidence': - this.overlayState.set({ ...current, evidence: generateMockEvidenceData(nodeIds) }); - break; - case 'license': - this.overlayState.set({ ...current, license: generateMockLicenseData(nodeIds) }); - break; - case 'exposure': - this.overlayState.set({ ...current, exposure: generateMockExposureData(nodeIds) }); - break; - case 'reachability': - this.overlayState.set({ ...current, reachability: generateMockReachabilityData(nodeIds, this.selectedSnapshot()) }); - break; + getVexData(nodeId: string): VexOverlayData | null { + if (!this.isEnabled('vex')) { + return null; } + + return this.overlayState?.vex.get(nodeId) ?? null; } - private clearOverlayDataForType(type: OverlayType): void { - const current = this.overlayState(); - - switch (type) { - case 'policy': - this.overlayState.set({ ...current, policy: new Map() }); - break; - case 'evidence': - this.overlayState.set({ ...current, evidence: new Map() }); - break; - case 'license': - this.overlayState.set({ ...current, license: new Map() }); - break; - case 'exposure': - this.overlayState.set({ ...current, exposure: new Map() }); - break; - case 'reachability': - this.overlayState.set({ ...current, reachability: new Map() }); - break; + getAocData(nodeId: string): AocOverlayData | null { + if (!this.isEnabled('aoc')) { + return null; } + + return this.overlayState?.aoc.get(nodeId) ?? null; } - private normalizeSnapshot(snapshot: string): SnapshotKey { - return this.snapshotOrder.includes(snapshot as SnapshotKey) ? (snapshot as SnapshotKey) : 'current'; + formatLabel(value: string): string { + return value.replace(/_/g, ' '); + } + + formatTimestamp(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + + private hasOverlayData(type: OverlayType): boolean { + const state = this.overlayState; + if (!state) { + return false; + } + + return state[type].size > 0; + } + + private emitOverlayState(): void { + const state = this.overlayState; + if (!state) { + this.overlayStateChange.emit(null); + return; + } + + const enabled = this.enabledState(); + this.overlayStateChange.emit({ + policy: enabled.policy ? new Map(state.policy) : new Map(), + vex: enabled.vex ? new Map(state.vex) : new Map(), + aoc: enabled.aoc ? new Map(state.aoc) : new Map(), + }); } } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts index 218bfaaca..c6dcc629e 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts @@ -541,7 +541,7 @@ export class IntegrationHubComponent implements OnDestroy { 'integration-hub', this.statsLoaded() && this.configuredConnectorCount() === 0 ? ['no-integrations'] : [], ); - }, { allowSignalWrites: true }); + }); } ngOnDestroy(): void { @@ -627,3 +627,4 @@ export class IntegrationHubComponent implements OnDestroy { }); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts index ce9137f39..e584ef26c 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts @@ -1,4 +1,4 @@ -import { CommonModule } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, Input, DestroyRef, effect } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -568,7 +568,7 @@ export class PolicyAuditLogComponent implements OnInit { constructor() { effect(() => { this.helperCtx.setScope('policy-audit-log', this.helperContexts()); - }, { allowSignalWrites: true }); + }); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('policy-audit-log')); } @@ -669,3 +669,4 @@ export class PolicyAuditLogComponent implements OnInit { } } } + diff --git a/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts index ddb620027..dc70bfdbf 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts @@ -29,6 +29,15 @@ export const POLICY_ROUTES: Routes = [ loadComponent: () => import('../security/exceptions-page.component').then((m) => m.ExceptionsPageComponent), }, + { + path: 'trust-algebra', + title: 'Trust Algebra', + data: { breadcrumb: 'Trust Algebra' }, + loadComponent: () => + import('../vulnerabilities/components/trust-algebra/trust-algebra-workbench.component').then( + (m) => m.TrustAlgebraWorkbenchComponent, + ), + }, { path: 'overview', redirectTo: '', diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts index 3df7dcf9a..b73c94fd6 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, RouterLink } from '@angular/router'; diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence.store.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence.store.ts index c1d4de355..c52302ce8 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence.store.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence.store.ts @@ -161,8 +161,23 @@ export class EvidenceStore { // Update the selected packet with verification result const current = this._selectedPacket(); if (current && current.id === id) { - this._selectedPacket.set({ ...current, verificationResult: result }); + this._selectedPacket.set({ + ...current, + signatureStatus: result.valid ? 'valid' : current.signatureStatus, + verificationResult: result, + }); } + this._packets.update((items) => + items.map((packet) => + packet.id === id && result.valid + ? { + ...packet, + signatureStatus: 'valid', + signedAt: result.verifiedAt, + } + : packet, + ), + ); this._verifying.set(false); }, error: (err) => { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts index 6210ffadc..62421abcf 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts @@ -2,21 +2,24 @@ import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/cor import { FormsModule } from '@angular/forms'; import { SlicePipe } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; +import { catchError, finalize, forkJoin, map, of, switchMap, throwError } from 'rxjs'; import { ReleaseManagementStore } from '../release.store'; import type { ManagedRelease } from '../../../../core/api/release-management.models'; import { formatDigest, - type DeploymentStrategy, type RegistryImage, - getStrategyLabel, } from '../../../../core/api/release-management.models'; import { PlatformContextStore } from '../../../../core/context/platform-context.store'; import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api'; +import { DEPLOYMENT_API, type CreateDeploymentRequest } from '../../../../core/api/deployment.client'; +import { + type DeploymentStrategy, + getStrategyLabel, +} from '../../../../core/api/deployment.models'; /* ─── Local mock types ─── */ -interface MockVersion { +interface VersionOption { id: string; name: string; version: string; @@ -24,7 +27,7 @@ interface MockVersion { sealedAt: string; } -interface MockHotfix { +interface HotfixOption { id: string; name: string; image: string; @@ -38,21 +41,6 @@ interface PromotionStage { } /* ─── Mock data ─── */ -const MOCK_VERSIONS: MockVersion[] = [ - { id: 'v1', name: 'api-gateway', version: 'v2.14.0', componentCount: 3, sealedAt: '2026-03-18T10:30:00Z' }, - { id: 'v2', name: 'payment-svc', version: 'v3.2.1', componentCount: 2, sealedAt: '2026-03-17T14:15:00Z' }, - { id: 'v3', name: 'auth-service', version: 'v1.8.0', componentCount: 1, sealedAt: '2026-03-16T09:00:00Z' }, - { id: 'v4', name: 'checkout-api', version: 'v4.0.0-rc1', componentCount: 4, sealedAt: '2026-03-15T16:45:00Z' }, - { id: 'v5', name: 'notification-svc', version: 'v2.1.3', componentCount: 1, sealedAt: '2026-03-14T11:20:00Z' }, - { id: 'v6', name: 'inventory-api', version: 'v5.0.0', componentCount: 5, sealedAt: '2026-03-13T08:00:00Z' }, -]; - -const MOCK_HOTFIXES: MockHotfix[] = [ - { id: 'h1', name: 'api-gateway-hotfix', image: 'registry.local/api-gateway', tag: 'v2.13.1-hf.20260320', sealedAt: '2026-03-20T08:00:00Z' }, - { id: 'h2', name: 'payment-svc-hotfix', image: 'registry.local/payment-svc', tag: 'v3.2.0-hf.20260319', sealedAt: '2026-03-19T12:30:00Z' }, - { id: 'h3', name: 'auth-service-hotfix', image: 'registry.local/auth-service', tag: 'v1.7.4-hf.20260318', sealedAt: '2026-03-18T15:00:00Z' }, -]; - @Component({ selector: 'app-create-deployment', standalone: true, @@ -123,12 +111,16 @@ const MOCK_HOTFIXES: MockHotfix[] = [ @case (1) {
-
-

Select Package

-

Choose a sealed Version or Hotfix to deploy, or create one inline.

-
+
+

Select Package

+

Choose a sealed Version or Hotfix to deploy, or create one inline.

+
- + @if (packageLoadError(); as packageError) { +
{{ packageError }}
+ } + +
Package type
@@ -511,8 +503,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [ - - + @@ -648,16 +639,16 @@ const MOCK_HOTFIXES: MockHotfix[] = [
} - @case ('recreate') { + @case ('all_at_once') {
} - @case ('ab-release') { - - - @if (strategyConfig.ab.subType === 'target-group') { -
-
- Rollout stages - -
- @for (stage of strategyConfig.ab.targetGroupStages; track $index; let i = $index) { -
- {{ i + 1 }} - - - - - @if (strategyConfig.ab.targetGroupStages.length > 1) { - - } -
- } -
- } @else { - -
- - -
- } - } }
@@ -821,17 +739,9 @@ const MOCK_HOTFIXES: MockHotfix[] = [
Warmup
{{ strategyConfig.blueGreen.warmupPeriod }}s
Keepalive
{{ strategyConfig.blueGreen.blueKeepalive }}min
} - @case ('recreate') { -
Concurrency
{{ strategyConfig.recreate.maxConcurrency === 0 ? 'unlimited' : strategyConfig.recreate.maxConcurrency }}
-
On failure
{{ strategyConfig.recreate.failureBehavior }}
- } - @case ('ab-release') { -
Sub-type
{{ strategyConfig.ab.subType }}
- @if (strategyConfig.ab.subType === 'target-group') { -
Stages
{{ strategyConfig.ab.targetGroupStages.length }} stage(s)
- } @else { -
Routing
{{ strategyConfig.ab.routerBasedConfig.routingStrategy }}
- } + @case ('all_at_once') { +
Concurrency
{{ strategyConfig.allAtOnce.maxConcurrency === 0 ? 'unlimited' : strategyConfig.allAtOnce.maxConcurrency }}
+
On failure
{{ strategyConfig.allAtOnce.failureBehavior }}
} } @@ -1223,6 +1133,7 @@ export class CreateDeploymentComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly bundleApi = inject(BundleOrganizerApi); + private readonly deploymentApi = inject(DEPLOYMENT_API); readonly store = inject(ReleaseManagementStore); readonly platformCtx = inject(PlatformContextStore); @@ -1234,6 +1145,9 @@ export class CreateDeploymentComponent { readonly fmtDigest = formatDigest; readonly linkedRelease = signal(null); + readonly availableVersions = signal([]); + readonly availableHotfixes = signal([]); + readonly packageLoadError = signal(null); constructor() { this.platformCtx.initialize(); @@ -1244,13 +1158,13 @@ export class CreateDeploymentComponent { this.store.selectRelease(releaseId); const existing = this.store.selectedRelease(); if (existing) { - this.linkedRelease.set(existing); + this.setLinkedRelease(existing); } else { // Wait for the store to load it const check = setInterval(() => { const loaded = this.store.selectedRelease(); if (loaded && loaded.id === releaseId) { - this.linkedRelease.set(loaded); + this.setLinkedRelease(loaded); clearInterval(check); } }, 200); @@ -1270,8 +1184,8 @@ export class CreateDeploymentComponent { // ─── Step 1: Package selection ─── readonly packageType = signal<'version' | 'hotfix'>('version'); - readonly selectedVersion = signal(null); - readonly selectedHotfix = signal(null); + readonly selectedVersion = signal(null); + readonly selectedHotfix = signal(null); readonly showInlineVersion = signal(false); readonly showInlineHotfix = signal(false); @@ -1290,18 +1204,20 @@ export class CreateDeploymentComponent { readonly inlineHotfixImage = signal(null); readonly inlineHotfixDigest = signal(''); - getFilteredVersions(): MockVersion[] { + getFilteredVersions(): VersionOption[] { const q = this.versionSearch.trim().toLowerCase(); - if (!q) return MOCK_VERSIONS; - return MOCK_VERSIONS.filter( + const versions = this.availableVersions(); + if (!q) return versions; + return versions.filter( (v) => v.name.toLowerCase().includes(q) || v.version.toLowerCase().includes(q), ); } - getFilteredHotfixes(): MockHotfix[] { + getFilteredHotfixes(): HotfixOption[] { const q = this.hotfixSearch.trim().toLowerCase(); - if (!q) return MOCK_HOTFIXES; - return MOCK_HOTFIXES.filter( + const hotfixes = this.availableHotfixes(); + if (!q) return hotfixes; + return hotfixes.filter( (h) => h.name.toLowerCase().includes(q) || h.tag.toLowerCase().includes(q), ); } @@ -1370,23 +1286,11 @@ export class CreateDeploymentComponent { blueKeepalive: 30, validationCommand: '', }, - recreate: { + allAtOnce: { maxConcurrency: 0, failureBehavior: 'rollback' as 'rollback' | 'continue' | 'pause', healthCheckTimeout: 120, }, - ab: { - subType: 'target-group' as 'target-group' | 'router-based', - targetGroupStages: [ - { name: 'Canary', aPercent: 100, bPercent: 10, durationMinutes: 15, healthThreshold: 99 }, - { name: 'Complete', aPercent: 0, bPercent: 100, durationMinutes: 0, healthThreshold: 99 }, - ] as Array<{ name: string; aPercent: number; bPercent: number; durationMinutes: number; healthThreshold: number }>, - routerBasedConfig: { - routingStrategy: 'weight-based' as string, - errorRateThreshold: 1, - latencyThreshold: 500, - }, - }, }; getStrategyLabel(): string { return getStrategyLabel(this.deploymentStrategy); } @@ -1432,7 +1336,7 @@ export class CreateDeploymentComponent { // ─── Package selection ─── - selectVersion(v: MockVersion): void { + selectVersion(v: VersionOption): void { this.selectedVersion.set(v); this.showInlineVersion.set(false); this.versionSearch = ''; @@ -1443,7 +1347,7 @@ export class CreateDeploymentComponent { this.versionSearch = ''; } - selectHotfix(h: MockHotfix): void { + selectHotfix(h: HotfixOption): void { this.selectedHotfix.set(h); this.showInlineHotfix.set(false); this.hotfixSearch = ''; @@ -1505,25 +1409,26 @@ export class CreateDeploymentComponent { this.createOrReuseBundle(slug, name, description).pipe( switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)), - catchError(() => { - // API unavailable -- fall back to local mock - console.log('[CreateDeployment] sealInlineVersion: API unavailable, using local mock'); - return of(null); - }), finalize(() => this.submitting.set(false)), - ).subscribe(result => { - const mockVersion: MockVersion = { - id: result?.id ?? `inline-${Date.now()}`, + ).subscribe({ + next: (result) => { + const versionOption: VersionOption = { + id: result.id, name, version, componentCount: this.inlineComponents.length, - sealedAt: result?.publishedAt ?? new Date().toISOString(), + sealedAt: result.publishedAt ?? new Date().toISOString(), }; - this.selectedVersion.set(mockVersion); + this.availableVersions.update((items) => [versionOption, ...items.filter((item) => item.id !== versionOption.id)]); + this.selectedVersion.set(versionOption); this.showInlineVersion.set(false); this.inlineVersion.name = ''; this.inlineVersion.version = ''; this.inlineComponents = []; + }, + error: (error) => { + this.submitError.set(this.mapCreateError(error)); + }, }); } @@ -1578,24 +1483,25 @@ export class CreateDeploymentComponent { reason: `Inline hotfix creation: ${hotfixTag}`, }).pipe(map(() => version))), )), - catchError(() => { - // API unavailable -- fall back to local mock - console.log('[CreateDeployment] sealInlineHotfix: API unavailable, using local mock'); - return of(null); - }), finalize(() => this.submitting.set(false)), - ).subscribe(result => { - const mockHotfix: MockHotfix = { - id: result?.id ?? `inline-hf-${Date.now()}`, + ).subscribe({ + next: (result) => { + const hotfixOption: HotfixOption = { + id: result.id, name: hotfixName, image: img.repository, tag: hotfixTag, - sealedAt: result?.publishedAt ?? now.toISOString(), + sealedAt: result.publishedAt ?? now.toISOString(), }; - this.selectedHotfix.set(mockHotfix); + this.availableHotfixes.update((items) => [hotfixOption, ...items.filter((item) => item.id !== hotfixOption.id)]); + this.selectedHotfix.set(hotfixOption); this.showInlineHotfix.set(false); this.inlineHotfixImage.set(null); this.inlineHotfixDigest.set(''); + }, + error: (error) => { + this.submitError.set(this.mapCreateError(error)); + }, }); } @@ -1687,19 +1593,6 @@ export class CreateDeploymentComponent { } } - addAbTargetGroupStage(): void { - this.strategyConfig.ab.targetGroupStages.push({ - name: `Stage ${this.strategyConfig.ab.targetGroupStages.length + 1}`, - aPercent: 0, bPercent: 100, durationMinutes: 15, healthThreshold: 99, - }); - } - - removeAbTargetGroupStage(index: number): void { - if (this.strategyConfig.ab.targetGroupStages.length > 1) { - this.strategyConfig.ab.targetGroupStages.splice(index, 1); - } - } - // ─── Create deployment ─── createDeployment(): void { @@ -1709,28 +1602,31 @@ export class CreateDeploymentComponent { this.submitError.set(null); const pkg = this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix(); - if (!pkg) return; + const release = this.linkedRelease(); + const environmentId = this.resolveTargetEnvironmentId(); + if (!pkg || !release || !environmentId) { + this.submitError.set('Select a package and target environment before creating the deployment.'); + this.submitting.set(false); + return; + } - const slug = `deploy-${this.toSlug(pkg.name)}-${Date.now()}`; - const description = `Deployment of ${pkg.name} to ${this.getTargetRegionNames().join(', ')} with ${this.deploymentStrategy} strategy`; + const request: CreateDeploymentRequest = { + releaseId: release.id, + environmentId, + environmentName: this.environmentDisplayName(environmentId), + strategy: this.deploymentStrategy, + strategyConfig: this.getActiveStrategyConfig(), + packageType: this.packageType(), + packageRefId: pkg.id, + packageRefName: pkg.name, + promotionStages: this.promotionStages.filter((stage) => stage.environmentId.trim().length > 0), + }; - // Try to create via bundle API, fall back to console.log if unavailable - this.bundleApi.createBundle({ slug, name: pkg.name, description }).pipe( - catchError(() => { - // API not available -- log payload and navigate anyway - console.log('[CreateDeployment] API unavailable, payload:', { - pkg, - regions: this.targetRegions(), - environments: this.targetEnvironments(), - strategy: this.deploymentStrategy, - config: this.getActiveStrategyConfig(), - }); - return of(null); - }), + this.deploymentApi.createDeployment(request).pipe( finalize(() => this.submitting.set(false)), ).subscribe({ - next: () => { - void this.router.navigate(['/releases'], { queryParamsHandling: 'merge' }); + next: (deployment) => { + void this.router.navigate(['/releases/deployments', deployment.id], { queryParamsHandling: 'merge' }); }, error: (error) => { this.submitError.set(this.mapCreateError(error)); @@ -1743,14 +1639,99 @@ export class CreateDeploymentComponent { case 'rolling': return this.strategyConfig.rolling; case 'canary': return this.strategyConfig.canary; case 'blue_green': return this.strategyConfig.blueGreen; - case 'recreate': return this.strategyConfig.recreate; - case 'ab-release': return this.strategyConfig.ab; + case 'all_at_once': return this.strategyConfig.allAtOnce; default: return {}; } } // ─── Private helpers ─── + private setLinkedRelease(release: ManagedRelease): void { + this.linkedRelease.set(release); + this.packageType.set(release.hotfixLane ? 'hotfix' : 'version'); + this.loadPackageOptions(release); + } + + private loadPackageOptions(release: ManagedRelease): void { + this.packageLoadError.set(null); + this.availableVersions.set([]); + this.availableHotfixes.set([]); + this.selectedVersion.set(null); + this.selectedHotfix.set(null); + + this.bundleApi.listBundleVersions(release.id, 50, 0).pipe( + switchMap((versions) => { + if (versions.length === 0) { + return of([] as Array<{ detail: any }>); + } + return forkJoin( + versions.map((version) => + this.bundleApi.getBundleVersion(release.id, version.id).pipe( + map((detail) => ({ detail })), + ), + ), + ); + }), + ).subscribe({ + next: (items) => { + const versions = items.map(({ detail }) => this.toVersionOption(release, detail)); + const hotfixes = items + .map(({ detail }) => this.toHotfixOption(release, detail)) + .filter((item): item is HotfixOption => item !== null); + this.availableVersions.set(versions); + this.availableHotfixes.set(hotfixes); + }, + error: (error) => { + this.packageLoadError.set(this.mapCreateError(error)); + }, + }); + } + + private toVersionOption(release: ManagedRelease, detail: any): VersionOption { + return { + id: detail.id, + name: release.name, + version: `Version ${detail.versionNumber}`, + componentCount: detail.components?.length ?? 0, + sealedAt: detail.publishedAt ?? detail.createdAt, + }; + } + + private toHotfixOption(release: ManagedRelease, detail: any): HotfixOption | null { + const firstComponent = detail.components?.[0]; + const metadata = this.parseMetadata(firstComponent?.metadataJson); + const tag = typeof metadata['tag'] === 'string' ? metadata['tag'] : ''; + const isHotfix = release.hotfixLane || tag.includes('hf') || metadata['hotfix'] === true; + if (!isHotfix || !firstComponent) { + return null; + } + + return { + id: detail.id, + name: `${release.name}-hotfix`, + image: typeof metadata['imageRef'] === 'string' ? metadata['imageRef'] : firstComponent.componentName, + tag: tag || `hf-${detail.versionNumber}`, + sealedAt: detail.publishedAt ?? detail.createdAt, + }; + } + + private parseMetadata(raw: string | null | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } + } + + private resolveTargetEnvironmentId(): string | null { + if (this.packageType() === 'hotfix') { + return this.targetEnvironments()[0] ?? null; + } + return this.promotionStages.find((stage) => stage.environmentId.trim().length > 0)?.environmentId ?? null; + } + private createOrReuseBundle(slug: string, name: string, description: string) { return this.bundleApi.createBundle({ slug, name, description }).pipe( catchError(error => { @@ -1782,7 +1763,7 @@ export class CreateDeploymentComponent { private mapCreateError(error: unknown): string { const status = this.statusCodeOf(error); if (status === 403) return 'Deployment creation requires orch:operate scope. Current session is not authorized.'; - if (status === 409) return 'A deployment bundle with this slug already exists.'; + if (status === 409) return 'A deployment with this identity already exists or is already in progress.'; if (status === 503) return 'Release control backend is unavailable. The deployment was not created.'; return 'Failed to create deployment.'; } diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index dbbdecc38..fa0aca9fe 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -815,7 +815,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy { effect(() => { this.helperCtx.setScope('releases-activity', this.helperContexts()); - }, { allowSignalWrites: true }); + }); } mergeQuery(next: Record): Record { @@ -1045,3 +1045,4 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy { }); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-list-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-list-page.component.ts index a5b882925..eb9d3f8fa 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-list-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-list-page.component.ts @@ -393,7 +393,7 @@ export class ReleasesListPageComponent { constructor() { effect(() => { this.helperCtx.setScope('releases-list', this.helperContexts()); - }, { allowSignalWrites: true }); + }); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('releases-list')); } @@ -457,3 +457,4 @@ export class ReleasesListPageComponent { }); } } + diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.spec.ts index 83c598305..4905291f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.spec.ts @@ -1,39 +1,61 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; import { ScheduleManagementComponent } from './schedule-management.component'; import { Schedule } from './scheduler-ops.models'; +import { SCHEDULER_API, SchedulerApi } from '../../core/api/scheduler.client'; describe('ScheduleManagementComponent', () => { let fixture: ComponentFixture; let component: ScheduleManagementComponent; + let mockApi: jasmine.SpyObj; const mockSchedule: Schedule = { id: 'sch-test-001', name: 'Test Schedule', - description: 'A test schedule', cronExpression: '0 6 * * *', timezone: 'UTC', enabled: true, - taskType: 'scan', - taskConfig: {}, + mode: 'analysis-only', + selection: { scope: 'all-images' }, + limits: { parallelism: 1 }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdBy: 'test@example.com', - tags: ['test', 'production'], - retryPolicy: { - maxRetries: 3, - backoffMultiplier: 2, - initialDelayMs: 1000, - maxDelayMs: 60000, - }, - concurrencyLimit: 1, }; beforeEach(async () => { + mockApi = jasmine.createSpyObj('SchedulerApi', [ + 'listSchedules', 'getSchedule', 'createSchedule', 'updateSchedule', + 'deleteSchedule', 'pauseSchedule', 'resumeSchedule', 'triggerSchedule', + 'previewImpact', 'listRuns', 'cancelRun', 'retryRun', + ]); + mockApi.listSchedules.and.returnValue(of([mockSchedule])); + mockApi.createSchedule.and.returnValue(of(mockSchedule)); + mockApi.updateSchedule.and.returnValue(of(mockSchedule)); + mockApi.deleteSchedule.and.returnValue(of(void 0)); + mockApi.pauseSchedule.and.returnValue(of(void 0)); + mockApi.resumeSchedule.and.returnValue(of(void 0)); + mockApi.triggerSchedule.and.returnValue(of(void 0)); + mockApi.previewImpact.and.returnValue(of({ + total: 42, + usageOnly: true, + generatedAt: new Date().toISOString(), + sample: [], + warnings: [], + })); + await TestBed.configureTestingModule({ imports: [FormsModule, ScheduleManagementComponent], - providers: [provideRouter([])], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: SCHEDULER_API, useValue: mockApi }, + ], }).compileComponents(); fixture = TestBed.createComponent(ScheduleManagementComponent); @@ -61,10 +83,9 @@ describe('ScheduleManagementComponent', () => { expect(cards.length).toBe(1); }); - it('should display schedule name and description', () => { + it('should display schedule name', () => { const card = fixture.nativeElement.querySelector('.schedule-card'); expect(card.textContent).toContain('Test Schedule'); - expect(card.textContent).toContain('A test schedule'); }); it('should display enabled indicator', () => { @@ -76,11 +97,6 @@ describe('ScheduleManagementComponent', () => { const cron = fixture.nativeElement.querySelector('.schedule-cron code'); expect(cron.textContent).toContain('0 6 * * *'); }); - - it('should display tags', () => { - const tags = fixture.nativeElement.querySelectorAll('.tag'); - expect(tags.length).toBe(2); - }); }); describe('Actions Menu', () => { @@ -99,42 +115,26 @@ describe('ScheduleManagementComponent', () => { expect(component.activeMenu()).toBeNull(); }); - it('should toggle schedule enabled status', () => { - expect(component.schedules()[0].enabled).toBe(true); - + it('should toggle schedule enabled status via API', () => { component.toggleEnabled(mockSchedule); - - expect(component.schedules()[0].enabled).toBe(false); - expect(component.activeMenu()).toBeNull(); + expect(mockApi.pauseSchedule).toHaveBeenCalledWith(mockSchedule.id); }); - it('should duplicate schedule', () => { - const initialCount = component.schedules().length; - + it('should duplicate schedule via API', () => { component.duplicateSchedule(mockSchedule); - - expect(component.schedules().length).toBe(initialCount + 1); - const duplicate = component.schedules().find(s => s.name.includes('Copy')); - expect(duplicate).toBeTruthy(); - expect(duplicate?.enabled).toBe(false); + expect(mockApi.createSchedule).toHaveBeenCalled(); }); it('should delete schedule after confirmation', () => { spyOn(window, 'confirm').and.returnValue(true); - const initialCount = component.schedules().length; - component.deleteSchedule(mockSchedule); - - expect(component.schedules().length).toBe(initialCount - 1); + expect(mockApi.deleteSchedule).toHaveBeenCalledWith(mockSchedule.id); }); it('should not delete schedule if cancelled', () => { spyOn(window, 'confirm').and.returnValue(false); - const initialCount = component.schedules().length; - component.deleteSchedule(mockSchedule); - - expect(component.schedules().length).toBe(initialCount); + expect(mockApi.deleteSchedule).not.toHaveBeenCalled(); }); }); @@ -160,18 +160,15 @@ describe('ScheduleManagementComponent', () => { expect(component.showModal()).toBe(false); }); - it('should create new schedule', () => { - const initialCount = component.schedules().length; - + it('should create new schedule via API', () => { component.showCreateModal(); component.scheduleForm.name = 'New Schedule'; component.scheduleForm.cronExpression = '0 12 * * *'; - component.scheduleForm.taskType = 'scan'; + component.scheduleForm.mode = 'analysis-only'; component.saveSchedule(); - expect(component.schedules().length).toBe(initialCount + 1); - expect(component.showModal()).toBe(false); + expect(mockApi.createSchedule).toHaveBeenCalled(); }); }); @@ -190,14 +187,13 @@ describe('ScheduleManagementComponent', () => { expect(component.scheduleForm.name).toBe(mockSchedule.name); }); - it('should update existing schedule', () => { + it('should update existing schedule via API', () => { component.editSchedule(mockSchedule); component.scheduleForm.name = 'Updated Name'; component.saveSchedule(); - const updated = component.schedules().find(s => s.id === mockSchedule.id); - expect(updated?.name).toBe('Updated Name'); + expect(mockApi.updateSchedule).toHaveBeenCalled(); }); }); @@ -205,37 +201,27 @@ describe('ScheduleManagementComponent', () => { it('should validate form correctly', () => { component.showCreateModal(); - // Empty form is invalid component.scheduleForm.name = ''; expect(component.isFormValid()).toBe(false); - // Valid form component.scheduleForm.name = 'Test'; component.scheduleForm.cronExpression = '0 6 * * *'; - component.scheduleForm.taskType = 'scan'; + component.scheduleForm.mode = 'analysis-only'; expect(component.isFormValid()).toBe(true); }); }); describe('Impact Preview', () => { - it('should generate impact preview', () => { + it('should generate impact preview via API', () => { component.showCreateModal(); component.scheduleForm.name = 'Test'; component.scheduleForm.cronExpression = '0 6 * * *'; component.previewImpact(); + expect(mockApi.previewImpact).toHaveBeenCalled(); expect(component.impactPreview()).not.toBeNull(); - expect(component.impactPreview()?.nextRunTime).toBeTruthy(); - }); - - it('should detect conflicts', () => { - component.showCreateModal(); - component.scheduleForm.cronExpression = '0 6 * * *'; - - component.previewImpact(); - - expect(component.impactPreview()?.conflicts.length).toBeGreaterThan(0); + expect(component.impactPreview()?.total).toBe(42); }); it('should clear impact preview on modal close', () => { @@ -248,15 +234,10 @@ describe('ScheduleManagementComponent', () => { }); }); - describe('Task Type Labels', () => { - it('should return correct labels for task types', () => { - expect(component.getTaskTypeLabel('scan')).toBe('Container Scan'); - expect(component.getTaskTypeLabel('sbom-refresh')).toBe('SBOM Refresh'); - expect(component.getTaskTypeLabel('vulnerability-sync')).toBe('Vulnerability Sync'); - expect(component.getTaskTypeLabel('advisory-update')).toBe('Advisory Update'); - expect(component.getTaskTypeLabel('export')).toBe('Export'); - expect(component.getTaskTypeLabel('cleanup')).toBe('Cleanup'); - expect(component.getTaskTypeLabel('custom')).toBe('Custom Task'); + describe('Mode Labels', () => { + it('should return correct labels for modes', () => { + expect(component.getModeLabel('analysis-only')).toBe('Analysis Only'); + expect(component.getModeLabel('content-refresh')).toBe('Content Refresh'); }); }); @@ -278,16 +259,11 @@ describe('ScheduleManagementComponent', () => { expect(formatted).toContain('Dec'); expect(formatted).toContain('29'); }); - - it('should calculate next run time', () => { - const nextRun = component.calculateNextRun('0 6 * * *'); - expect(nextRun).toBeTruthy(); - expect(new Date(nextRun).getTime()).toBeGreaterThan(Date.now()); - }); }); describe('Empty State', () => { it('should show empty state when no schedules', () => { + mockApi.listSchedules.and.returnValue(of([])); component.schedules.set([]); fixture.detectChanges(); diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts index c36ffb5a9..158425d80 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/schedule-management.component.ts @@ -7,13 +7,15 @@ import { inject, signal, } from '@angular/core'; +import { SlicePipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { catchError, of } from 'rxjs'; import { Schedule, - ScheduleTaskType, + ScheduleMode, ScheduleImpactPreview, + type SelectorScope, } from './scheduler-ops.models'; import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.client'; import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; @@ -25,7 +27,7 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; */ @Component({ selector: 'app-schedule-management', - imports: [FormsModule, RouterLink], + imports: [FormsModule, RouterLink, SlicePipe], template: `

{{ schedule.name }}

-

{{ schedule.description }}

Schedule @@ -88,7 +89,8 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
- {{ getTaskTypeLabel(schedule.taskType) }} + {{ getModeLabel(schedule.mode) }} + {{ schedule.selection.scope }}
@@ -98,18 +100,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; {{ schedule.lastRunAt ? formatDateTime(schedule.lastRunAt) : 'Never' }}
-
- Next Run - - {{ schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '—' }} - -
-
- -
- @for (tag of schedule.tags; track tag) { - {{ tag }} - }
} @empty { @@ -141,14 +131,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; /> -
- - -
-
@@ -172,46 +154,53 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
-
- - -
-
- - + +
- - + +
+ @if (scheduleForm.selectionScope === 'by-namespace') { +
+ + +
+ } + + @if (scheduleForm.selectionScope === 'by-repository') { +
+ + +
+ } +
- +
@@ -224,25 +213,25 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; @if (impactPreview()) { -
+

Impact Preview

- Next Run - {{ impactPreview()!.nextRunTime || 'N/A' }} + Total Affected + {{ impactPreview()!.total }}
- Estimated Load - {{ impactPreview()!.estimatedLoad }}% + Generated At + {{ formatDateTime(impactPreview()!.generatedAt) }}
- @if (impactPreview()!.conflicts.length > 0) { -
-
Schedule Conflicts
- @for (conflict of impactPreview()!.conflicts; track conflict.scheduleId) { -
- {{ conflict.scheduleName }} - {{ conflict.overlapTime }} + @if (impactPreview()!.sample.length > 0) { +
+
Sample Images ({{ impactPreview()!.sample.length }})
+ @for (item of impactPreview()!.sample; track item.imageDigest) { +
+ {{ item.registry }}/{{ item.repository }} + {{ item.imageDigest | slice:0:19 }}
}
@@ -684,6 +673,44 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; } } + .impact-sample { + margin-top: 0.75rem; + + h5 { + margin: 0 0 0.5rem; + font-size: 0.75rem; + } + + .sample-item { + display: flex; + justify-content: space-between; + padding: 0.375rem 0.5rem; + background: var(--color-surface-primary); + border-radius: var(--radius-sm); + margin-top: 0.25rem; + font-size: 0.8rem; + + .sample-repo { + font-weight: var(--font-weight-medium); + } + + .sample-digest { + font-family: monospace; + font-size: 0.72rem; + color: var(--color-text-secondary); + } + } + } + + .task-scope { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--color-surface-tertiary); + border-radius: var(--radius-sm); + font-size: 0.72rem; + margin-left: 0.375rem; + } + .impact-warnings { .warning-item { padding: 0.5rem; @@ -765,13 +792,13 @@ export class ScheduleManagementComponent implements OnInit { this.editingSchedule.set(schedule); this.scheduleForm = { name: schedule.name, - description: schedule.description, cronExpression: schedule.cronExpression, timezone: schedule.timezone, - taskType: schedule.taskType, - retryPolicy: { ...schedule.retryPolicy }, - concurrencyLimit: schedule.concurrencyLimit, - tagsInput: schedule.tags.join(', '), + mode: schedule.mode, + selectionScope: schedule.selection.scope, + namespacesInput: (schedule.selection.namespaces ?? []).join(', '), + repositoriesInput: (schedule.selection.repositories ?? []).join(', '), + parallelism: schedule.limits.parallelism ?? 1, enabled: schedule.enabled, }; this.impactPreview.set(null); @@ -810,19 +837,27 @@ export class ScheduleManagementComponent implements OnInit { } duplicateSchedule(schedule: Schedule): void { - const newSchedule: Schedule = { - ...schedule, - id: `sch-${Date.now()}`, + const dto: CreateScheduleDto = { name: `${schedule.name} (Copy)`, + cronExpression: schedule.cronExpression, + timezone: schedule.timezone, enabled: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - lastRunAt: undefined, - nextRunAt: undefined, + mode: schedule.mode, + selection: { ...schedule.selection }, + limits: { ...schedule.limits }, }; - this.schedules.update(schedules => [...schedules, newSchedule]); + this.schedulerApi.createSchedule(dto).pipe( + catchError(() => { + this.actionNotice.set(`Failed to duplicate schedule "${schedule.name}".`); + return of(null); + }) + ).subscribe((created) => { + if (created) { + this.actionNotice.set(`Schedule duplicated as "${created.name}".`); + this.loadSchedules(); + } + }); this.activeMenu.set(null); - this.actionNotice.set(`Schedule duplicated as "${newSchedule.name}".`); } runNow(schedule: Schedule): void { @@ -853,17 +888,8 @@ export class ScheduleManagementComponent implements OnInit { } previewImpact(): void { - const dto: CreateScheduleDto = { - name: this.scheduleForm.name, - description: this.scheduleForm.description, - cronExpression: this.scheduleForm.cronExpression, - timezone: this.scheduleForm.timezone, - enabled: this.scheduleForm.enabled, - taskType: this.scheduleForm.taskType as ScheduleTaskType, - retryPolicy: { ...this.scheduleForm.retryPolicy }, - concurrencyLimit: this.scheduleForm.concurrencyLimit, - }; - this.schedulerApi.previewImpact(dto).pipe( + const selector = this.buildSelector(); + this.schedulerApi.previewImpact(selector).pipe( catchError(() => { this.formError.set('Failed to preview impact.'); return of(null); @@ -877,28 +903,21 @@ export class ScheduleManagementComponent implements OnInit { saveSchedule(): void { if (!this.isFormValid()) { - this.formError.set('Provide a name, valid cron expression, and task type before saving.'); + this.formError.set('Provide a name, valid cron expression, and mode before saving.'); return; } const editing = this.editingSchedule(); - const tags = this.scheduleForm.tagsInput - .split(',') - .map(t => t.trim()) - .filter(t => t) - .filter((tag, index, all) => all.indexOf(tag) === index) - .sort((left, right) => left.localeCompare(right)); - const dto: CreateScheduleDto = { name: this.scheduleForm.name, - description: this.scheduleForm.description, cronExpression: this.scheduleForm.cronExpression, timezone: this.scheduleForm.timezone, enabled: this.scheduleForm.enabled, - taskType: this.scheduleForm.taskType as ScheduleTaskType, - tags, - retryPolicy: { ...this.scheduleForm.retryPolicy }, - concurrencyLimit: this.scheduleForm.concurrencyLimit, + mode: this.scheduleForm.mode as ScheduleMode, + selection: this.buildSelector(), + limits: { + parallelism: this.scheduleForm.parallelism, + }, }; const save$ = editing @@ -923,22 +942,17 @@ export class ScheduleManagementComponent implements OnInit { return !!( this.scheduleForm.name && this.scheduleForm.cronExpression && - this.scheduleForm.taskType && + this.scheduleForm.mode && this.isCronExpressionValid(this.scheduleForm.cronExpression) ); } - getTaskTypeLabel(type: ScheduleTaskType): string { - const labels: Record = { - 'scan': 'Container Scan', - 'sbom-refresh': 'SBOM Refresh', - 'vulnerability-sync': 'Vulnerability Sync', - 'advisory-update': 'Advisory Update', - 'export': 'Export', - 'cleanup': 'Cleanup', - 'custom': 'Custom Task', + getModeLabel(mode: ScheduleMode): string { + const labels: Record = { + 'analysis-only': 'Analysis Only', + 'content-refresh': 'Content Refresh', }; - return labels[type] || type; + return labels[mode] || mode; } getCronDescription(cron: string): string { @@ -975,21 +989,36 @@ export class ScheduleManagementComponent implements OnInit { }); } + private buildSelector() { + const scope = (this.scheduleForm.selectionScope || 'all-images') as SelectorScope; + const selector: Record = { scope }; + + if (scope === 'by-namespace') { + selector['namespaces'] = this.parseCommaSeparated(this.scheduleForm.namespacesInput); + } else if (scope === 'by-repository') { + selector['repositories'] = this.parseCommaSeparated(this.scheduleForm.repositoriesInput); + } + + return selector as unknown as import('../../features/scheduler-ops/scheduler-ops.models').ScheduleSelector; + } + + private parseCommaSeparated(input: string): string[] { + return input + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + } + private getEmptyForm() { return { name: '', - description: '', cronExpression: '0 6 * * *', timezone: 'UTC', - taskType: 'scan', - retryPolicy: { - maxRetries: 3, - backoffMultiplier: 2, - initialDelayMs: 1000, - maxDelayMs: 60000, - }, - concurrencyLimit: 1, - tagsInput: '', + mode: 'analysis-only' as string, + selectionScope: 'all-images' as string, + namespacesInput: '', + repositoriesInput: '', + parallelism: 1, enabled: true, }; } diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-ops.models.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-ops.models.ts index 7575b7f02..411185a35 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-ops.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-ops.models.ts @@ -1,82 +1,155 @@ /** - * Scheduler/JobEngine Ops Models (Sprint: SPRINT_20251229_017) + * Scheduler/JobEngine Ops Models + * + * UI-side models aligned with the Scheduler WebService API contracts + * (ScheduleContracts.cs, RunContracts.cs, Enums.cs). */ // ============================================ // Scheduler Run Models // ============================================ +/** + * Maps to backend `Run` record (StellaOps.Scheduler.Models.Run). + * + * Field mapping: + * id <- Run.Id + * scheduleId <- Run.ScheduleId + * scheduleName <- resolved client-side from schedules list (not in backend) + * status <- Run.State (mapped: Planning->pending, Error->failed) + * triggeredAt <- Run.CreatedAt + * startedAt <- Run.StartedAt + * completedAt <- Run.FinishedAt + * durationMs <- computed from FinishedAt - StartedAt + * triggeredBy <- Run.Trigger (mapped: Cron->schedule, Manual->manual, Conselier/Excitor->automated) + * progress <- computed from stats.completed / stats.candidates + * itemsProcessed <- Run.Stats.Completed + * itemsTotal <- Run.Stats.Candidates + * error <- Run.Error + * retryOf <- Run.RetryOf (nullable run ID) + * stats <- Run.Stats (full object for detail views) + */ export interface SchedulerRun { id: string; - scheduleId: string; + scheduleId?: string; scheduleName: string; status: SchedulerRunStatus; triggeredAt: string; startedAt?: string; completedAt?: string; durationMs?: number; - triggeredBy: 'schedule' | 'manual' | 'webhook'; + triggeredBy: SchedulerRunTrigger; progress: number; itemsProcessed: number; itemsTotal: number; - output?: SchedulerRunOutput; error?: string; - retryCount: number; - metadata?: Record; + retryOf?: string; + stats?: SchedulerRunStats; } +/** + * Maps to backend RunState enum: Planning, Queued, Running, Completed, Error, Cancelled. + */ export type SchedulerRunStatus = | 'pending' | 'queued' | 'running' | 'completed' | 'failed' - | 'cancelled' - | 'retrying'; + | 'cancelled'; -export interface SchedulerRunOutput { - logs: string[]; - artifacts: string[]; - metrics: Record; +/** + * Maps to backend RunTrigger enum: Cron, Conselier, Excitor, Manual. + */ +export type SchedulerRunTrigger = 'schedule' | 'manual' | 'automated'; + +/** + * Maps to backend RunStats record. + */ +export interface SchedulerRunStats { + candidates: number; + deduped: number; + queued: number; + completed: number; + deltas: number; + newCriticals: number; + newHigh: number; + newMedium: number; + newLow: number; } // ============================================ // Schedule Models // ============================================ +/** + * Maps to backend Schedule record (StellaOps.Scheduler.Models.Schedule). + * + * Field mapping: + * id <- Schedule.Id + * name <- Schedule.Name + * cronExpression <- Schedule.CronExpression + * timezone <- Schedule.Timezone + * enabled <- Schedule.Enabled + * mode <- Schedule.Mode (AnalysisOnly / ContentRefresh) + * selection <- Schedule.Selection (Selector) + * limits <- Schedule.Limits (ScheduleLimits) + * lastRunAt <- derived from RunSummaryProjection (via ScheduleResponse.Summary) + * createdAt <- Schedule.CreatedAt + * updatedAt <- Schedule.UpdatedAt + * createdBy <- Schedule.CreatedBy + */ export interface Schedule { id: string; name: string; - description: string; cronExpression: string; timezone: string; enabled: boolean; - taskType: ScheduleTaskType; - taskConfig: Record; + mode: ScheduleMode; + selection: ScheduleSelector; + limits: ScheduleLimits; lastRunAt?: string; - nextRunAt?: string; createdAt: string; updatedAt: string; createdBy: string; - tags: string[]; - retryPolicy: RetryPolicy; - concurrencyLimit: number; } -export type ScheduleTaskType = - | 'scan' - | 'sbom-refresh' - | 'vulnerability-sync' - | 'advisory-update' - | 'export' - | 'cleanup' - | 'custom'; +/** + * Maps to backend ScheduleMode enum: AnalysisOnly, ContentRefresh. + * JSON wire format: "analysis-only", "content-refresh". + */ +export type ScheduleMode = 'analysis-only' | 'content-refresh'; -export interface RetryPolicy { - maxRetries: number; - backoffMultiplier: number; - initialDelayMs: number; - maxDelayMs: number; +/** + * Maps to backend Selector record (StellaOps.Scheduler.Models.Selector). + */ +export interface ScheduleSelector { + scope: SelectorScope; + namespaces?: string[]; + repositories?: string[]; + digests?: string[]; + includeTags?: string[]; +} + +/** + * Maps to backend SelectorScope enum. + * JSON wire format: "all-images", "by-namespace", "by-repository", "by-digest", "by-labels". + */ +export type SelectorScope = + | 'all-images' + | 'by-namespace' + | 'by-repository' + | 'by-digest' + | 'by-labels'; + +/** + * Maps to backend ScheduleLimits record. + */ +export interface ScheduleLimits { + maxJobs?: number; + ratePerSecond?: number; + parallelism?: number; + burst?: number; } // ============================================ @@ -288,19 +361,34 @@ export interface JobDagEdge { // Impact Preview Models // ============================================ +/** + * Maps to backend ImpactPreviewResponse (RunContracts.cs). + * + * Field mapping: + * total <- ImpactPreviewResponse.Total + * usageOnly <- ImpactPreviewResponse.UsageOnly + * generatedAt <- ImpactPreviewResponse.GeneratedAt + * snapshotId <- ImpactPreviewResponse.SnapshotId + * sample <- ImpactPreviewResponse.Sample + * warnings <- computed client-side + */ export interface ScheduleImpactPreview { - scheduleId: string; - proposedChange: 'enable' | 'disable' | 'update' | 'delete'; - affectedRuns: number; - nextRunTime?: string; - estimatedLoad: number; - conflicts: ScheduleConflict[]; + total: number; + usageOnly: boolean; + generatedAt: string; + snapshotId?: string; + sample: ImpactPreviewSample[]; warnings: string[]; } -export interface ScheduleConflict { - scheduleId: string; - scheduleName: string; - overlapTime: string; - severity: 'low' | 'medium' | 'high'; +/** + * Maps to backend ImpactPreviewSample record. + */ +export interface ImpactPreviewSample { + imageDigest: string; + registry: string; + repository: string; + namespaces: string[]; + tags: string[]; + usedByEntrypoint: boolean; } diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.spec.ts index 81f3db6c4..2d808a35a 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.spec.ts @@ -1,17 +1,22 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; import { SchedulerRunsComponent } from './scheduler-runs.component'; import { SchedulerRun } from './scheduler-ops.models'; +import { SCHEDULER_API, SchedulerApi } from '../../core/api/scheduler.client'; describe('SchedulerRunsComponent', () => { let fixture: ComponentFixture; let component: SchedulerRunsComponent; + let mockApi: jasmine.SpyObj; const mockRun: SchedulerRun = { id: 'run-test-001', scheduleId: 'sch-test-001', - scheduleName: 'Test Schedule', + scheduleName: 'sch-test-001', status: 'running', triggeredAt: new Date().toISOString(), startedAt: new Date().toISOString(), @@ -19,13 +24,31 @@ describe('SchedulerRunsComponent', () => { progress: 50, itemsProcessed: 500, itemsTotal: 1000, - retryCount: 0, }; beforeEach(async () => { + mockApi = jasmine.createSpyObj('SchedulerApi', [ + 'listSchedules', 'getSchedule', 'createSchedule', 'updateSchedule', + 'deleteSchedule', 'pauseSchedule', 'resumeSchedule', 'triggerSchedule', + 'previewImpact', 'listRuns', 'cancelRun', 'retryRun', + ]); + mockApi.listRuns.and.returnValue(of({ runs: [mockRun], nextCursor: undefined })); + mockApi.cancelRun.and.returnValue(of({ ...mockRun, status: 'cancelled' })); + mockApi.retryRun.and.returnValue(of({ + ...mockRun, + id: 'run-retry-001', + status: 'queued', + retryOf: mockRun.id, + })); + await TestBed.configureTestingModule({ imports: [FormsModule, SchedulerRunsComponent], - providers: [provideRouter([])], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: SCHEDULER_API, useValue: mockApi }, + ], }).compileComponents(); fixture = TestBed.createComponent(SchedulerRunsComponent); @@ -33,7 +56,7 @@ describe('SchedulerRunsComponent', () => { }); afterEach(() => { - component.ngOnDestroy(); + component?.ngOnDestroy(); }); it('should create', () => { @@ -53,12 +76,10 @@ describe('SchedulerRunsComponent', () => { }); it('should filter runs by search query', () => { - component.searchQuery = 'Test Schedule'; - component.onSearch(); + component.onSearchChange('sch-test-001'); expect(component.filteredRuns().length).toBe(1); - component.searchQuery = 'Nonexistent'; - component.onSearch(); + component.onSearchChange('Nonexistent'); expect(component.filteredRuns().length).toBe(0); }); @@ -66,8 +87,7 @@ describe('SchedulerRunsComponent', () => { const completedRun = { ...mockRun, id: 'r2', status: 'completed' as const }; component.runs.set([mockRun, completedRun]); - component.statusFilter = 'running'; - component.onFilterChange(); + component.onStatusFilterChange('running'); expect(component.filteredRuns().length).toBe(1); expect(component.filteredRuns()[0].status).toBe('running'); @@ -105,7 +125,7 @@ describe('SchedulerRunsComponent', () => { expect(stats.total).toBe(5); expect(stats.completed).toBe(2); - expect(stats.running).toBe(2); // running + queued + expect(stats.running).toBe(2); expect(stats.failed).toBe(1); }); @@ -127,9 +147,8 @@ describe('SchedulerRunsComponent', () => { expect(runCards.length).toBe(1); }); - it('should display run name and id', () => { + it('should display run id', () => { const card = fixture.nativeElement.querySelector('.run-card'); - expect(card.textContent).toContain('Test Schedule'); expect(card.textContent).toContain('run-test-001'); }); @@ -160,45 +179,43 @@ describe('SchedulerRunsComponent', () => { fixture.detectChanges(); }); - it('should cancel running run after confirmation', () => { + it('should cancel running run via API after confirmation', () => { spyOn(window, 'confirm').and.returnValue(true); component.cancelRun(mockRun); - const updated = component.runs().find(r => r.id === mockRun.id); - expect(updated?.status).toBe('cancelled'); + expect(mockApi.cancelRun).toHaveBeenCalledWith(mockRun.id); }); - it('should not cancel run if cancelled', () => { + it('should not cancel run if confirmation cancelled', () => { spyOn(window, 'confirm').and.returnValue(false); component.cancelRun(mockRun); - const updated = component.runs().find(r => r.id === mockRun.id); - expect(updated?.status).toBe('running'); + expect(mockApi.cancelRun).not.toHaveBeenCalled(); }); - it('should retry failed run', () => { - const failedRun = { ...mockRun, status: 'failed' as const, retryCount: 1 }; + it('should retry run via API', () => { + const failedRun = { ...mockRun, status: 'failed' as const }; component.runs.set([failedRun]); - const initialCount = component.runs().length; component.retryRun(failedRun); - expect(component.runs().length).toBe(initialCount + 1); - expect(component.runs()[0].status).toBe('queued'); - expect(component.runs()[0].retryCount).toBe(2); + expect(mockApi.retryRun).toHaveBeenCalledWith(failedRun.id); }); }); describe('Run Details', () => { it('should display error for failed runs', () => { - const failedRun = { + const failedRun: SchedulerRun = { ...mockRun, - status: 'failed' as const, + status: 'failed', error: 'Connection timeout', }; + // First detectChanges triggers ngOnInit → loadRuns, then override + fixture.detectChanges(); component.runs.set([failedRun]); + fixture.detectChanges(); component.toggleExpand(failedRun.id); fixture.detectChanges(); @@ -207,32 +224,45 @@ describe('SchedulerRunsComponent', () => { expect(errorDiv.textContent).toContain('Connection timeout'); }); - it('should display output metrics for completed runs', () => { - const completedRun = { + it('should display stats for runs with stats', () => { + const runWithStats: SchedulerRun = { ...mockRun, - status: 'completed' as const, - output: { - logs: [], - artifacts: [], - metrics: { 'items_processed': 1000, 'errors': 0 }, + status: 'completed', + stats: { + candidates: 100, + deduped: 5, + queued: 95, + completed: 95, + deltas: 10, + newCriticals: 2, + newHigh: 3, + newMedium: 5, + newLow: 0, }, }; - component.runs.set([completedRun]); - component.toggleExpand(completedRun.id); + // First detectChanges triggers ngOnInit → loadRuns, then override + fixture.detectChanges(); + component.runs.set([runWithStats]); + fixture.detectChanges(); + component.toggleExpand(runWithStats.id); fixture.detectChanges(); const metricItems = fixture.nativeElement.querySelectorAll('.metric-item'); - expect(metricItems.length).toBe(2); + expect(metricItems.length).toBeGreaterThan(0); }); }); describe('Utility Methods', () => { - it('should get metric entries', () => { - const metrics = { key1: 100, key2: 200 }; - const entries = component.getMetricEntries(metrics); + it('should get stats entries filtering zero values', () => { + const stats = { + candidates: 100, deduped: 0, queued: 50, completed: 50, + deltas: 5, newCriticals: 1, newHigh: 2, newMedium: 0, newLow: 0, + }; + const entries = component.getStatsEntries(stats); - expect(entries.length).toBe(2); - expect(entries[0]).toEqual({ key: 'key1', value: 100 }); + // Non-zero values: candidates(100), queued(50), completed(50), deltas(5), critical(1), high(2) = 6 + expect(entries.length).toBe(6); + expect(entries.find(e => e.key === 'Candidates')?.value).toBe(100); }); it('should format datetime correctly', () => { @@ -273,6 +303,7 @@ describe('SchedulerRunsComponent', () => { describe('Empty State', () => { it('should show empty state when no runs match', () => { + mockApi.listRuns.and.returnValue(of({ runs: [], nextCursor: undefined })); component.runs.set([]); fixture.detectChanges(); diff --git a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.ts b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.ts index 0d2fd1077..9e2545fe7 100644 --- a/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scheduler-ops/scheduler-runs.component.ts @@ -8,10 +8,11 @@ import { OnInit, signal, } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; -import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models'; +import { catchError, of } from 'rxjs'; +import { SchedulerRun, SchedulerRunStatus, SchedulerRunStats } from './scheduler-ops.models'; +import { SCHEDULER_API } from '../../core/api/scheduler.client'; import { OPERATIONS_PATHS, schedulerRunStreamPath } from '../platform/ops/operations-paths'; import { DateFormatService } from '../../core/i18n/date-format.service'; @@ -153,24 +154,19 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; Duration {{ run.durationMs ? formatDuration(run.durationMs) : '—' }}
-
- Retry Count - {{ run.retryCount }} -
+ @if (run.retryOf) { +
+ Retry Of + {{ run.retryOf }} +
+ }
- @if (run.error) { -
-

Error

-
{{ run.error }}
-
- } - - @if (run.output) { + @if (run.stats) {
-

Output

+

Stats

- @for (metric of getMetricEntries(run.output.metrics); track metric.key) { + @for (metric of getStatsEntries(run.stats); track metric.key) {
{{ metric.key }} {{ metric.value }} @@ -180,6 +176,13 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
} + @if (run.error) { +
+

Error

+
{{ run.error }}
+
+ } +
@if (run.status === 'running' || run.status === 'queued') {
} @else { + @if (errorMessage()) { +
{{ errorMessage() }}
+ }
@@ -261,6 +264,14 @@ import { DateFormatService } from '../../core/i18n/date-format.service'; .field--sm { flex: 0 1 180px; } .field__label { font-size: 0.6875rem; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } .detail__editor { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); overflow: hidden; } + .detail__error { + padding: 0.7rem 0.85rem; + border: 1px solid var(--color-status-error); + border-radius: var(--radius-md); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + font-size: 0.8125rem; + } /* Action bar */ .detail__actions { display: flex; gap: 0.5rem; align-items: center; } @@ -399,6 +410,7 @@ export class ScriptDetailComponent implements OnInit, OnDestroy { readonly loadingScript = signal(false); readonly saving = signal(false); readonly compiling = signal(false); + readonly errorMessage = signal(null); readonly currentScript = signal