Update Web UI components, test suite, and bundle configuration
Refactor 40+ feature components (evidence, graph, scheduler, topology, security, releases), stabilize 80+ test specs, add active-surfaces vitest config, setup-wizard SCSS extraction, and deployment create spec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
258
src/Web/StellaOps.Web/scripts/run-unit-test-batches.mjs
Normal file
258
src/Web/StellaOps.Web/scripts/run-unit-test-batches.mjs
Normal file
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DeploymentSummary[]>;
|
||||
getDeployment(id: string): Observable<Deployment>;
|
||||
createDeployment(request: CreateDeploymentRequest): Observable<Deployment>;
|
||||
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]>;
|
||||
getDeploymentEvents(deploymentId: string): Observable<DeploymentEvent[]>;
|
||||
getDeploymentMetrics(deploymentId: string): Observable<DeploymentMetrics>;
|
||||
@@ -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<DeploymentSummary[]>(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<Deployment> {
|
||||
return this.http.get<Deployment>(`${this.baseUrl}/${id}`);
|
||||
return this.http.get<BackendDeploymentDetail>(`${this.baseUrl}/${id}`).pipe(
|
||||
map((response) => this.mapDetail(response)),
|
||||
);
|
||||
}
|
||||
|
||||
createDeployment(request: CreateDeploymentRequest): Observable<Deployment> {
|
||||
return this.http.post<BackendDeploymentDetail>(this.baseUrl, request).pipe(
|
||||
map((response) => this.mapDetail(response)),
|
||||
);
|
||||
}
|
||||
|
||||
getDeploymentLogs(deploymentId: string, targetId?: string): Observable<LogEntry[]> {
|
||||
const url = targetId
|
||||
? `${this.baseUrl}/${deploymentId}/targets/${targetId}/logs`
|
||||
: `${this.baseUrl}/${deploymentId}/logs`;
|
||||
return this.http.get<LogEntry[]>(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<DeploymentEvent[]> {
|
||||
return this.http.get<DeploymentEvent[]>(`${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<DeploymentMetrics> {
|
||||
return this.http.get<DeploymentMetrics>(`${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<void> {
|
||||
@@ -89,9 +145,121 @@ export class DeploymentHttpClient implements DeploymentApi {
|
||||
}
|
||||
|
||||
subscribeToUpdates(deploymentId: string): Observable<Deployment> {
|
||||
// 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<Deployment> {
|
||||
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<LogEntry[]> {
|
||||
const baseLogs: LogEntry[] = [
|
||||
{ timestamp: new Date(Date.now() - 280000).toISOString(), level: 'info', source: 'jobengine', targetId: null, message: 'Deployment started' },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<ReleaseEvidenceApi>('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<EvidenceListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
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<EvidenceListResponse>(this.baseUrl, { params });
|
||||
return this.http.get<ReleaseEvidenceListResponseDto>(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<EvidencePacketDetail> {
|
||||
return this.http.get<EvidencePacketDetail>(`${this.baseUrl}/${id}`);
|
||||
return this.http.get<ReleaseEvidencePacketDto>(`${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<VerificationResult> {
|
||||
@@ -61,7 +111,150 @@ export class ReleaseEvidenceHttpClient implements ReleaseEvidenceApi {
|
||||
}
|
||||
|
||||
getTimeline(id: string): Observable<EvidenceTimelineEvent[]> {
|
||||
return this.http.get<EvidenceTimelineEvent[]>(`${this.baseUrl}/${id}/timeline`);
|
||||
return this.http.get<ReleaseEvidenceTimelineResponseDto>(`${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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
tags?: string[];
|
||||
retryPolicy?: RetryPolicy;
|
||||
concurrencyLimit?: number;
|
||||
mode: ScheduleMode;
|
||||
selection: ScheduleSelector;
|
||||
limits?: ScheduleLimits;
|
||||
}
|
||||
|
||||
export type UpdateScheduleDto = Partial<CreateScheduleDto>;
|
||||
|
||||
interface SchedulerScheduleEnvelope {
|
||||
// ============================================================================
|
||||
// Backend response envelope types
|
||||
// ============================================================================
|
||||
|
||||
interface BackendScheduleEnvelope {
|
||||
readonly schedule?: Record<string, unknown>;
|
||||
readonly summary?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface SchedulerScheduleCollectionResponse {
|
||||
readonly schedules?: readonly SchedulerScheduleEnvelope[];
|
||||
interface BackendScheduleCollectionResponse {
|
||||
readonly schedules?: readonly BackendScheduleEnvelope[];
|
||||
}
|
||||
|
||||
interface SchedulerRunsPreviewResponse {
|
||||
interface BackendRunEnvelope {
|
||||
readonly run?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BackendRunCollectionResponse {
|
||||
readonly runs?: readonly Record<string, unknown>[];
|
||||
readonly nextCursor?: string | null;
|
||||
}
|
||||
|
||||
interface BackendImpactPreviewResponse {
|
||||
readonly total?: number;
|
||||
readonly usageOnly?: boolean;
|
||||
readonly generatedAt?: string;
|
||||
readonly snapshotId?: string | null;
|
||||
readonly sample?: readonly Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -60,7 +84,22 @@ export interface SchedulerApi {
|
||||
pauseSchedule(id: string): Observable<void>;
|
||||
resumeSchedule(id: string): Observable<void>;
|
||||
triggerSchedule(id: string): Observable<void>;
|
||||
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview>;
|
||||
previewImpact(selector?: ScheduleSelector): Observable<ScheduleImpactPreview>;
|
||||
listRuns(options?: RunListOptions): Observable<RunListResult>;
|
||||
cancelRun(runId: string): Observable<SchedulerRun>;
|
||||
retryRun(runId: string): Observable<SchedulerRun>;
|
||||
}
|
||||
|
||||
export interface RunListOptions {
|
||||
scheduleId?: string;
|
||||
state?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface RunListResult {
|
||||
runs: SchedulerRun[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export const SCHEDULER_API = new InjectionToken<SchedulerApi>('SCHEDULER_API');
|
||||
@@ -78,55 +117,43 @@ export class SchedulerHttpClient implements SchedulerApi {
|
||||
private readonly authSession: AuthSessionStore,
|
||||
) {}
|
||||
|
||||
// --- Schedule endpoints ---
|
||||
|
||||
listSchedules(): Observable<Schedule[]> {
|
||||
return this.http.get<SchedulerScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules/`, {
|
||||
const params = new HttpParams().set('includeDisabled', 'false');
|
||||
return this.http.get<BackendScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules/`, {
|
||||
headers: this.buildHeaders(),
|
||||
params,
|
||||
}).pipe(
|
||||
map((response) => this.mapScheduleList(response)),
|
||||
);
|
||||
}
|
||||
|
||||
getSchedule(id: string): Observable<Schedule> {
|
||||
return this.http.get<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/${id}`, {
|
||||
return this.http.get<BackendScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/${id}`, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((response) => this.mapSchedule(response)),
|
||||
);
|
||||
}
|
||||
|
||||
createSchedule(schedule: CreateScheduleDto): Observable<Schedule> {
|
||||
const payload = this.toCreateRequest(schedule);
|
||||
return this.http.post<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/`, payload, {
|
||||
createSchedule(dto: CreateScheduleDto): Observable<Schedule> {
|
||||
return this.http.post<BackendScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/`, dto, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((response) => this.mapSchedule(response)),
|
||||
);
|
||||
}
|
||||
|
||||
updateSchedule(id: string, schedule: UpdateScheduleDto): Observable<Schedule> {
|
||||
const headers = this.buildHeaders();
|
||||
const payload = this.toUpdateRequest(schedule);
|
||||
|
||||
return this.http.patch<SchedulerScheduleEnvelope | Schedule>(`${this.baseUrl}/schedules/${id}`, payload, {
|
||||
headers,
|
||||
updateSchedule(id: string, dto: UpdateScheduleDto): Observable<Schedule> {
|
||||
return this.http.patch<BackendScheduleEnvelope | Schedule>(`${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<void>(`${this.baseUrl}/schedules/${id}/resume`, {}, { headers })
|
||||
: this.http.post<void>(`${this.baseUrl}/schedules/${id}/pause`, {}, { headers });
|
||||
|
||||
return toggle$.pipe(map(() => response));
|
||||
}),
|
||||
map((response) => this.mapSchedule(response)),
|
||||
);
|
||||
}
|
||||
|
||||
deleteSchedule(id: string): Observable<void> {
|
||||
// Compatibility fallback: pausing removes the item from default list responses.
|
||||
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/pause`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
@@ -156,36 +183,74 @@ export class SchedulerHttpClient implements SchedulerApi {
|
||||
});
|
||||
}
|
||||
|
||||
previewImpact(_schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
|
||||
return this.http.post<SchedulerRunsPreviewResponse>(`${this.baseUrl}/runs/preview`, {
|
||||
selector: {
|
||||
scope: 'all-images',
|
||||
},
|
||||
usageOnly: true,
|
||||
sampleSize: 10,
|
||||
}, {
|
||||
// --- Run endpoints ---
|
||||
|
||||
listRuns(options?: RunListOptions): Observable<RunListResult> {
|
||||
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<BackendRunCollectionResponse>(`${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<SchedulerRun> {
|
||||
return this.http.post<BackendRunEnvelope>(`${this.baseUrl}/runs/${runId}/cancel`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((response) => this.mapRun(response?.run ?? response as Record<string, unknown>)),
|
||||
);
|
||||
}
|
||||
|
||||
retryRun(runId: string): Observable<SchedulerRun> {
|
||||
return this.http.post<BackendRunEnvelope>(`${this.baseUrl}/runs/${runId}/retry`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((response) => this.mapRun(response?.run ?? response as Record<string, unknown>)),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Impact preview ---
|
||||
|
||||
previewImpact(selector?: ScheduleSelector): Observable<ScheduleImpactPreview> {
|
||||
const body: Record<string, unknown> = {
|
||||
selector: selector ?? { scope: 'all-images' },
|
||||
usageOnly: true,
|
||||
sampleSize: 10,
|
||||
};
|
||||
|
||||
return this.http.post<BackendImpactPreviewResponse>(`${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<string, unknown>): Schedule {
|
||||
const envelope = payload as BackendScheduleEnvelope;
|
||||
const schedule = (envelope?.schedule ?? payload) as Record<string, unknown>;
|
||||
const summary = envelope?.summary as Record<string, unknown> | 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<string, unknown>[]
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown> {
|
||||
const request: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>, 'imageDigest'),
|
||||
registry: this.readString(s as Record<string, unknown>, 'registry'),
|
||||
repository: this.readString(s as Record<string, unknown>, 'repository'),
|
||||
namespaces: this.readStringArray(s as Record<string, unknown>, 'namespaces'),
|
||||
tags: this.readStringArray(s as Record<string, unknown>, 'tags'),
|
||||
usedByEntrypoint: this.readBoolean(s as Record<string, unknown>, '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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null | undefined, key: string, fallback: boolean): boolean {
|
||||
const value = source?.[key];
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
private readStringArray(source: Record<string, unknown> | 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<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : 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<Schedule[]> {
|
||||
return of([...this.schedules]).pipe(delay(300));
|
||||
}
|
||||
|
||||
getSchedule(id: string): Observable<Schedule> {
|
||||
const s = this.schedules.find(s => s.id === id);
|
||||
return of(s ?? this.schedules[0]).pipe(delay(200));
|
||||
}
|
||||
|
||||
createSchedule(dto: CreateScheduleDto): Observable<Schedule> {
|
||||
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<Schedule> {
|
||||
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<void> {
|
||||
this.schedules = this.schedules.filter(s => s.id !== id);
|
||||
return of(void 0).pipe(delay(200));
|
||||
}
|
||||
|
||||
pauseSchedule(id: string): Observable<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return of(void 0).pipe(delay(200));
|
||||
}
|
||||
|
||||
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return {
|
||||
name: String(item['name'] ?? item['environment'] ?? ''),
|
||||
version: String(item['version'] ?? item['releaseVersion'] ?? 'unknown'),
|
||||
deployedAt: typeof item['deployedAt'] === 'string' ? item['deployedAt'] : undefined,
|
||||
releaseId: typeof item['releaseId'] === 'string' ? item['releaseId'] : undefined,
|
||||
} satisfies DeployedEnvironmentDto;
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0);
|
||||
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<string, unknown>;
|
||||
const actual = this.normalizePercentage(source['score'], this.deterministicRange(cveId, 'score', 45, 95));
|
||||
const threshold = this.normalizePercentage(source['threshold'], 70);
|
||||
const gateStatus = this.toGateStatus(source['gateStatus'], actual, threshold);
|
||||
const reason = typeof source['gateReason'] === 'string'
|
||||
? source['gateReason']
|
||||
: gateStatus === 'pass'
|
||||
? 'Score passed policy threshold.'
|
||||
: gateStatus === 'warn'
|
||||
? 'Score requires manual approval.'
|
||||
: 'Score below policy threshold.';
|
||||
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<string, unknown>;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-findings-list',
|
||||
standalone: true,
|
||||
template: '@for (finding of findings(); track finding.id) { <div>{{ finding.advisoryId }}</div> }',
|
||||
})
|
||||
class StubFindingsListComponent {
|
||||
readonly findings = input<Array<{ id: string; advisoryId: string }>>([]);
|
||||
readonly findingSelect = output<unknown>();
|
||||
}
|
||||
|
||||
describe('FindingsContainerComponent', () => {
|
||||
let fixture: ComponentFixture<FindingsContainerComponent>;
|
||||
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<T>(component: T): RouterLink[] {
|
||||
const fixture = TestBed.createComponent(component as never);
|
||||
@@ -14,6 +25,113 @@ function routerLinksFor<T>(component: T): RouterLink[] {
|
||||
}
|
||||
|
||||
describe('Mission scope-preserving links', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
const vulnerabilityApiStub: Pick<VulnerabilityApi, 'getStats'> = {
|
||||
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<SourceManagementApi, 'getStatus'> = {
|
||||
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<PageActionService, 'set' | 'clear'> = {
|
||||
set: jasmine.createSpy('set'),
|
||||
clear: jasmine.createSpy('clear'),
|
||||
};
|
||||
|
||||
const helperContextStub: Pick<StellaHelperContextService, 'setScope' | 'clearScope'> = {
|
||||
setScope: jasmine.createSpy('setScope'),
|
||||
clearScope: jasmine.createSpy('clearScope'),
|
||||
};
|
||||
|
||||
const preferencesStub: Pick<StellaPreferencesService, 'isBannerDismissed' | 'dismissBanner'> = {
|
||||
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<string | null>(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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(component: Type<T>): 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<unknown>; routeData?: Record<string, unknown>; 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="notice">Loading evidence packets...</div>
|
||||
}
|
||||
|
||||
@if (error(); as errorMessage) {
|
||||
<div class="notice notice--error">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
@if (feedback(); as feedbackMessage) {
|
||||
<div class="notice" [class.notice--error]="feedbackTone() === 'error'">
|
||||
{{ feedbackMessage }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Evidence Table -->
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
@@ -138,7 +155,7 @@ interface EvidencePacket {
|
||||
View Packet
|
||||
</button>
|
||||
<button type="button" class="btn btn--sm" (click)="downloadPacket(packet)">
|
||||
Export
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -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<string | null>(null);
|
||||
readonly feedbackTone = signal<'info' | 'error'>('info');
|
||||
|
||||
packets = signal<EvidencePacket[]>([
|
||||
{ id: 'EVD-2026-045', type: 'promotion', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'QA', createdAt: '2h ago', signed: true, verified: true, containsProofChain: true },
|
||||
{ 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<EvidencePacket[]>(() =>
|
||||
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<EvidencePacketSummary>(this.toPacketSummary(this.packets()[0]!));
|
||||
readonly drawerContents = signal<EvidenceContentItem[]>(this.buildPacketContents(this.packets()[0]!));
|
||||
readonly drawerPacket = signal<EvidencePacketSummary>({
|
||||
evidenceId: '',
|
||||
type: 'release',
|
||||
subject: 'No packet selected',
|
||||
subjectDigest: '',
|
||||
signed: false,
|
||||
verified: false,
|
||||
createdAt: '',
|
||||
contentCount: 0,
|
||||
totalSize: '0 B',
|
||||
});
|
||||
readonly drawerContents = signal<EvidenceContentItem[]>([]);
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const PACKET_TABS: StellaPageTab[] = [
|
||||
template: `
|
||||
<div class="evidence-packet">
|
||||
<header class="page-header">
|
||||
<a routerLink="/evidence" class="back-link">← Back to Evidence Center</a>
|
||||
<a routerLink="/evidence" class="back-link">Back to Evidence Center</a>
|
||||
<div class="header-main">
|
||||
<h1 class="page-title">{{ packet().id }}</h1>
|
||||
<div class="header-badges">
|
||||
@@ -47,7 +47,7 @@ const PACKET_TABS: StellaPageTab[] = [
|
||||
</div>
|
||||
</div>
|
||||
<p class="page-subtitle">
|
||||
Created {{ packet().createdAt }} · Release {{ packet().releaseVersion }}
|
||||
Created {{ packet().createdAt }} | Release {{ packet().releaseVersion }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -178,7 +178,7 @@ const PACKET_TABS: StellaPageTab[] = [
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</stella-page-tabs>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
<rect
|
||||
class="reachability-halo"
|
||||
class="overlay-halo"
|
||||
x="-6"
|
||||
y="-6"
|
||||
[attr.width]="node.width + 12"
|
||||
[attr.height]="node.height + 12"
|
||||
rx="12"
|
||||
fill="none"
|
||||
[attr.stroke]="getReachabilityHaloStroke(reach.latticeState)"
|
||||
[attr.stroke]="indicator.stroke"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="5 4"
|
||||
opacity="0.85"
|
||||
>
|
||||
<title>{{ reach.latticeState }} {{ reach.status }} ({{ (reach.confidence * 100).toFixed(0) }}%) - {{ reach.observedAt }}</title>
|
||||
<title>{{ indicator.summary }}</title>
|
||||
</rect>
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +122,9 @@
|
||||
</div>
|
||||
<div class="canvas-view__sidebar">
|
||||
<app-graph-overlays
|
||||
[nodeIds]="nodeIds()"
|
||||
[overlayState]="availableOverlays()"
|
||||
[selectedNodeId]="selectedNodeId()"
|
||||
(overlayStateChange)="onOverlayStateChange($event)"
|
||||
(simulationModeChange)="onSimulationModeChange($event)"
|
||||
(pathViewChange)="onPathViewChange($event)"
|
||||
(timeTravelChange)="onTimeTravelChange($event)"
|
||||
(showDiffRequest)="onShowDiffRequest($event)"
|
||||
></app-graph-overlays>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<GraphOverlayState | null>(null);
|
||||
readonly overlayState = signal<GraphOverlayState | null>(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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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[] = [
|
||||
<!-- ═══ STEP 1: SELECT PACKAGE ═══ -->
|
||||
@case (1) {
|
||||
<div class="step-panel">
|
||||
<div class="step-intro">
|
||||
<h2>Select Package</h2>
|
||||
<p>Choose a sealed Version or Hotfix to deploy, or create one inline.</p>
|
||||
</div>
|
||||
<div class="step-intro">
|
||||
<h2>Select Package</h2>
|
||||
<p>Choose a sealed Version or Hotfix to deploy, or create one inline.</p>
|
||||
</div>
|
||||
|
||||
<!-- Package type toggle -->
|
||||
@if (packageLoadError(); as packageError) {
|
||||
<div class="submit-error">{{ packageError }}</div>
|
||||
}
|
||||
|
||||
<!-- Package type toggle -->
|
||||
<div class="type-toggle-row">
|
||||
<span class="field__label">Package type</span>
|
||||
<div class="toggle-pair" role="radiogroup" aria-label="Package type">
|
||||
@@ -511,8 +503,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
||||
<option value="rolling">Rolling Update</option>
|
||||
<option value="blue_green">Blue/Green</option>
|
||||
<option value="canary">Canary</option>
|
||||
<option value="recreate">Recreate</option>
|
||||
<option value="ab-release">A/B Release</option>
|
||||
<option value="all_at_once">All at Once</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -648,16 +639,16 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
@case ('recreate') {
|
||||
@case ('all_at_once') {
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Max concurrency</span>
|
||||
<input type="number" [(ngModel)]="strategyConfig.recreate.maxConcurrency" min="0" placeholder="0" />
|
||||
<input type="number" [(ngModel)]="strategyConfig.allAtOnce.maxConcurrency" min="0" placeholder="0" />
|
||||
<span class="field__hint">0 = unlimited concurrency</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Failure behavior</span>
|
||||
<select [(ngModel)]="strategyConfig.recreate.failureBehavior">
|
||||
<select [(ngModel)]="strategyConfig.allAtOnce.failureBehavior">
|
||||
<option value="rollback">Rollback</option>
|
||||
<option value="continue">Continue</option>
|
||||
<option value="pause">Pause</option>
|
||||
@@ -666,82 +657,9 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field__label">Health check timeout (seconds)</span>
|
||||
<input type="number" [(ngModel)]="strategyConfig.recreate.healthCheckTimeout" min="0" placeholder="120" />
|
||||
<input type="number" [(ngModel)]="strategyConfig.allAtOnce.healthCheckTimeout" min="0" placeholder="120" />
|
||||
</label>
|
||||
}
|
||||
@case ('ab-release') {
|
||||
<label class="field">
|
||||
<span class="field__label">A/B sub-type</span>
|
||||
<div class="toggle-pair" role="radiogroup" aria-label="A/B sub-type">
|
||||
<button type="button" role="radio" class="toggle-pair__btn"
|
||||
[class.toggle-pair__btn--active]="strategyConfig.ab.subType === 'target-group'"
|
||||
[attr.aria-checked]="strategyConfig.ab.subType === 'target-group'"
|
||||
(click)="strategyConfig.ab.subType = 'target-group'">Target-Group</button>
|
||||
<button type="button" role="radio" class="toggle-pair__btn"
|
||||
[class.toggle-pair__btn--active]="strategyConfig.ab.subType === 'router-based'"
|
||||
[attr.aria-checked]="strategyConfig.ab.subType === 'router-based'"
|
||||
(click)="strategyConfig.ab.subType = 'router-based'">Router-Based</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@if (strategyConfig.ab.subType === 'target-group') {
|
||||
<div class="canary-stages">
|
||||
<div class="canary-stages__header">
|
||||
<span class="field__label">Rollout stages</span>
|
||||
<button type="button" class="btn-secondary btn-sm" (click)="addAbTargetGroupStage()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Add Stage
|
||||
</button>
|
||||
</div>
|
||||
@for (stage of strategyConfig.ab.targetGroupStages; track $index; let i = $index) {
|
||||
<div class="canary-stage-row canary-stage-row--ab">
|
||||
<span class="canary-stage-row__num">{{ i + 1 }}</span>
|
||||
<label class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<input type="text" [(ngModel)]="stage.name" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">A %</span>
|
||||
<input type="number" [(ngModel)]="stage.aPercent" min="0" max="100" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">B %</span>
|
||||
<input type="number" [(ngModel)]="stage.bPercent" min="0" max="100" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Duration</span>
|
||||
<input type="number" [(ngModel)]="stage.durationMinutes" min="0" />
|
||||
</label>
|
||||
@if (strategyConfig.ab.targetGroupStages.length > 1) {
|
||||
<button type="button" class="btn-remove btn-remove--sm" (click)="removeAbTargetGroupStage(i)" aria-label="Remove stage">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<label class="field">
|
||||
<span class="field__label">Routing strategy</span>
|
||||
<select [(ngModel)]="strategyConfig.ab.routerBasedConfig.routingStrategy">
|
||||
<option value="weight-based">Weight-based</option>
|
||||
<option value="header-based">Header-based</option>
|
||||
<option value="cookie-based">Cookie-based</option>
|
||||
<option value="tenant-based">Tenant-based</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Error rate threshold (%)</span>
|
||||
<input type="number" [(ngModel)]="strategyConfig.ab.routerBasedConfig.errorRateThreshold" min="0" max="100" step="0.1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Latency threshold (ms)</span>
|
||||
<input type="number" [(ngModel)]="strategyConfig.ab.routerBasedConfig.latencyThreshold" min="0" />
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
@@ -821,17 +739,9 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
||||
<dt>Warmup</dt><dd>{{ strategyConfig.blueGreen.warmupPeriod }}s</dd>
|
||||
<dt>Keepalive</dt><dd>{{ strategyConfig.blueGreen.blueKeepalive }}min</dd>
|
||||
}
|
||||
@case ('recreate') {
|
||||
<dt>Concurrency</dt><dd>{{ strategyConfig.recreate.maxConcurrency === 0 ? 'unlimited' : strategyConfig.recreate.maxConcurrency }}</dd>
|
||||
<dt>On failure</dt><dd>{{ strategyConfig.recreate.failureBehavior }}</dd>
|
||||
}
|
||||
@case ('ab-release') {
|
||||
<dt>Sub-type</dt><dd>{{ strategyConfig.ab.subType }}</dd>
|
||||
@if (strategyConfig.ab.subType === 'target-group') {
|
||||
<dt>Stages</dt><dd>{{ strategyConfig.ab.targetGroupStages.length }} stage(s)</dd>
|
||||
} @else {
|
||||
<dt>Routing</dt><dd>{{ strategyConfig.ab.routerBasedConfig.routingStrategy }}</dd>
|
||||
}
|
||||
@case ('all_at_once') {
|
||||
<dt>Concurrency</dt><dd>{{ strategyConfig.allAtOnce.maxConcurrency === 0 ? 'unlimited' : strategyConfig.allAtOnce.maxConcurrency }}</dd>
|
||||
<dt>On failure</dt><dd>{{ strategyConfig.allAtOnce.failureBehavior }}</dd>
|
||||
}
|
||||
}
|
||||
</dl>
|
||||
@@ -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<ManagedRelease | null>(null);
|
||||
readonly availableVersions = signal<VersionOption[]>([]);
|
||||
readonly availableHotfixes = signal<HotfixOption[]>([]);
|
||||
readonly packageLoadError = signal<string | null>(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<MockVersion | null>(null);
|
||||
readonly selectedHotfix = signal<MockHotfix | null>(null);
|
||||
readonly selectedVersion = signal<VersionOption | null>(null);
|
||||
readonly selectedHotfix = signal<HotfixOption | null>(null);
|
||||
readonly showInlineVersion = signal(false);
|
||||
readonly showInlineHotfix = signal(false);
|
||||
|
||||
@@ -1290,18 +1204,20 @@ export class CreateDeploymentComponent {
|
||||
readonly inlineHotfixImage = signal<RegistryImage | null>(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<string, unknown> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
|
||||
} 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.';
|
||||
}
|
||||
|
||||
@@ -815,7 +815,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('releases-activity', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
}
|
||||
|
||||
mergeQuery(next: Record<string, string>): Record<string, string | null> {
|
||||
@@ -1045,3 +1045,4 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ScheduleManagementComponent>;
|
||||
let component: ScheduleManagementComponent;
|
||||
let mockApi: jasmine.SpyObj<SchedulerApi>;
|
||||
|
||||
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>('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();
|
||||
|
||||
|
||||
@@ -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: `
|
||||
<div class="schedule-management">
|
||||
<header class="page-header">
|
||||
@@ -79,7 +81,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
</div>
|
||||
|
||||
<h3 class="schedule-name">{{ schedule.name }}</h3>
|
||||
<p class="schedule-description">{{ schedule.description }}</p>
|
||||
|
||||
<div class="schedule-cron">
|
||||
<span class="cron-label">Schedule</span>
|
||||
@@ -88,7 +89,8 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
</div>
|
||||
|
||||
<div class="schedule-task">
|
||||
<span class="task-type">{{ getTaskTypeLabel(schedule.taskType) }}</span>
|
||||
<span class="task-type">{{ getModeLabel(schedule.mode) }}</span>
|
||||
<span class="task-scope">{{ schedule.selection.scope }}</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-timing">
|
||||
@@ -98,18 +100,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
{{ schedule.lastRunAt ? formatDateTime(schedule.lastRunAt) : 'Never' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timing-item">
|
||||
<span class="timing-label">Next Run</span>
|
||||
<span class="timing-value">
|
||||
{{ schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-tags">
|
||||
@for (tag of schedule.tags; track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
@@ -141,14 +131,6 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
[(ngModel)]="scheduleForm.description"
|
||||
placeholder="Describe what this schedule does..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Cron Expression *</label>
|
||||
@@ -172,46 +154,53 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Task Type *</label>
|
||||
<select [(ngModel)]="scheduleForm.taskType">
|
||||
<option value="scan">Container Scan</option>
|
||||
<option value="sbom-refresh">SBOM Refresh</option>
|
||||
<option value="vulnerability-sync">Vulnerability Sync</option>
|
||||
<option value="advisory-update">Advisory Update</option>
|
||||
<option value="export">Export</option>
|
||||
<option value="cleanup">Cleanup</option>
|
||||
<option value="custom">Custom Task</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Max Retries</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="scheduleForm.retryPolicy.maxRetries"
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
<label>Mode *</label>
|
||||
<select [(ngModel)]="scheduleForm.mode">
|
||||
<option value="analysis-only">Analysis Only</option>
|
||||
<option value="content-refresh">Content Refresh</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Concurrency Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="scheduleForm.concurrencyLimit"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
<label>Selection Scope *</label>
|
||||
<select [(ngModel)]="scheduleForm.selectionScope">
|
||||
<option value="all-images">All Images</option>
|
||||
<option value="by-namespace">By Namespace</option>
|
||||
<option value="by-repository">By Repository</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (scheduleForm.selectionScope === 'by-namespace') {
|
||||
<div class="form-group">
|
||||
<label>Namespaces</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scheduleForm.namespacesInput"
|
||||
placeholder="production, staging (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (scheduleForm.selectionScope === 'by-repository') {
|
||||
<div class="form-group">
|
||||
<label>Repositories</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scheduleForm.repositoriesInput"
|
||||
placeholder="myregistry/myrepo (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<label>Parallelism</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scheduleForm.tagsInput"
|
||||
placeholder="production, critical (comma-separated)"
|
||||
type="number"
|
||||
[(ngModel)]="scheduleForm.parallelism"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -224,25 +213,25 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
|
||||
<!-- Impact Preview -->
|
||||
@if (impactPreview()) {
|
||||
<div class="impact-preview" [class.warning]="impactPreview()!.conflicts.length > 0">
|
||||
<div class="impact-preview" [class.warning]="impactPreview()!.warnings.length > 0">
|
||||
<h4>Impact Preview</h4>
|
||||
<div class="impact-details">
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Next Run</span>
|
||||
<span>{{ impactPreview()!.nextRunTime || 'N/A' }}</span>
|
||||
<span class="impact-label">Total Affected</span>
|
||||
<span>{{ impactPreview()!.total }}</span>
|
||||
</div>
|
||||
<div class="impact-item">
|
||||
<span class="impact-label">Estimated Load</span>
|
||||
<span>{{ impactPreview()!.estimatedLoad }}%</span>
|
||||
<span class="impact-label">Generated At</span>
|
||||
<span>{{ formatDateTime(impactPreview()!.generatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (impactPreview()!.conflicts.length > 0) {
|
||||
<div class="impact-conflicts">
|
||||
<h5>Schedule Conflicts</h5>
|
||||
@for (conflict of impactPreview()!.conflicts; track conflict.scheduleId) {
|
||||
<div class="conflict-item" [class]="'severity-' + conflict.severity">
|
||||
<span class="conflict-name">{{ conflict.scheduleName }}</span>
|
||||
<span class="conflict-time">{{ conflict.overlapTime }}</span>
|
||||
@if (impactPreview()!.sample.length > 0) {
|
||||
<div class="impact-sample">
|
||||
<h5>Sample Images ({{ impactPreview()!.sample.length }})</h5>
|
||||
@for (item of impactPreview()!.sample; track item.imageDigest) {
|
||||
<div class="sample-item">
|
||||
<span class="sample-repo">{{ item.registry }}/{{ item.repository }}</span>
|
||||
<span class="sample-digest">{{ item.imageDigest | slice:0:19 }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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<ScheduleTaskType, string> = {
|
||||
'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<ScheduleMode, string> = {
|
||||
'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<string, unknown> = { 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, number>;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<SchedulerRunsComponent>;
|
||||
let component: SchedulerRunsComponent;
|
||||
let mockApi: jasmine.SpyObj<SchedulerApi>;
|
||||
|
||||
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>('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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
<span class="label">Duration</span>
|
||||
<span>{{ run.durationMs ? formatDuration(run.durationMs) : '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Retry Count</span>
|
||||
<span>{{ run.retryCount }}</span>
|
||||
</div>
|
||||
@if (run.retryOf) {
|
||||
<div class="detail-item">
|
||||
<span class="label">Retry Of</span>
|
||||
<code>{{ run.retryOf }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (run.error) {
|
||||
<div class="run-error">
|
||||
<h4>Error</h4>
|
||||
<pre>{{ run.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (run.output) {
|
||||
@if (run.stats) {
|
||||
<div class="run-output">
|
||||
<h4>Output</h4>
|
||||
<h4>Stats</h4>
|
||||
<div class="output-metrics">
|
||||
@for (metric of getMetricEntries(run.output.metrics); track metric.key) {
|
||||
@for (metric of getStatsEntries(run.stats); track metric.key) {
|
||||
<div class="metric-item">
|
||||
<span class="metric-key">{{ metric.key }}</span>
|
||||
<span class="metric-value">{{ metric.value }}</span>
|
||||
@@ -180,6 +176,13 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (run.error) {
|
||||
<div class="run-error">
|
||||
<h4>Error</h4>
|
||||
<pre>{{ run.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="run-actions">
|
||||
@if (run.status === 'running' || run.status === 'queued') {
|
||||
<button class="btn btn-danger" (click)="cancelRun(run); $event.stopPropagation()">
|
||||
@@ -676,7 +679,7 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly schedulerRunStreamPath = schedulerRunStreamPath;
|
||||
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly schedulerApi = inject(SCHEDULER_API);
|
||||
|
||||
readonly searchQuery = signal('');
|
||||
readonly statusFilter = signal<SchedulerRunStatus | ''>('');
|
||||
@@ -743,15 +746,14 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private loadRuns(): void {
|
||||
this.http.get<any>('/scheduler/api/v1/scheduler/runs').subscribe({
|
||||
next: (data) => {
|
||||
const items = Array.isArray(data) ? data : (data?.items ?? data?.runs ?? []);
|
||||
this.runs.set(items);
|
||||
},
|
||||
error: (err) => {
|
||||
this.schedulerApi.listRuns().pipe(
|
||||
catchError((err) => {
|
||||
console.error('[SchedulerRuns] Failed to load runs:', err);
|
||||
this.connectionError.set('Failed to load scheduler runs');
|
||||
},
|
||||
return of({ runs: [], nextCursor: undefined });
|
||||
}),
|
||||
).subscribe((result) => {
|
||||
this.runs.set(result.runs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -802,41 +804,51 @@ export class SchedulerRunsComponent implements OnInit, OnDestroy {
|
||||
|
||||
cancelRun(run: SchedulerRun): void {
|
||||
if (confirm(`Cancel run "${run.id}"?`)) {
|
||||
this.runs.update(runs =>
|
||||
runs.map(r =>
|
||||
r.id === run.id ? { ...r, status: 'cancelled' as SchedulerRunStatus } : r
|
||||
)
|
||||
);
|
||||
this.actionNotice.set(`Run ${run.id} was cancelled.`);
|
||||
this.schedulerApi.cancelRun(run.id).pipe(
|
||||
catchError(() => {
|
||||
this.actionNotice.set(`Failed to cancel run ${run.id}.`);
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe((cancelled) => {
|
||||
if (cancelled) {
|
||||
this.runs.update(runs =>
|
||||
runs.map(r => r.id === run.id ? cancelled : r)
|
||||
);
|
||||
this.actionNotice.set(`Run ${run.id} was cancelled.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
retryRun(run: SchedulerRun): void {
|
||||
const newRun: SchedulerRun = {
|
||||
...run,
|
||||
id: `run-${Date.now()}`,
|
||||
status: 'queued',
|
||||
triggeredAt: new Date().toISOString(),
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
durationMs: undefined,
|
||||
triggeredBy: 'manual',
|
||||
progress: 0,
|
||||
itemsProcessed: 0,
|
||||
error: undefined,
|
||||
output: undefined,
|
||||
retryCount: run.retryCount + 1,
|
||||
};
|
||||
this.runs.update(runs => [newRun, ...runs]);
|
||||
this.actionNotice.set(`Retry queued as ${newRun.id}.`);
|
||||
this.schedulerApi.retryRun(run.id).pipe(
|
||||
catchError(() => {
|
||||
this.actionNotice.set(`Failed to retry run ${run.id}.`);
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe((retried) => {
|
||||
if (retried) {
|
||||
this.runs.update(runs => [retried, ...runs]);
|
||||
this.actionNotice.set(`Retry queued as ${retried.id}.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
viewLogs(run: SchedulerRun): void {
|
||||
this.actionNotice.set(`Opened logs for ${run.id}.`);
|
||||
}
|
||||
|
||||
getMetricEntries(metrics: Record<string, number>): Array<{ key: string; value: number }> {
|
||||
return Object.entries(metrics).map(([key, value]) => ({ key, value }));
|
||||
getStatsEntries(stats: SchedulerRunStats): Array<{ key: string; value: number }> {
|
||||
return [
|
||||
{ key: 'Candidates', value: stats.candidates },
|
||||
{ key: 'Completed', value: stats.completed },
|
||||
{ key: 'Queued', value: stats.queued },
|
||||
{ key: 'Deltas', value: stats.deltas },
|
||||
{ key: 'Critical', value: stats.newCriticals },
|
||||
{ key: 'High', value: stats.newHigh },
|
||||
{ key: 'Medium', value: stats.newMedium },
|
||||
{ key: 'Low', value: stats.newLow },
|
||||
].filter(entry => entry.value > 0);
|
||||
}
|
||||
|
||||
formatDateTime(dateStr: string): string {
|
||||
|
||||
@@ -62,6 +62,9 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
<div class="skeleton-cell" style="height:400px;width:100%"></div>
|
||||
</div>
|
||||
} @else {
|
||||
@if (errorMessage()) {
|
||||
<div class="detail__error">{{ errorMessage() }}</div>
|
||||
}
|
||||
<!-- Form fields -->
|
||||
<div class="detail__form">
|
||||
<div class="detail__fields">
|
||||
@@ -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<string | null>(null);
|
||||
readonly currentScript = signal<Script | null>(null);
|
||||
readonly validationResult = signal<ScriptValidationResult | null>(null);
|
||||
readonly versions = signal<ScriptVersion[]>([]);
|
||||
@@ -431,6 +443,7 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.compiling.set(true);
|
||||
this.validationResult.set(null);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.api.validateScript(language, content, vars).pipe(take(1)).subscribe({
|
||||
next: (result) => {
|
||||
@@ -440,7 +453,8 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
const allDiags = [...result.errors, ...result.warnings];
|
||||
this.scriptEditor?.setDiagnosticMarkers(allDiags);
|
||||
},
|
||||
error: () => {
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Validation service unavailable.');
|
||||
this.validationResult.set({ isValid: false, errors: [{ line: 0, column: 0, message: 'Validation service unavailable', severity: 'error' }], warnings: [] });
|
||||
this.compiling.set(false);
|
||||
},
|
||||
@@ -468,6 +482,7 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
const tags = this.scriptTags.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
this.saving.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
if (this.isCreate) {
|
||||
this.api.createScript({
|
||||
@@ -483,7 +498,10 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
this.saving.set(false);
|
||||
void this.router.navigate(['/ops/scripts']);
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Failed to create script.');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const id = this.currentScript()?.id;
|
||||
@@ -502,7 +520,10 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
this.saving.set(false);
|
||||
this.loadVersions(id);
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Failed to update script.');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -538,6 +559,7 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
const id = this.currentScript()?.id;
|
||||
if (!id) return;
|
||||
this.loadingVersion.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.api.getVersionContent(id, version).pipe(take(1)).subscribe({
|
||||
next: (versionDetail) => {
|
||||
this.editingVersion.set(version);
|
||||
@@ -545,7 +567,10 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
this.scriptContent = versionDetail.content;
|
||||
this.loadingVersion.set(false);
|
||||
},
|
||||
error: () => this.loadingVersion.set(false),
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Failed to load script version.');
|
||||
this.loadingVersion.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -562,17 +587,22 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
if (!id) return;
|
||||
this.compatChecking.set(true);
|
||||
this.compatResult.set(null);
|
||||
this.errorMessage.set(null);
|
||||
this.api.checkCompatibility(id, { targetType: this.compatTargetType }).pipe(take(1)).subscribe({
|
||||
next: (result) => {
|
||||
this.compatResult.set(result);
|
||||
this.compatChecking.set(false);
|
||||
},
|
||||
error: () => this.compatChecking.set(false),
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Compatibility service unavailable.');
|
||||
this.compatChecking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadScript(id: string): void {
|
||||
this.loadingScript.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.api.getScript(id).pipe(take(1)).subscribe({
|
||||
next: (script) => {
|
||||
this.currentScript.set(script);
|
||||
@@ -585,9 +615,9 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadingScript.set(false);
|
||||
this.loadVersions(id);
|
||||
},
|
||||
error: () => {
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Failed to load script.');
|
||||
this.loadingScript.set(false);
|
||||
void this.router.navigate(['/ops/scripts']);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -595,7 +625,9 @@ export class ScriptDetailComponent implements OnInit, OnDestroy {
|
||||
private loadVersions(id: string): void {
|
||||
this.api.getVersions(id).pipe(take(1)).subscribe({
|
||||
next: (v) => this.versions.set(v),
|
||||
error: () => {},
|
||||
error: (err: unknown) => {
|
||||
this.errorMessage.set(err instanceof Error ? err.message : 'Failed to load version history.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,9 @@ export class ScriptsListComponent implements OnInit, OnDestroy {
|
||||
this.scripts.update((list) => list.filter((s) => s.id !== script.id));
|
||||
this.pendingDeleteScript.set(null);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to delete script.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ export class SecuritySbomExplorerPageComponent {
|
||||
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('security-sbom-explorer', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('security-sbom-explorer'));
|
||||
}
|
||||
|
||||
@@ -537,3 +537,4 @@ export class SecuritySbomExplorerPageComponent {
|
||||
return Date.now() - parsed > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ export class UnknownsPageComponent {
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('security-unknowns', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('security-unknowns'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,10 +61,23 @@ type DetailState = 'loading' | 'ready' | 'not-found' | 'malformed' | 'error';
|
||||
}
|
||||
</section>
|
||||
|
||||
<stella-signed-score-ribbon
|
||||
[model]="item.signedScore"
|
||||
(verifyRequested)="verifySignedScore()"
|
||||
/>
|
||||
@if (verifyError(); as verifyError) {
|
||||
<div class="vuln-detail__banner vuln-detail__banner--error">
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item.signedScore; as signedScore) {
|
||||
<stella-signed-score-ribbon
|
||||
[model]="signedScore"
|
||||
[canVerify]="canVerifySignedScore(item)"
|
||||
(verifyRequested)="verifySignedScore()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="vuln-detail__banner">
|
||||
Signed score evidence is not available for this vulnerability.
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="vuln-detail__grid">
|
||||
<article class="vuln-detail__card">
|
||||
@@ -308,6 +321,7 @@ export class VulnerabilityDetailPageComponent {
|
||||
readonly vulnerabilityId = signal('');
|
||||
readonly detail = signal<VulnerabilityDetailViewModel | null>(null);
|
||||
readonly errorMessage = signal('Failed to load vulnerability detail.');
|
||||
readonly verifyError = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap
|
||||
@@ -321,28 +335,28 @@ export class VulnerabilityDetailPageComponent {
|
||||
|
||||
verifySignedScore(): void {
|
||||
const model = this.detail();
|
||||
if (!model) {
|
||||
if (!model || !model.signedScore || !this.canVerifySignedScore(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifyError.set(null);
|
||||
this.facade.verify(model)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (verify) => {
|
||||
this.detail.update((current) => current ? {
|
||||
...current,
|
||||
signedScore: {
|
||||
signedScore: current.signedScore ? {
|
||||
...current.signedScore,
|
||||
verify,
|
||||
},
|
||||
} : current.signedScore,
|
||||
} : current);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to verify signed score.';
|
||||
this.errorMessage.set(message);
|
||||
this.state.set('error');
|
||||
this.verifyError.set(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -372,7 +386,15 @@ export class VulnerabilityDetailPageComponent {
|
||||
return status.replace(/_/g, ' ').replace(/\b\w/g, (value) => value.toUpperCase());
|
||||
}
|
||||
|
||||
formatPercent(value: number): string {
|
||||
canVerifySignedScore(item: VulnerabilityDetailViewModel): boolean {
|
||||
return Boolean(item.proofSubjectId?.trim() && item.signedScore?.rootHash);
|
||||
}
|
||||
|
||||
formatPercent(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -386,6 +408,7 @@ export class VulnerabilityDetailPageComponent {
|
||||
|
||||
this.state.set('loading');
|
||||
this.errorMessage.set('Failed to load vulnerability detail.');
|
||||
this.verifyError.set(null);
|
||||
this.detail.set(null);
|
||||
|
||||
this.facade.load(id)
|
||||
|
||||
@@ -1,250 +1,45 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { forkJoin, Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
SECURITY_FINDINGS_API,
|
||||
SignedScoreDto,
|
||||
SignedScoreVerifyDto,
|
||||
VulnerabilityDetailDto,
|
||||
} from '../../core/api/security-findings.client';
|
||||
import {
|
||||
SCORE_REPLAY_API,
|
||||
} from '../../core/api/proof.client';
|
||||
import {
|
||||
ScoreBundleResponse,
|
||||
ScoreHistoryEntry,
|
||||
ScoreReplayFactor,
|
||||
ScoreVerifyRequest,
|
||||
} from '../../core/api/proof.models';
|
||||
import { SCORE_REPLAY_API } from '../../core/api/proof.client';
|
||||
|
||||
export interface VulnerabilityDetailViewModel extends VulnerabilityDetailDto {
|
||||
scanId: string;
|
||||
signedScore: SignedScoreDto;
|
||||
}
|
||||
export type VulnerabilityDetailViewModel = VulnerabilityDetailDto;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VulnerabilityDetailFacade {
|
||||
private readonly findingsApi = inject(SECURITY_FINDINGS_API);
|
||||
private readonly replayApi = inject(SCORE_REPLAY_API);
|
||||
|
||||
load(vulnerabilityId: string): Observable<VulnerabilityDetailViewModel> {
|
||||
const id = vulnerabilityId.trim();
|
||||
if (!id || /\s/.test(id)) {
|
||||
return throwError(() => this.toError(`Malformed vulnerability identifier: ${vulnerabilityId}`, 400));
|
||||
}
|
||||
|
||||
return this.findingsApi.getVulnerabilityDetail(id).pipe(
|
||||
switchMap((detail) => {
|
||||
const scanId = this.resolveScanId(detail);
|
||||
const rootHash = detail.signedScore?.rootHash;
|
||||
|
||||
return forkJoin({
|
||||
detail: of(detail),
|
||||
history: this.replayApi.getScoreHistory(scanId).pipe(catchError(() => of([] as readonly ScoreHistoryEntry[]))),
|
||||
bundle: this.replayApi.getScoreBundle(scanId, rootHash).pipe(
|
||||
catchError(() => of(null as ScoreBundleResponse | null)),
|
||||
),
|
||||
}).pipe(
|
||||
map(({ detail: resolvedDetail, history, bundle }) => {
|
||||
const signedScore = this.mergeSignedScore(resolvedDetail, history, bundle, scanId);
|
||||
return {
|
||||
...resolvedDetail,
|
||||
scanId,
|
||||
signedScore,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((error: unknown) => throwError(() => this.normalizeError(error, id))),
|
||||
);
|
||||
load(identifier: string): Observable<VulnerabilityDetailViewModel> {
|
||||
return this.findingsApi.getVulnerabilityDetail(identifier.trim());
|
||||
}
|
||||
|
||||
verify(view: VulnerabilityDetailViewModel): Observable<SignedScoreVerifyDto> {
|
||||
const expectedRootHash = view.signedScore.rootHash;
|
||||
if (!expectedRootHash) {
|
||||
return throwError(() =>
|
||||
this.toError('Cannot verify signed score because no replay root hash is available.', 422),
|
||||
);
|
||||
const proofSubjectId = view.proofSubjectId?.trim();
|
||||
const signedScore = view.signedScore;
|
||||
if (!proofSubjectId || !signedScore?.rootHash) {
|
||||
return throwError(() => new Error('Verification is unavailable for this vulnerability.'));
|
||||
}
|
||||
|
||||
const request: ScoreVerifyRequest = {
|
||||
expectedRootHash,
|
||||
expectedCanonicalInputHash: view.signedScore.canonicalInputHash,
|
||||
};
|
||||
|
||||
return this.replayApi.verifyScore(view.scanId, request).pipe(
|
||||
map((result) => {
|
||||
const baseline = view.signedScore.verify ?? this.defaultVerify(view.scanId);
|
||||
return {
|
||||
replaySuccessRatio: result.valid
|
||||
? Math.max(0.9, baseline.replaySuccessRatio)
|
||||
: Math.max(0.2, baseline.replaySuccessRatio - 0.35),
|
||||
medianVerifyTimeMs: baseline.medianVerifyTimeMs,
|
||||
symbolCoverage: baseline.symbolCoverage,
|
||||
verifiedAt: result.verifiedAtUtc,
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => throwError(() => this.normalizeError(error, view.cveId))),
|
||||
return this.replayApi.verifyScore(proofSubjectId, {
|
||||
expectedRootHash: signedScore.rootHash,
|
||||
expectedCanonicalInputHash: signedScore.canonicalInputHash,
|
||||
}).pipe(
|
||||
map((result) => ({
|
||||
replaySuccessRatio: result.valid ? 1 : 0,
|
||||
verifiedAt: result.verifiedAtUtc,
|
||||
valid: result.valid,
|
||||
manifestValid: result.manifestValid,
|
||||
ledgerValid: result.ledgerValid,
|
||||
canonicalInputHashValid: result.canonicalInputHashValid,
|
||||
errorMessage: result.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private mergeSignedScore(
|
||||
detail: VulnerabilityDetailDto,
|
||||
history: readonly ScoreHistoryEntry[],
|
||||
bundle: ScoreBundleResponse | null,
|
||||
scanId: string,
|
||||
): SignedScoreDto {
|
||||
const fromHistory = this.buildSignedScoreFromHistory(history, bundle, scanId);
|
||||
const base = detail.signedScore ?? fromHistory;
|
||||
|
||||
if (detail.signedScore && fromHistory) {
|
||||
return {
|
||||
...detail.signedScore,
|
||||
rootHash: detail.signedScore.rootHash ?? fromHistory.rootHash,
|
||||
canonicalInputHash: detail.signedScore.canonicalInputHash ?? fromHistory.canonicalInputHash,
|
||||
factors: detail.signedScore.factors.length > 0 ? detail.signedScore.factors : fromHistory.factors,
|
||||
provenanceLinks: detail.signedScore.provenanceLinks.length > 0
|
||||
? detail.signedScore.provenanceLinks
|
||||
: fromHistory.provenanceLinks,
|
||||
verify: detail.signedScore.verify ?? fromHistory.verify,
|
||||
gate: this.normalizeGate(detail.signedScore.score, detail.signedScore.gate.threshold, detail.signedScore.gate.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (base) {
|
||||
return {
|
||||
...base,
|
||||
gate: this.normalizeGate(base.score, base.gate.threshold, base.gate.reason),
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackScore = Math.round(Math.min(100, Math.max(0, detail.cvss * 10)));
|
||||
return {
|
||||
score: fallbackScore,
|
||||
policyVersion: 'ews.v1.2',
|
||||
computedAt: detail.firstSeen,
|
||||
rootHash: bundle?.rootHash,
|
||||
factors: [],
|
||||
provenanceLinks: [
|
||||
{ label: 'Replay history', href: `/api/v1/scans/${encodeURIComponent(scanId)}/score/history` },
|
||||
{ label: 'Proof bundle', href: `/api/v1/scans/${encodeURIComponent(scanId)}/score/bundle` },
|
||||
],
|
||||
verify: this.defaultVerify(scanId),
|
||||
gate: this.normalizeGate(fallbackScore, 70, ''),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSignedScoreFromHistory(
|
||||
history: readonly ScoreHistoryEntry[],
|
||||
bundle: ScoreBundleResponse | null,
|
||||
scanId: string,
|
||||
): SignedScoreDto | null {
|
||||
if (!history || history.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latest = [...history].sort(
|
||||
(left, right) => Date.parse(right.replayedAt) - Date.parse(left.replayedAt),
|
||||
)[0];
|
||||
|
||||
const score = latest.score <= 1 ? Math.round(latest.score * 100) : Math.round(latest.score);
|
||||
|
||||
return {
|
||||
score,
|
||||
policyVersion: 'ews.v1.2',
|
||||
computedAt: latest.replayedAt,
|
||||
rootHash: latest.rootHash,
|
||||
canonicalInputHash: latest.canonicalInputHash,
|
||||
factors: latest.factors.map((factor) => this.mapFactor(factor)),
|
||||
provenanceLinks: [
|
||||
{ label: 'Replay history', href: `/api/v1/scans/${encodeURIComponent(scanId)}/score/history` },
|
||||
{ label: 'Proof bundle', href: `/api/v1/scans/${encodeURIComponent(scanId)}/score/bundle` },
|
||||
...(bundle ? [{ label: 'Bundle URI', href: bundle.bundleUri }] : []),
|
||||
],
|
||||
verify: this.defaultVerify(scanId),
|
||||
gate: this.normalizeGate(score, 70, ''),
|
||||
};
|
||||
}
|
||||
|
||||
private mapFactor(factor: ScoreReplayFactor) {
|
||||
return {
|
||||
name: factor.name,
|
||||
weight: factor.weight,
|
||||
raw: factor.raw,
|
||||
weighted: factor.weighted,
|
||||
source: factor.source,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGate(score: number, threshold: number, reason: string) {
|
||||
const normalizedThreshold = Number.isFinite(threshold) ? threshold : 70;
|
||||
if (score >= normalizedThreshold) {
|
||||
return {
|
||||
status: 'pass' as const,
|
||||
threshold: normalizedThreshold,
|
||||
actual: score,
|
||||
reason: reason || 'Signed score meets release gate threshold.',
|
||||
};
|
||||
}
|
||||
|
||||
if (score >= normalizedThreshold - 10) {
|
||||
return {
|
||||
status: 'warn' as const,
|
||||
threshold: normalizedThreshold,
|
||||
actual: score,
|
||||
reason: reason || 'Signed score is close to threshold and requires operator approval.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'block' as const,
|
||||
threshold: normalizedThreshold,
|
||||
actual: score,
|
||||
reason: reason || 'Signed score is below threshold and blocks promotion.',
|
||||
};
|
||||
}
|
||||
|
||||
private resolveScanId(detail: VulnerabilityDetailDto): string {
|
||||
if (detail.findingId && detail.findingId.trim().length > 0) {
|
||||
return `scan-${detail.findingId.toLowerCase().replace(/[^a-z0-9-]+/g, '-')}`;
|
||||
}
|
||||
|
||||
return `scan-${detail.cveId.toLowerCase().replace(/[^a-z0-9-]+/g, '-')}`;
|
||||
}
|
||||
|
||||
private defaultVerify(scanId: string): SignedScoreVerifyDto {
|
||||
return {
|
||||
replaySuccessRatio: Number(this.deterministicRange(scanId, 'ratio', 0.86, 0.99).toFixed(2)),
|
||||
medianVerifyTimeMs: Math.round(this.deterministicRange(scanId, 'ms', 42, 180)),
|
||||
symbolCoverage: Math.round(this.deterministicRange(scanId, 'symbols', 74, 98)),
|
||||
verifiedAt: new Date('2026-03-04T12:00:00Z').toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private deterministicRange(seed: string, salt: string, min: number, max: number): number {
|
||||
const value = `${seed}:${salt}`;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
const unit = (hash >>> 0) / 4294967295;
|
||||
return min + unit * (max - min);
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown, vulnerabilityId: string): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return this.toError(`Failed to load vulnerability detail for ${vulnerabilityId}.`, undefined);
|
||||
}
|
||||
|
||||
private toError(message: string, status: number | undefined): Error {
|
||||
const error = new Error(message);
|
||||
if (status !== undefined) {
|
||||
(error as Error & { status?: number }).status = status;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -61,39 +61,7 @@ interface Environment {
|
||||
|
||||
// ── Mock data ───────────────────────────────────────────────────────
|
||||
|
||||
const REGIONS = [
|
||||
{ regionId: 'apac', displayName: 'Asia-Pacific' },
|
||||
{ regionId: 'eu-west', displayName: 'EU West' },
|
||||
{ regionId: 'us-east', displayName: 'US East' },
|
||||
{ regionId: 'us-west', displayName: 'US West' },
|
||||
];
|
||||
|
||||
const MOCK_ENVS: Environment[] = [
|
||||
{ environmentId: 'eu-prod', displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production' },
|
||||
{ environmentId: 'eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging' },
|
||||
{ environmentId: 'us-prod', displayName: 'US Production', regionId: 'us-east', environmentType: 'production' },
|
||||
{ environmentId: 'us-uat', displayName: 'US UAT', regionId: 'us-east', environmentType: 'uat' },
|
||||
{ environmentId: 'prod-us-west', displayName: 'US West Production', regionId: 'us-west', environmentType: 'production' },
|
||||
{ environmentId: 'prod-eu-west', displayName: 'EU West DR', regionId: 'eu-west', environmentType: 'dr' },
|
||||
{ environmentId: 'apac-prod', displayName: 'APAC Production', regionId: 'apac', environmentType: 'production' },
|
||||
];
|
||||
|
||||
function g(name: string, status: string, msg?: string, dur?: number): GateResult {
|
||||
return { gateName: name, status, message: msg,
|
||||
checkedAt: new Date(Date.now() - Math.random() * 120_000).toISOString(),
|
||||
durationMs: dur ?? Math.round(30 + Math.random() * 400) };
|
||||
}
|
||||
function ok(): GateResult[] {
|
||||
return [g('agent_bound','pass','Agent heartbeat OK'), g('docker_version_ok','pass','Docker 24.0.9'),
|
||||
g('docker_ping_ok','pass','Daemon reachable'), g('registry_pull_ok','pass','Pull test OK'),
|
||||
g('vault_reachable','pass','Vault unsealed'), g('consul_reachable','pass','Consul leader elected'),
|
||||
g('connectivity_ok','pass','All required gates pass')];
|
||||
}
|
||||
function t(id: string, name: string, env: string, ready: boolean, gates?: GateResult[]): ReadinessReport {
|
||||
return { targetId: id, targetName: name, environmentId: env, isReady: ready,
|
||||
gates: gates ?? ok(), evaluatedAt: new Date(Date.now() - Math.round(Math.random()*300_000)).toISOString() };
|
||||
}
|
||||
|
||||
/*
|
||||
const MOCK_REPORTS: ReadinessReport[] = [
|
||||
t('tgt-eu-p-a1','eu-prod-app-01','eu-prod',true), t('tgt-eu-p-a2','eu-prod-app-02','eu-prod',true),
|
||||
t('tgt-eu-p-w1','eu-prod-worker-01','eu-prod',false,[
|
||||
@@ -235,6 +203,18 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────
|
||||
|
||||
*/
|
||||
|
||||
const REMEDIATION: Record<string, string> = {
|
||||
agent_bound: 'Register an agent via Ops > Agent Fleet.',
|
||||
docker_version_ok: 'Upgrade Docker on the host to 20.10+.',
|
||||
docker_ping_ok: 'Check the Docker daemon is running and accessible.',
|
||||
registry_pull_ok: 'Verify registry connectivity and credentials.',
|
||||
vault_reachable: 'Unseal Vault or check the network path.',
|
||||
consul_reachable: 'Check Consul cluster health and partitions.',
|
||||
connectivity_ok: 'Fix the upstream gate failures listed above.',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-environments-command',
|
||||
standalone: true,
|
||||
@@ -273,7 +253,6 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
</div>
|
||||
|
||||
<div class="toolbar__right">
|
||||
@if (usingMock()) { <span class="mock-badge">Demo Data</span> }
|
||||
@if (statusFilter() !== 'all') {
|
||||
<button class="btn btn--ghost btn--sm" (click)="statusFilter.set('all')">Clear</button>
|
||||
}
|
||||
@@ -304,10 +283,26 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
<app-loading-state size="md" message="Loading environment data..." />
|
||||
}
|
||||
|
||||
@if (!loading() && filtered().length === 0) {
|
||||
@if (!loading() && loadError()) {
|
||||
<div class="notice notice--error">{{ loadError() }}</div>
|
||||
}
|
||||
|
||||
@if (!loading() && !loadError() && allEnvs().length === 0) {
|
||||
<div class="empty">
|
||||
<p>No environments are currently registered in topology.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && !loadError() && allEnvs().length > 0 && reports().length === 0) {
|
||||
<div class="empty">
|
||||
<p>Environments loaded, but no readiness reports are available yet.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && !loadError() && reports().length > 0 && filtered().length === 0) {
|
||||
<div class="empty">
|
||||
<p>No environments match the current filters.</p>
|
||||
@if (activeFilterCount() > 0) {
|
||||
@if (statusFilter() !== 'all') {
|
||||
<button class="btn btn--secondary btn--sm" (click)="clearFilters()">Clear Filters</button>
|
||||
}
|
||||
</div>
|
||||
@@ -397,6 +392,14 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
<div class="topo-graph">
|
||||
@if (topoLoading()) {
|
||||
<div class="topo-loading"><div class="spinner"></div> Loading topology...</div>
|
||||
} @else if (topologyError()) {
|
||||
<div class="empty empty--compact">
|
||||
<p>{{ topologyError() }}</p>
|
||||
</div>
|
||||
} @else if (!topoLayout()) {
|
||||
<div class="empty empty--compact">
|
||||
<p>No topology layout is available for the current scope.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<app-topology-graph
|
||||
[layout]="topoLayout()"
|
||||
@@ -454,6 +457,8 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="td__empty">No hosts were returned for this environment.</p>
|
||||
}
|
||||
|
||||
<!-- Targets table -->
|
||||
@@ -472,6 +477,8 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="td__empty">No targets were returned for this environment.</p>
|
||||
}
|
||||
|
||||
<!-- Deployments -->
|
||||
@@ -553,10 +560,18 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
.toolbar__left { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.toolbar__right { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
|
||||
.status-chips { display: flex; gap: 0.25rem; align-items: center; }
|
||||
.mock-badge {
|
||||
padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.62rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
background: var(--color-status-warning-bg, #fef3cd); color: var(--color-status-warning-text, #856404);
|
||||
.notice {
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.notice--error {
|
||||
border-color: var(--color-status-error, #ef4444);
|
||||
background: var(--color-status-error-bg, rgba(239, 68, 68, 0.08));
|
||||
color: var(--color-status-error, #ef4444);
|
||||
}
|
||||
|
||||
/* ── View toggle ── */
|
||||
@@ -621,6 +636,7 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
|
||||
padding: 1.5rem; text-align: center; color: var(--color-text-muted); font-size: 0.78rem;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.empty--compact { min-height: 220px; justify-content: center; }
|
||||
|
||||
/* ── Environment card ── */
|
||||
.env-card {
|
||||
@@ -753,8 +769,9 @@ export class EnvironmentsCommandComponent implements OnInit, OnDestroy {
|
||||
// ── View state ──
|
||||
readonly view = signal<'command' | 'topology'>('command');
|
||||
readonly loading = signal(false);
|
||||
readonly usingMock = signal(false);
|
||||
readonly lastRefresh = signal<string | null>(null);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly topologyError = signal<string | null>(null);
|
||||
|
||||
// ── Data ──
|
||||
readonly allEnvs = signal<Environment[]>([]);
|
||||
@@ -812,7 +829,7 @@ export class EnvironmentsCommandComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly grouped = computed(() => {
|
||||
const envLookup = new Map(this.allEnvs().map(e => [e.environmentId, e]));
|
||||
const regionLookup = new Map(REGIONS.map(r => [r.regionId, r.displayName]));
|
||||
const regionLookup = new Map(this.context.regions().map(region => [region.regionId, region.displayName]));
|
||||
const map = new Map<string, {
|
||||
envId: string; envName: string; regionName: string; envType: string;
|
||||
reports: ReadinessReport[]; readyCnt: number; notReadyCnt: number; allReady: boolean;
|
||||
@@ -877,47 +894,70 @@ export class EnvironmentsCommandComponent implements OnInit, OnDestroy {
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
this.http.get<{ items: Environment[] }>('/api/v2/topology/environments')
|
||||
.pipe(catchError(() => of({ items: [] as Environment[] })))
|
||||
.subscribe(envResp => {
|
||||
if (envResp.items.length === 0) { this.loadMock(); return; }
|
||||
this.usingMock.set(false);
|
||||
this.allEnvs.set(envResp.items);
|
||||
const requests = envResp.items.map(env =>
|
||||
this.http.get<{ items: ReadinessReport[] }>(`/api/v1/environments/${env.environmentId}/readiness`)
|
||||
.pipe(catchError(() => of({ items: [] as ReadinessReport[] })))
|
||||
);
|
||||
if (requests.length === 0) { this.loading.set(false); return; }
|
||||
forkJoin(requests).subscribe(results => {
|
||||
const all = results.flatMap(r => r.items);
|
||||
if (all.length === 0) { this.loadMock(); return; }
|
||||
this.reports.set(all);
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (envResp) => {
|
||||
const environments = envResp?.items ?? [];
|
||||
this.allEnvs.set(environments);
|
||||
if (environments.length === 0) {
|
||||
this.reports.set([]);
|
||||
this.lastRefresh.set(new Date().toLocaleTimeString());
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = environments.map(env =>
|
||||
this.http.get<{ items: ReadinessReport[] }>(`/api/v1/environments/${env.environmentId}/readiness`)
|
||||
.pipe(
|
||||
take(1),
|
||||
catchError(() => of({ items: [] as ReadinessReport[], failed: true })),
|
||||
)
|
||||
);
|
||||
|
||||
forkJoin(requests).subscribe(results => {
|
||||
const readinessFailures = results.filter(result => 'failed' in result).length;
|
||||
const all = results.flatMap(result => result.items ?? []);
|
||||
this.reports.set(all);
|
||||
if (readinessFailures > 0) {
|
||||
this.loadError.set('Some readiness reports could not be loaded. Showing the results that were returned.');
|
||||
}
|
||||
this.lastRefresh.set(new Date().toLocaleTimeString());
|
||||
this.loading.set(false);
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.allEnvs.set([]);
|
||||
this.reports.set([]);
|
||||
this.loadError.set('Unable to load topology environments.');
|
||||
this.lastRefresh.set(new Date().toLocaleTimeString());
|
||||
this.loading.set(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
this.loadTopology();
|
||||
}
|
||||
|
||||
private loadMock(): void {
|
||||
this.usingMock.set(true);
|
||||
this.allEnvs.set(MOCK_ENVS);
|
||||
this.reports.set(MOCK_REPORTS);
|
||||
this.topoLayout.set(buildMockLayout(MOCK_ENVS, MOCK_REPORTS));
|
||||
this.lastRefresh.set(new Date().toLocaleTimeString());
|
||||
this.loading.set(false);
|
||||
this.topoLoading.set(false);
|
||||
}
|
||||
clearFilters(): void { this.statusFilter.set('all'); }
|
||||
|
||||
private loadTopology(): void {
|
||||
this.topoLoading.set(true);
|
||||
this.topologyError.set(null);
|
||||
this.topoLayout.set(null);
|
||||
const regions = this.context.selectedRegions();
|
||||
this.layoutService.getLayout({
|
||||
region: regions.length > 0 ? regions.join(',') : undefined,
|
||||
}).pipe(take(1), catchError(() => of(null))).subscribe(layout => {
|
||||
if (layout) { this.topoLayout.set(layout); this.topoLoading.set(false); }
|
||||
// If null and mock is loaded, mock layout is already set
|
||||
else if (!this.usingMock()) { this.topoLoading.set(false); }
|
||||
}).pipe(take(1)).subscribe({
|
||||
next: (layout) => {
|
||||
const hasNodes = (layout?.nodes?.length ?? 0) > 0;
|
||||
const hasEdges = (layout?.edges?.length ?? 0) > 0;
|
||||
this.topoLayout.set(hasNodes || hasEdges ? layout : null);
|
||||
this.topoLoading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.topologyError.set('Unable to load topology layout for the current scope.');
|
||||
this.topoLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -703,7 +703,7 @@ export class TopologyAgentsPageComponent {
|
||||
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('topology-agents', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-agents'));
|
||||
}
|
||||
|
||||
@@ -755,3 +755,4 @@ export class TopologyAgentsPageComponent {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export class TopologyInventoryPageComponent {
|
||||
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('topology-inventory', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-inventory'));
|
||||
|
||||
@@ -352,3 +352,4 @@ export class TopologyInventoryPageComponent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -562,7 +562,7 @@ export class TopologyTargetsPageComponent {
|
||||
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('topology-targets', this.helperContexts());
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('topology-targets'));
|
||||
}
|
||||
|
||||
@@ -598,3 +598,4 @@ export class TopologyTargetsPageComponent {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,9 +191,7 @@ export class VexDecisionModalComponent {
|
||||
if (initialStatus) {
|
||||
this.status.set(initialStatus);
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -366,3 +364,4 @@ export class VexDecisionModalComponent {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit }
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { TrustAuditEvent, ListAuditEventsParams, TrustAuditFilter } from '../../core/api/trust.models';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
|
||||
export interface AirgapEvent {
|
||||
@@ -47,11 +45,6 @@ export type AirgapEventType =
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="airgap-audit">
|
||||
@if (!apiConnected()) {
|
||||
<div class="degraded-banner">
|
||||
Air-gap audit is showing sample data. Live events will appear when the Trust API air-gap endpoint is available.
|
||||
</div>
|
||||
}
|
||||
<!-- Status Banner -->
|
||||
<div class="status-banner" [class]="'status-banner--' + currentAirgapMode()">
|
||||
<div class="status-icon">
|
||||
@@ -273,11 +266,6 @@ export type AirgapEventType =
|
||||
.airgap-audit {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.degraded-banner {
|
||||
background: var(--color-status-warning-bg); color: var(--color-status-warning-text);
|
||||
padding: 0.75rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1rem;
|
||||
font-size: 0.85rem; border-left: 3px solid var(--color-status-warning);
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
@@ -685,11 +673,10 @@ export type AirgapEventType =
|
||||
`]
|
||||
})
|
||||
export class AirgapAuditComponent implements OnInit {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
private readonly auditClient = inject(AuditLogClient);
|
||||
|
||||
// State
|
||||
readonly apiConnected = signal(true);
|
||||
readonly allEvents = signal<AirgapEvent[]>([]);
|
||||
readonly events = signal<AirgapEvent[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -722,24 +709,26 @@ export class AirgapAuditComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEvents();
|
||||
this.loadStatus();
|
||||
}
|
||||
|
||||
private loadEvents(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
// Try to load from real API first, fall back to mock data if unavailable
|
||||
this.auditClient.getAirgapAudit(undefined, undefined, 50).subscribe({
|
||||
next: (res) => {
|
||||
const mapped = res.items.map((e) => this.mapAuditEventToAirgap(e));
|
||||
this.applyFiltersAndPaginate(mapped);
|
||||
this.apiConnected.set(true);
|
||||
this.allEvents.set(mapped);
|
||||
this.updateStatus(mapped);
|
||||
this.applyFiltersAndPaginate();
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to mock data when API is not available
|
||||
this.apiConnected.set(false);
|
||||
this.applyFiltersAndPaginate(this.getMockEvents());
|
||||
this.allEvents.set([]);
|
||||
this.events.set([]);
|
||||
this.totalCount.set(0);
|
||||
this.error.set('Unable to load air-gap audit events from Authority.');
|
||||
this.resetStatus();
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -761,33 +750,8 @@ export class AirgapAuditComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
private getMockEvents(): AirgapEvent[] {
|
||||
return [
|
||||
{
|
||||
eventId: 'ag-001', tenantId: 'tenant-1', eventType: 'offline_signing', severity: 'info',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
actorName: 'scanner@stellaops.local',
|
||||
description: 'Offline signing operation completed for 15 attestations',
|
||||
airgapMode: 'full', syncStatus: 'pending', offlineKeyUsed: 'key-001', signatureCount: 15,
|
||||
},
|
||||
{
|
||||
eventId: 'ag-002', tenantId: 'tenant-1', eventType: 'airgap_enabled', severity: 'warning',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
actorName: 'admin@stellaops.local',
|
||||
description: 'Air-gap mode enabled for disconnected operation',
|
||||
airgapMode: 'full', syncStatus: 'skipped', details: { reason: 'Network maintenance', duration: '4 hours' },
|
||||
},
|
||||
{
|
||||
eventId: 'ag-003', tenantId: 'tenant-1', eventType: 'sync_failed', severity: 'error',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(),
|
||||
description: 'Synchronization failed: connection timeout',
|
||||
airgapMode: 'partial', syncStatus: 'failed', details: { errorCode: 'CONN_TIMEOUT', retryCount: 3 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private applyFiltersAndPaginate(allEvents: AirgapEvent[]): void {
|
||||
let filtered = [...allEvents];
|
||||
private applyFiltersAndPaginate(): void {
|
||||
let filtered = [...this.allEvents()];
|
||||
|
||||
if (this.searchQuery()) {
|
||||
const search = this.searchQuery().toLowerCase();
|
||||
@@ -814,29 +778,68 @@ export class AirgapAuditComponent implements OnInit {
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
private loadStatus(): void {
|
||||
// Mock status data
|
||||
private resetStatus(): void {
|
||||
this.currentAirgapMode.set('none');
|
||||
this.lastSyncTime.set(new Date(Date.now() - 1000 * 60 * 30).toISOString());
|
||||
this.pendingSyncCount.set(3);
|
||||
this.offlineSignatureCount.set(47);
|
||||
this.failedSyncCount.set(1);
|
||||
this.cacheAge.set('2h 15m');
|
||||
this.lastSyncTime.set(new Date().toISOString());
|
||||
this.pendingSyncCount.set(0);
|
||||
this.offlineSignatureCount.set(0);
|
||||
this.failedSyncCount.set(0);
|
||||
this.cacheAge.set('unknown');
|
||||
}
|
||||
|
||||
private updateStatus(events: AirgapEvent[]): void {
|
||||
if (events.length === 0) {
|
||||
this.resetStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedEvents = [...events].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
|
||||
const latestEvent = orderedEvents[0];
|
||||
const lastSyncEvent = orderedEvents.find((event) => event.syncStatus === 'synced' || event.eventType === 'sync_completed');
|
||||
const cacheEvent = orderedEvents.find((event) => event.eventType === 'cache_refreshed' || event.eventType === 'cache_expired');
|
||||
|
||||
this.currentAirgapMode.set(latestEvent.airgapMode ?? 'none');
|
||||
this.lastSyncTime.set((lastSyncEvent ?? latestEvent).timestamp);
|
||||
this.pendingSyncCount.set(events.filter((event) => event.syncStatus === 'pending').length);
|
||||
this.offlineSignatureCount.set(events.reduce((sum, event) => sum + (event.signatureCount ?? 0), 0));
|
||||
this.failedSyncCount.set(events.filter((event) => event.syncStatus === 'failed' || event.eventType === 'sync_failed').length);
|
||||
this.cacheAge.set(this.formatAge(cacheEvent?.timestamp));
|
||||
}
|
||||
|
||||
private formatAge(timestamp: string | undefined): string {
|
||||
if (!timestamp) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const deltaMs = Date.now() - Date.parse(timestamp);
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(deltaMs / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours <= 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
this.pageNumber.set(1);
|
||||
this.loadEvents();
|
||||
this.applyFiltersAndPaginate();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageNumber.set(1);
|
||||
this.loadEvents();
|
||||
this.applyFiltersAndPaginate();
|
||||
}
|
||||
|
||||
onPageChange(page: number): void {
|
||||
this.pageNumber.set(page);
|
||||
this.loadEvents();
|
||||
this.applyFiltersAndPaginate();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
@@ -844,7 +847,7 @@ export class AirgapAuditComponent implements OnInit {
|
||||
this.selectedEventType.set('all');
|
||||
this.selectedSyncStatus.set('all');
|
||||
this.pageNumber.set(1);
|
||||
this.loadEvents();
|
||||
this.applyFiltersAndPaginate();
|
||||
}
|
||||
|
||||
toggleEventDetails(event: AirgapEvent): void {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit }
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditEvent } from '../../core/api/audit-log.models';
|
||||
|
||||
export interface IncidentEvent {
|
||||
readonly incidentId: string;
|
||||
@@ -61,11 +62,6 @@ export type IncidentType =
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="incident-audit">
|
||||
@if (!apiConnected()) {
|
||||
<div class="degraded-banner">
|
||||
Incident audit is showing sample data. Live incidents will appear when the Trust API incident endpoint is available.
|
||||
</div>
|
||||
}
|
||||
<!-- Summary Cards -->
|
||||
<div class="summary-row">
|
||||
<div class="summary-card summary-card--open" (click)="filterByStatus('open')">
|
||||
@@ -288,20 +284,8 @@ export type IncidentType =
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="incident-actions">
|
||||
@if (incident.status !== 'closed' && incident.status !== 'resolved') {
|
||||
<button type="button" class="btn-action" (click)="addComment(incident); $event.stopPropagation()">
|
||||
Add Comment
|
||||
</button>
|
||||
<button type="button" class="btn-action" (click)="updateStatus(incident); $event.stopPropagation()">
|
||||
Update Status
|
||||
</button>
|
||||
@if (!incident.assignee) {
|
||||
<button type="button" class="btn-action" (click)="assignIncident(incident); $event.stopPropagation()">
|
||||
Assign
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<div class="incident-actions incident-actions--readonly">
|
||||
Incident workflow actions are read-only in the audit view.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -340,11 +324,6 @@ export type IncidentType =
|
||||
.incident-audit {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.degraded-banner {
|
||||
background: var(--color-status-warning-bg); color: var(--color-status-warning-text);
|
||||
padding: 0.75rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1rem;
|
||||
font-size: 0.85rem; border-left: 3px solid var(--color-status-warning);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: grid;
|
||||
@@ -791,20 +770,9 @@ export type IncidentType =
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.75rem;
|
||||
.incident-actions--readonly {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
border-color: var(--color-status-info);
|
||||
color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.incident-audit__pagination {
|
||||
@@ -845,10 +813,9 @@ export type IncidentType =
|
||||
`]
|
||||
})
|
||||
export class IncidentAuditComponent implements OnInit {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
private readonly auditClient = inject(AuditLogClient);
|
||||
|
||||
// State
|
||||
readonly apiConnected = signal(false);
|
||||
readonly incidents = signal<IncidentEvent[]>([]);
|
||||
readonly allIncidents = signal<IncidentEvent[]>([]);
|
||||
readonly loading = signal(false);
|
||||
@@ -882,137 +849,268 @@ export class IncidentAuditComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
// Mock incident data
|
||||
const mockIncidents: IncidentEvent[] = [
|
||||
{
|
||||
incidentId: 'INC-2024-001',
|
||||
tenantId: 'tenant-1',
|
||||
incidentType: 'key_compromise',
|
||||
severity: 'critical',
|
||||
status: 'investigating',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
title: 'Potential signing key exposure detected',
|
||||
description: 'Anomalous access pattern detected on signing key key-prod-001. Investigation in progress.',
|
||||
assignee: 'security@stellaops.local',
|
||||
reporter: 'system',
|
||||
affectedResources: [
|
||||
{ resourceType: 'key', resourceId: 'key-prod-001', resourceName: 'Production Signing Key', impact: 'high' },
|
||||
{ resourceType: 'service', resourceId: 'svc-attestor', resourceName: 'Attestor Service', impact: 'medium' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-001',
|
||||
actionType: 'status_change',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
|
||||
actor: 'security@stellaops.local',
|
||||
description: 'Status changed from Open to Investigating',
|
||||
previousValue: 'open',
|
||||
newValue: 'investigating',
|
||||
},
|
||||
{
|
||||
actionId: 'act-002',
|
||||
actionType: 'comment',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
actor: 'security@stellaops.local',
|
||||
description: 'Initial analysis shows access from unexpected IP range. Reviewing access logs.',
|
||||
},
|
||||
],
|
||||
details: { sourceIP: '192.168.1.100', accessCount: 47, normalBaseline: 5 },
|
||||
this.auditClient.getIncidentAudit(undefined, undefined, 100).subscribe({
|
||||
next: (response) => {
|
||||
const incidents = this.mapAuditEventsToIncidents(response.items);
|
||||
this.allIncidents.set(incidents);
|
||||
this.applyFilters();
|
||||
},
|
||||
{
|
||||
incidentId: 'INC-2024-002',
|
||||
tenantId: 'tenant-1',
|
||||
incidentType: 'trust_violation',
|
||||
severity: 'high',
|
||||
status: 'mitigated',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
title: 'Untrusted issuer accepted in VEX pipeline',
|
||||
description: 'VEX document from blocked issuer was processed due to configuration error.',
|
||||
assignee: 'ops@stellaops.local',
|
||||
reporter: 'vex-pipeline',
|
||||
affectedResources: [
|
||||
{ resourceType: 'issuer', resourceId: 'issuer-blocked-001', resourceName: 'Blocked Vendor XYZ', impact: 'high' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-003',
|
||||
actionType: 'mitigation',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 20).toISOString(),
|
||||
actor: 'ops@stellaops.local',
|
||||
description: 'Reverted affected VEX documents and corrected issuer blocklist configuration.',
|
||||
},
|
||||
],
|
||||
error: () => {
|
||||
this.allIncidents.set([]);
|
||||
this.incidents.set([]);
|
||||
this.totalCount.set(0);
|
||||
this.error.set('Unable to load incident audit events from Authority.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
{
|
||||
incidentId: 'INC-2024-003',
|
||||
tenantId: 'tenant-1',
|
||||
incidentType: 'certificate_misuse',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
resolvedAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
title: 'mTLS certificate used outside allowed scope',
|
||||
description: 'Client certificate was used to access unauthorized service endpoints.',
|
||||
assignee: 'security@stellaops.local',
|
||||
reporter: 'gateway',
|
||||
affectedResources: [
|
||||
{ resourceType: 'certificate', resourceId: 'cert-client-007', resourceName: 'Scanner Client Cert', impact: 'medium' },
|
||||
{ resourceType: 'user', resourceId: 'user-scanner', resourceName: 'scanner-service', impact: 'low' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-004',
|
||||
actionType: 'assignment',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 47).toISOString(),
|
||||
actor: 'system',
|
||||
description: 'Incident auto-assigned to security team',
|
||||
},
|
||||
{
|
||||
actionId: 'act-005',
|
||||
actionType: 'resolution',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
actor: 'security@stellaops.local',
|
||||
description: 'Certificate scope corrected. Access policies updated.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
incidentId: 'INC-2024-004',
|
||||
tenantId: 'tenant-1',
|
||||
incidentType: 'anomaly_detected',
|
||||
severity: 'low',
|
||||
status: 'closed',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 72).toISOString(),
|
||||
resolvedAt: new Date(Date.now() - 1000 * 60 * 60 * 70).toISOString(),
|
||||
title: 'Unusual signature volume detected',
|
||||
description: 'Signature rate exceeded normal threshold during batch processing.',
|
||||
reporter: 'monitoring',
|
||||
affectedResources: [
|
||||
{ resourceType: 'key', resourceId: 'key-batch-001', resourceName: 'Batch Signing Key', impact: 'none' },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-006',
|
||||
actionType: 'comment',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 71).toISOString(),
|
||||
actor: 'ops@stellaops.local',
|
||||
description: 'Confirmed as legitimate batch job. No security concern.',
|
||||
},
|
||||
{
|
||||
actionId: 'act-007',
|
||||
actionType: 'status_change',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 70).toISOString(),
|
||||
actor: 'ops@stellaops.local',
|
||||
description: 'Closed as false positive',
|
||||
previousValue: 'investigating',
|
||||
newValue: 'closed',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
this.allIncidents.set(mockIncidents);
|
||||
this.applyFilters();
|
||||
private mapAuditEventsToIncidents(events: AuditEvent[]): IncidentEvent[] {
|
||||
const groupedEvents = new Map<string, AuditEvent[]>();
|
||||
|
||||
for (const event of events) {
|
||||
const incidentId = this.extractIncidentId(event);
|
||||
const existing = groupedEvents.get(incidentId) ?? [];
|
||||
existing.push(event);
|
||||
groupedEvents.set(incidentId, existing);
|
||||
}
|
||||
|
||||
return Array.from(groupedEvents.values())
|
||||
.map((group) => this.mapIncidentGroup(group))
|
||||
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
|
||||
}
|
||||
|
||||
private mapIncidentGroup(events: AuditEvent[]): IncidentEvent {
|
||||
const ordered = [...events].sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
|
||||
const firstEvent = ordered[0];
|
||||
const latestEvent = ordered[ordered.length - 1];
|
||||
|
||||
return {
|
||||
incidentId: this.extractIncidentId(latestEvent),
|
||||
tenantId: latestEvent.tenantId ?? firstEvent.tenantId ?? 'unknown',
|
||||
incidentType: this.normalizeIncidentType(latestEvent.details?.['incidentType'] ?? latestEvent.resource?.type),
|
||||
severity: this.normalizeIncidentSeverity(latestEvent.severity),
|
||||
status: this.resolveIncidentStatus(ordered, latestEvent),
|
||||
timestamp: firstEvent.timestamp,
|
||||
resolvedAt: this.resolveResolvedAt(ordered),
|
||||
title: this.readString(latestEvent.details?.['title']) ?? latestEvent.description ?? this.extractIncidentId(latestEvent),
|
||||
description: latestEvent.description || this.readString(latestEvent.details?.['summary']) || 'Incident audit event',
|
||||
affectedResources: this.extractAffectedResources(latestEvent),
|
||||
assignee: this.resolveAssignee(ordered),
|
||||
reporter: firstEvent.actor?.email ?? firstEvent.actor?.name,
|
||||
actions: ordered.map((event) => this.mapIncidentAction(event)),
|
||||
details: latestEvent.details,
|
||||
};
|
||||
}
|
||||
|
||||
private extractIncidentId(event: AuditEvent): string {
|
||||
return this.readString(event.details?.['incidentId'])
|
||||
?? this.readString(event.resource?.metadata?.['incidentId'])
|
||||
?? event.resource?.id
|
||||
?? event.id;
|
||||
}
|
||||
|
||||
private resolveIncidentStatus(events: AuditEvent[], latestEvent: AuditEvent): IncidentEvent['status'] {
|
||||
const explicitStatus = this.readString(latestEvent.details?.['status']);
|
||||
if (explicitStatus) {
|
||||
return this.normalizeIncidentStatus(explicitStatus);
|
||||
}
|
||||
|
||||
const latestStatusEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => this.readString(event.details?.['status']) || this.readString(event.details?.['newStatus']));
|
||||
|
||||
return this.normalizeIncidentStatus(
|
||||
this.readString(latestStatusEvent?.details?.['status'])
|
||||
?? this.readString(latestStatusEvent?.details?.['newStatus'])
|
||||
?? latestEvent.action);
|
||||
}
|
||||
|
||||
private resolveResolvedAt(events: AuditEvent[]): string | undefined {
|
||||
const resolvedEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => {
|
||||
const status = this.normalizeIncidentStatus(this.readString(event.details?.['status']) ?? event.action);
|
||||
return status === 'resolved' || status === 'closed';
|
||||
});
|
||||
|
||||
return resolvedEvent?.timestamp;
|
||||
}
|
||||
|
||||
private resolveAssignee(events: AuditEvent[]): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
const assignee = this.readString(event.details?.['assignee']) ?? this.readString(event.details?.['assignedTo']);
|
||||
if (assignee) {
|
||||
return assignee;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractAffectedResources(event: AuditEvent): AffectedResource[] {
|
||||
const rawResources = event.details?.['affectedResources'];
|
||||
if (Array.isArray(rawResources) && rawResources.length > 0) {
|
||||
return rawResources
|
||||
.map((resource) => this.mapAffectedResource(resource))
|
||||
.filter((resource): resource is AffectedResource => resource !== null);
|
||||
}
|
||||
|
||||
if (event.resource) {
|
||||
return [{
|
||||
resourceType: this.normalizeResourceType(event.resource.type),
|
||||
resourceId: event.resource.id,
|
||||
resourceName: event.resource.name ?? event.resource.id,
|
||||
impact: this.normalizeImpact(event.severity),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private mapAffectedResource(resource: unknown): AffectedResource | null {
|
||||
if (!resource || typeof resource !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = resource as Record<string, unknown>;
|
||||
const resourceId = this.readString(source['resourceId']) ?? this.readString(source['id']);
|
||||
if (!resourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resourceType: this.normalizeResourceType(this.readString(source['resourceType']) ?? this.readString(source['type'])),
|
||||
resourceId,
|
||||
resourceName: this.readString(source['resourceName']) ?? this.readString(source['name']) ?? resourceId,
|
||||
impact: this.normalizeImpact(this.readString(source['impact']) ?? this.readString(source['severity'])),
|
||||
};
|
||||
}
|
||||
|
||||
private mapIncidentAction(event: AuditEvent): IncidentAction {
|
||||
return {
|
||||
actionId: event.id,
|
||||
actionType: this.normalizeActionType(event.action),
|
||||
timestamp: event.timestamp,
|
||||
actor: event.actor?.email ?? event.actor?.name ?? event.actor?.id ?? 'system',
|
||||
description: event.description || `${event.action} incident event`,
|
||||
previousValue: this.formatDiffValue(event.diff?.before),
|
||||
newValue: this.formatDiffValue(event.diff?.after),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeIncidentType(value: unknown): IncidentType {
|
||||
switch ((this.readString(value) ?? '').toLowerCase()) {
|
||||
case 'key_compromise':
|
||||
case 'unauthorized_access':
|
||||
case 'certificate_misuse':
|
||||
case 'signature_forgery':
|
||||
case 'trust_violation':
|
||||
case 'policy_breach':
|
||||
case 'data_leak':
|
||||
case 'service_disruption':
|
||||
case 'anomaly_detected':
|
||||
return (this.readString(value) ?? 'anomaly_detected') as IncidentType;
|
||||
default:
|
||||
return 'anomaly_detected';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeIncidentSeverity(value: string): IncidentEvent['severity'] {
|
||||
switch (value) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
case 'medium':
|
||||
case 'low':
|
||||
return value;
|
||||
case 'warning':
|
||||
return 'medium';
|
||||
case 'error':
|
||||
return 'high';
|
||||
default:
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeIncidentStatus(value: unknown): IncidentEvent['status'] {
|
||||
switch ((this.readString(value) ?? '').toLowerCase()) {
|
||||
case 'open':
|
||||
case 'investigating':
|
||||
case 'mitigated':
|
||||
case 'resolved':
|
||||
case 'closed':
|
||||
return (this.readString(value) ?? 'investigating') as IncidentEvent['status'];
|
||||
case 'complete':
|
||||
case 'approve':
|
||||
return 'resolved';
|
||||
case 'reject':
|
||||
case 'fail':
|
||||
return 'investigating';
|
||||
case 'create':
|
||||
case 'start':
|
||||
case 'submit':
|
||||
return 'open';
|
||||
default:
|
||||
return 'investigating';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeActionType(value: string): IncidentAction['actionType'] {
|
||||
switch (value) {
|
||||
case 'update':
|
||||
return 'status_change';
|
||||
case 'approve':
|
||||
return 'resolution';
|
||||
case 'complete':
|
||||
return 'resolution';
|
||||
case 'comment':
|
||||
return 'comment';
|
||||
default:
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeResourceType(value: string | undefined): AffectedResource['resourceType'] {
|
||||
switch ((value ?? '').toLowerCase()) {
|
||||
case 'key':
|
||||
case 'certificate':
|
||||
case 'issuer':
|
||||
case 'service':
|
||||
case 'user':
|
||||
return value as AffectedResource['resourceType'];
|
||||
default:
|
||||
return 'service';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeImpact(value: unknown): AffectedResource['impact'] {
|
||||
switch ((this.readString(value) ?? '').toLowerCase()) {
|
||||
case 'none':
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
case 'critical':
|
||||
return (this.readString(value) ?? 'medium') as AffectedResource['impact'];
|
||||
case 'warning':
|
||||
return 'medium';
|
||||
case 'error':
|
||||
return 'high';
|
||||
default:
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
private formatDiffValue(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
private readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
private applyFilters(): void {
|
||||
@@ -1094,31 +1192,6 @@ export class IncidentAuditComponent implements OnInit {
|
||||
return this.allIncidents().filter(i => i.severity === severity && i.status !== 'closed' && i.status !== 'resolved').length;
|
||||
}
|
||||
|
||||
addComment(incident: IncidentEvent): void {
|
||||
const comment = prompt('Enter comment:');
|
||||
if (comment) {
|
||||
// In real implementation, this would call the API
|
||||
console.log('Adding comment to incident:', incident.incidentId, comment);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(incident: IncidentEvent): void {
|
||||
const statuses = ['open', 'investigating', 'mitigated', 'resolved', 'closed'];
|
||||
const newStatus = prompt(`Enter new status (${statuses.join(', ')}):`, incident.status);
|
||||
if (newStatus && statuses.includes(newStatus)) {
|
||||
// In real implementation, this would call the API
|
||||
console.log('Updating incident status:', incident.incidentId, newStatus);
|
||||
}
|
||||
}
|
||||
|
||||
assignIncident(incident: IncidentEvent): void {
|
||||
const assignee = prompt('Enter assignee email:');
|
||||
if (assignee) {
|
||||
// In real implementation, this would call the API
|
||||
console.log('Assigning incident:', incident.incidentId, assignee);
|
||||
}
|
||||
}
|
||||
|
||||
formatType(type: IncidentType): string {
|
||||
const labels: Record<IncidentType, string> = {
|
||||
key_compromise: 'Key Compromise',
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { AfterViewChecked, AfterViewInit, Component, Directive, ElementRef, OnDestroy, Renderer2, inject, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AUTH_SERVICE, MockAuthService } from '../../core/auth';
|
||||
import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { AppSidebarComponent } from '../app-sidebar';
|
||||
import { AppTopbarComponent } from '../app-topbar/app-topbar.component';
|
||||
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
|
||||
import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
import { SearchAssistantHostComponent } from '../search-assistant-host/search-assistant-host.component';
|
||||
import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive';
|
||||
import { PageHelpPanelComponent } from '../../shared/components/page-help/page-help-panel.component';
|
||||
import { StellaHelperComponent } from '../../shared/components/stella-helper/stella-helper.component';
|
||||
import { StellaTourComponent } from '../../shared/components/stella-helper/stella-tour.component';
|
||||
|
||||
import { AppShellComponent } from './app-shell.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-legacy-header-route',
|
||||
template: `
|
||||
<section class="legacy-header-page">
|
||||
<header class="page-header">
|
||||
@@ -23,6 +33,7 @@ class LegacyHeaderRouteComponent {}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-legacy-header-second-route',
|
||||
template: `
|
||||
<section class="legacy-header-page">
|
||||
<header class="page-header">
|
||||
@@ -34,6 +45,173 @@ class LegacyHeaderRouteComponent {}
|
||||
})
|
||||
class LegacyHeaderSecondRouteComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-topbar',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubAppTopbarComponent {
|
||||
readonly menuToggle = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubAppSidebarComponent {
|
||||
readonly collapsed = input(false);
|
||||
readonly mobileClose = output<void>();
|
||||
readonly collapseToggle = output<void>();
|
||||
readonly flyoutOpen = signal(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubBreadcrumbComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-overlay-host',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubOverlayHostComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-assistant-host',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubSearchAssistantHostComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stella-helper',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubStellaHelperComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stella-tour',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubStellaTourComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-help-panel',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPageHelpPanelComponent {}
|
||||
|
||||
@Directive({
|
||||
selector: '[stellaopsGlossaryTooltip]',
|
||||
standalone: true,
|
||||
})
|
||||
class StubGlossaryTooltipDirective implements AfterViewInit, AfterViewChecked, OnDestroy {
|
||||
readonly targetSelectors = input<string | null>(null);
|
||||
readonly observeMutations = input(false);
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.process();
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
this.process();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
}
|
||||
|
||||
private process(): void {
|
||||
const root = this.host.nativeElement;
|
||||
this.removeOwnedTerms(root);
|
||||
|
||||
const selectorList = this.targetSelectors()?.trim();
|
||||
const targets = selectorList
|
||||
? Array.from(root.querySelectorAll<HTMLElement>(selectorList))
|
||||
: [root];
|
||||
const seenTerms = new Set<string>();
|
||||
|
||||
for (const target of targets) {
|
||||
for (const textNode of this.collectTextNodes(target)) {
|
||||
const match = this.findMatch(textNode.nodeValue ?? '', seenTerms);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const value = textNode.nodeValue ?? '';
|
||||
if (match.index > 0) {
|
||||
fragment.append(value.slice(0, match.index));
|
||||
}
|
||||
|
||||
const termElement = this.renderer.createElement('span') as HTMLElement;
|
||||
termElement.className = 'glossary-term glossary-term--inline';
|
||||
termElement.setAttribute('data-test-glossary-owner', 'true');
|
||||
termElement.textContent = match.term;
|
||||
fragment.append(termElement);
|
||||
|
||||
const endIndex = match.index + match.term.length;
|
||||
if (endIndex < value.length) {
|
||||
fragment.append(value.slice(endIndex));
|
||||
}
|
||||
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
seenTerms.add(match.term);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private collectTextNodes(root: HTMLElement): Text[] {
|
||||
const walker = document.createTreeWalker(
|
||||
root,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
|
||||
},
|
||||
);
|
||||
|
||||
const results: Text[] = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
results.push(current as Text);
|
||||
current = walker.nextNode();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private findMatch(value: string, seenTerms: Set<string>): { term: string; index: number } | null {
|
||||
const candidates = ['Policy Gate', 'Promotion', 'SBOM', 'VEX'];
|
||||
for (const term of candidates) {
|
||||
if (seenTerms.has(term)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = value.indexOf(term);
|
||||
if (index >= 0) {
|
||||
return { term, index };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private removeOwnedTerms(root: HTMLElement): void {
|
||||
for (const node of Array.from(root.querySelectorAll<HTMLElement>('[data-test-glossary-owner="true"]'))) {
|
||||
node.replaceWith(document.createTextNode(node.textContent ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OfflineModeServiceStub {
|
||||
readonly isOffline = signal(false);
|
||||
readonly bundleFreshness = signal<{
|
||||
@@ -81,7 +259,36 @@ describe('AppShellComponent', () => {
|
||||
{ provide: OfflineModeService, useClass: OfflineModeServiceStub },
|
||||
{ provide: PolicyPackStore, useClass: PolicyPackStoreStub },
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
.overrideComponent(AppShellComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
AppTopbarComponent,
|
||||
AppSidebarComponent,
|
||||
BreadcrumbComponent,
|
||||
OverlayHostComponent,
|
||||
SearchAssistantHostComponent,
|
||||
StellaHelperComponent,
|
||||
StellaTourComponent,
|
||||
GlossaryTooltipDirective,
|
||||
PageHelpPanelComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubAppTopbarComponent,
|
||||
StubAppSidebarComponent,
|
||||
StubBreadcrumbComponent,
|
||||
StubOverlayHostComponent,
|
||||
StubSearchAssistantHostComponent,
|
||||
StubStellaHelperComponent,
|
||||
StubStellaTourComponent,
|
||||
StubGlossaryTooltipDirective,
|
||||
StubPageHelpPanelComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppShellComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -1053,7 +1053,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
if (nextRecommendedStep) {
|
||||
this.sidebarPrefs.expandGroup(nextRecommendedStep.menuGroupId);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
|
||||
// Auto-expand the sidebar group matching the current URL on load
|
||||
this.expandGroupForUrl(this.router.url);
|
||||
@@ -1432,3 +1432,4 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,16 +45,15 @@ describe('ContextChipsComponent', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('renders shared context controls and summaries', () => {
|
||||
it('renders shared context controls and the active global scope selectors', () => {
|
||||
const fixture = TestBed.createComponent(ContextChipsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('Region');
|
||||
expect(text).toContain('US East');
|
||||
expect(text).toContain('Env');
|
||||
expect(text).toContain('Stage');
|
||||
expect(text).toContain('Window');
|
||||
expect(text).toContain('Prod');
|
||||
expect(text).toContain('7d');
|
||||
});
|
||||
|
||||
@@ -63,7 +62,10 @@ describe('ContextChipsComponent', () => {
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onToggleRegion('eu-west');
|
||||
component.onRegionMultiChange([
|
||||
{ id: 'us-east', label: 'US East', checked: true },
|
||||
{ id: 'eu-west', label: 'EU West', checked: true },
|
||||
]);
|
||||
|
||||
expect(contextStore.setRegions).toHaveBeenCalledWith(['us-east', 'eu-west']);
|
||||
});
|
||||
|
||||
@@ -127,13 +127,6 @@ export const RELEASES_ROUTES: Routes = [
|
||||
(m) => m.ReleaseDetailComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'deployments',
|
||||
title: 'Deployments',
|
||||
data: { breadcrumb: 'Deployments', semanticObject: 'run' },
|
||||
loadComponent: () =>
|
||||
import('../features/releases/releases-activity.component').then((m) => m.ReleasesActivityComponent),
|
||||
},
|
||||
{
|
||||
path: 'deployments/new',
|
||||
title: 'Create Deployment',
|
||||
|
||||
@@ -330,7 +330,7 @@ export class GraphSplitViewComponent {
|
||||
} catch {
|
||||
this.viewMode.set(initial);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
}
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
@@ -366,3 +366,4 @@ export class GraphSplitViewComponent {
|
||||
return digest.length > 16 ? digest.substring(0, 12) + '...' : digest;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ export class PageHelpPanelComponent {
|
||||
// Ignore if the component was destroyed before the queued refresh runs.
|
||||
}
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
@@ -303,3 +303,4 @@ export class PageHelpPanelComponent {
|
||||
this.prefs.setPageHelpOpen(key, !this.isOpen());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ import { DateFormatService } from '../../../core/i18n/date-format.service';
|
||||
@if (showHardFailBadge()) {
|
||||
<stella-score-badge type="hard-fail" size="sm" [showTooltip]="true" [showLabel]="true" />
|
||||
}
|
||||
<span class="signed-score__gate" [class]="'signed-score__gate--' + model().gate.status">
|
||||
{{ model().gate.status | uppercase }}
|
||||
{{ model().gate.actual }}/{{ model().gate.threshold }}
|
||||
</span>
|
||||
@if (model().gate; as gate) {
|
||||
<span class="signed-score__gate" [class]="'signed-score__gate--' + gate.status">
|
||||
{{ gate.status | uppercase }}
|
||||
{{ gate.actual }}/{{ gate.threshold }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="signed-score__reason">{{ model().gate.reason }}</p>
|
||||
@if (model().gate?.reason) {
|
||||
<p class="signed-score__reason">{{ model().gate?.reason }}</p>
|
||||
}
|
||||
|
||||
@if (model().provenanceLinks.length > 0) {
|
||||
<div class="signed-score__provenance">
|
||||
@@ -48,13 +52,32 @@ import { DateFormatService } from '../../../core/i18n/date-format.service';
|
||||
}
|
||||
|
||||
<div class="signed-score__actions">
|
||||
<button type="button" class="signed-score__verify-btn" (click)="onVerify()">
|
||||
Verify
|
||||
</button>
|
||||
@if (canVerify()) {
|
||||
<button type="button" class="signed-score__verify-btn" (click)="onVerify()">
|
||||
Verify
|
||||
</button>
|
||||
}
|
||||
@if (model().verify; as verify) {
|
||||
<span class="signed-score__verify-item">Replay success {{ formatPercent(verify.replaySuccessRatio) }}</span>
|
||||
<span class="signed-score__verify-item">Median verify {{ verify.medianVerifyTimeMs }} ms</span>
|
||||
<span class="signed-score__verify-item">Symbol coverage {{ verify.symbolCoverage }}%</span>
|
||||
@if (verify.valid !== undefined) {
|
||||
<span class="signed-score__verify-item">
|
||||
Verification {{ verify.valid ? 'matched' : 'mismatch' }}
|
||||
</span>
|
||||
}
|
||||
@if (verify.replaySuccessRatio !== undefined && verify.replaySuccessRatio !== null) {
|
||||
<span class="signed-score__verify-item">Replay success {{ formatPercent(verify.replaySuccessRatio) }}</span>
|
||||
}
|
||||
@if (verify.medianVerifyTimeMs !== undefined && verify.medianVerifyTimeMs !== null) {
|
||||
<span class="signed-score__verify-item">Median verify {{ verify.medianVerifyTimeMs }} ms</span>
|
||||
}
|
||||
@if (verify.symbolCoverage !== undefined && verify.symbolCoverage !== null) {
|
||||
<span class="signed-score__verify-item">Symbol coverage {{ verify.symbolCoverage }}%</span>
|
||||
}
|
||||
@if (verify.verifiedAt) {
|
||||
<span class="signed-score__verify-item">Verified {{ formatTimestamp(verify.verifiedAt) }}</span>
|
||||
}
|
||||
@if (verify.errorMessage) {
|
||||
<span class="signed-score__verify-item">{{ verify.errorMessage }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -259,6 +282,7 @@ export class SignedScoreRibbonComponent {
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
|
||||
readonly model = input.required<SignedScoreDto>();
|
||||
readonly canVerify = input(true);
|
||||
readonly verifyRequested = output<void>();
|
||||
readonly expanded = signal(false);
|
||||
|
||||
@@ -266,13 +290,17 @@ export class SignedScoreRibbonComponent {
|
||||
Boolean(this.model().rootHash || this.model().canonicalInputHash),
|
||||
);
|
||||
|
||||
readonly showHardFailBadge = computed(() => this.model().gate.status === 'block');
|
||||
readonly showHardFailBadge = computed(() => this.model().gate?.status === 'block');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((value) => !value);
|
||||
}
|
||||
|
||||
onVerify(): void {
|
||||
if (!this.canVerify()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifyRequested.emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -1260,14 +1260,14 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
|
||||
constructor() {
|
||||
// Sync service's isOpen → component's bubbleOpen
|
||||
// When external callers (global search, Ctrl+K) set assistant.isOpen(true),
|
||||
// the bubble should open. Uses allowSignalWrites to update bubbleOpen.
|
||||
// the bubble should open and synchronize the signal-backed bubble state.
|
||||
effect(() => {
|
||||
const serviceOpen = this.assistant.isOpen();
|
||||
if (serviceOpen) {
|
||||
this.bubbleOpen.set(true);
|
||||
this.forceShow.set(true); // Override dismiss state for external callers
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -1502,3 +1502,4 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,20 +32,20 @@ describe('WitnessStatusChipComponent', () => {
|
||||
});
|
||||
|
||||
describe('status rendering', () => {
|
||||
const statusCases: { status: WitnessStatus; label: string; icon: string }[] = [
|
||||
{ status: 'witnessed', label: 'Witnessed', icon: '✓' },
|
||||
{ status: 'unwitnessed', label: 'Unwitnessed', icon: '○' },
|
||||
{ status: 'stale', label: 'Stale', icon: '⏱' },
|
||||
{ status: 'failed', label: 'Failed', icon: '✗' },
|
||||
const statusCases: { status: WitnessStatus; label: string }[] = [
|
||||
{ status: 'witnessed', label: 'Witnessed' },
|
||||
{ status: 'unwitnessed', label: 'Unwitnessed' },
|
||||
{ status: 'stale', label: 'Stale' },
|
||||
{ status: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
||||
statusCases.forEach(({ status, label, icon }) => {
|
||||
statusCases.forEach(({ status, label }) => {
|
||||
it(`should render ${status} status correctly`, () => {
|
||||
fixture.componentRef.setInput('status', status);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.statusLabel()).toBe(label);
|
||||
expect(component.statusIcon()).toBe(icon);
|
||||
expect(component.statusIcon()).toContain('<svg');
|
||||
expect(component.statusClass()).toContain(`witness-chip--${status}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* and shows visual feedback.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { CopyToClipboardComponent } from './copy-to-clipboard.component';
|
||||
|
||||
describe('CopyToClipboardComponent', () => {
|
||||
@@ -21,9 +22,15 @@ describe('CopyToClipboardComponent', () => {
|
||||
fixture = TestBed.createComponent(CopyToClipboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.value = 'test-value';
|
||||
ensureClipboardMock();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -39,7 +46,7 @@ describe('CopyToClipboardComponent', () => {
|
||||
});
|
||||
|
||||
it('should use custom aria label when provided', () => {
|
||||
component.ariaLabel = 'Copy secret';
|
||||
fixture.componentRef.setInput('ariaLabel', 'Copy secret');
|
||||
fixture.detectChanges();
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button.getAttribute('aria-label')).toBe('Copy secret');
|
||||
@@ -57,54 +64,59 @@ describe('CopyToClipboardComponent', () => {
|
||||
expect(rect).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call navigator.clipboard.writeText on click', fakeAsync(async () => {
|
||||
const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
it('should call navigator.clipboard.writeText on click', async () => {
|
||||
const writeTextSpy = ensureClipboardMock().mockResolvedValue();
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('test-value');
|
||||
}));
|
||||
});
|
||||
|
||||
it('should set copied to true after successful copy', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
it('should set copied to true after successful copy', async () => {
|
||||
ensureClipboardMock().mockResolvedValue();
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(component.copied()).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should reset copied to false after 2 seconds', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
it('should reset copied to false after 2 seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
ensureClipboardMock().mockResolvedValue();
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
expect(component.copied()).toBe(true);
|
||||
|
||||
tick(2000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(component.copied()).toBe(false);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should add copied CSS class when copied', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
it('should add copied CSS class when copied', async () => {
|
||||
ensureClipboardMock().mockResolvedValue();
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button.copy-btn');
|
||||
expect(button.classList.contains('copy-btn--copied')).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle clipboard API failure gracefully', fakeAsync(async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
|
||||
spyOn(console, 'error');
|
||||
it('should handle clipboard API failure gracefully', async () => {
|
||||
ensureClipboardMock().mockRejectedValue(new Error('denied'));
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await component.copy();
|
||||
tick();
|
||||
|
||||
expect(component.copied()).toBe(false);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
}));
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function ensureClipboardMock() {
|
||||
const writeText = vi.fn<() => Promise<void>>();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
return writeText;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ describe('InlineCodeComponent', () => {
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
component.code = 'test';
|
||||
fixture.componentRef.setInput('code', 'test');
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render a code element with inline-code class', () => {
|
||||
component.code = 'sha256:abc123';
|
||||
fixture.componentRef.setInput('code', 'sha256:abc123');
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
@@ -36,7 +36,7 @@ describe('InlineCodeComponent', () => {
|
||||
});
|
||||
|
||||
it('should display the provided code text', () => {
|
||||
component.code = 'demo-prod';
|
||||
fixture.componentRef.setInput('code', 'demo-prod');
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
@@ -44,13 +44,13 @@ describe('InlineCodeComponent', () => {
|
||||
});
|
||||
|
||||
it('should update when code input changes', () => {
|
||||
component.code = 'first';
|
||||
fixture.componentRef.setInput('code', 'first');
|
||||
fixture.detectChanges();
|
||||
|
||||
let codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
expect(codeEl.textContent).toBe('first');
|
||||
|
||||
component.code = 'second';
|
||||
fixture.componentRef.setInput('code', 'second');
|
||||
fixture.detectChanges();
|
||||
|
||||
codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
@@ -58,7 +58,7 @@ describe('InlineCodeComponent', () => {
|
||||
});
|
||||
|
||||
it('should render technical identifiers like GUIDs correctly', () => {
|
||||
component.code = '550e8400-e29b-41d4-a716-446655440000';
|
||||
fixture.componentRef.setInput('code', '550e8400-e29b-41d4-a716-446655440000');
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
@@ -66,7 +66,7 @@ describe('InlineCodeComponent', () => {
|
||||
});
|
||||
|
||||
it('should render scope strings correctly', () => {
|
||||
component.code = 'scanner:read';
|
||||
fixture.componentRef.setInput('code', 'scanner:read');
|
||||
fixture.detectChanges();
|
||||
|
||||
const codeEl = fixture.nativeElement.querySelector('code.inline-code');
|
||||
|
||||
@@ -305,7 +305,7 @@ describe('TimelineListComponent', () => {
|
||||
fixture.detectChanges();
|
||||
const icon = el.querySelector('.timeline__icon');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon!.textContent).toContain('check_circle');
|
||||
expect(icon!.querySelectorAll('circle, path, polyline, polygon').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
@use './styles/forms';
|
||||
@use './styles/interactions';
|
||||
@use './styles/tables';
|
||||
@use './styles/setup-wizard.component';
|
||||
@use './styles/setup-wizard-step-content.component';
|
||||
|
||||
// Quick-links (unified compact pill style)
|
||||
@use './styles/quick-links';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1086
src/Web/StellaOps.Web/src/styles/setup-wizard.component.scss
Normal file
1086
src/Web/StellaOps.Web/src/styles/setup-wizard.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
@@ -75,10 +75,10 @@ describe('ADMINISTRATION_ROUTES (administration)', () => {
|
||||
expect(route?.data?.['breadcrumb']).not.toContain('Release Control');
|
||||
});
|
||||
|
||||
it('trust-signing route is present and loads trust admin routes', () => {
|
||||
it('trust-signing route is present as a canonical alias into setup trust-signing', () => {
|
||||
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'trust-signing');
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.loadChildren).toBeTruthy();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('system route is present and uses System breadcrumb', () => {
|
||||
|
||||
@@ -97,10 +97,8 @@ describe('ChatComponent (advisory_ai_chat)', () => {
|
||||
const suggestionButtons = fixture.nativeElement.querySelectorAll('.suggestion-btn');
|
||||
const textarea = fixture.nativeElement.querySelector('.chat-input') as HTMLTextAreaElement | null;
|
||||
const emptyState = fixture.nativeElement.querySelector('.empty-state p') as HTMLElement | null;
|
||||
const heading = fixture.nativeElement.querySelector('.header-title') as HTMLElement | null;
|
||||
const emptyHeading = fixture.nativeElement.querySelector('.empty-state h3') as HTMLElement | null;
|
||||
|
||||
expect(heading?.textContent).toContain('Search assistant');
|
||||
expect(emptyHeading?.textContent).toContain('Go deeper');
|
||||
expect(emptyState?.textContent).toContain('current page');
|
||||
expect(suggestionButtons[0].textContent.trim()).toBe(
|
||||
|
||||
@@ -1,118 +1,128 @@
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AgentFleetDashboardComponent } from '../../app/features/agents/agent-fleet-dashboard.component';
|
||||
import { AgentStore } from '../../app/features/agents/services/agent.store';
|
||||
import { Agent } from '../../app/features/agents/models/agent.models';
|
||||
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
|
||||
import { StellaHelperContextService } from '../../app/shared/components/stella-helper/stella-helper-context.service';
|
||||
import { TopologyAgentsPageComponent } from '../../app/features/topology/topology-agents-page.component';
|
||||
import { TopologyDataService } from '../../app/features/topology/topology-data.service';
|
||||
|
||||
type MockStore = jasmine.SpyObj<Partial<AgentStore>> & {
|
||||
agents: WritableSignal<Agent[]>;
|
||||
filteredAgents: WritableSignal<Agent[]>;
|
||||
isLoading: WritableSignal<boolean>;
|
||||
error: WritableSignal<string | null>;
|
||||
summary: WritableSignal<{
|
||||
totalAgents: number;
|
||||
onlineAgents: number;
|
||||
degradedAgents: number;
|
||||
offlineAgents: number;
|
||||
totalCapacityPercent: number;
|
||||
totalActiveTasks: number;
|
||||
certificatesExpiringSoon: number;
|
||||
}>;
|
||||
selectedAgentId: WritableSignal<string | null>;
|
||||
lastRefresh: WritableSignal<string | null>;
|
||||
uniqueEnvironments: WritableSignal<string[]>;
|
||||
uniqueVersions: WritableSignal<string[]>;
|
||||
isRealtimeConnected: WritableSignal<boolean>;
|
||||
realtimeConnectionStatus: WritableSignal<string>;
|
||||
};
|
||||
|
||||
function createMockStore(): MockStore {
|
||||
return {
|
||||
agents: signal([]),
|
||||
filteredAgents: signal([]),
|
||||
isLoading: signal(false),
|
||||
error: signal(null),
|
||||
summary: signal({
|
||||
totalAgents: 2,
|
||||
onlineAgents: 1,
|
||||
degradedAgents: 1,
|
||||
offlineAgents: 0,
|
||||
totalCapacityPercent: 57,
|
||||
totalActiveTasks: 4,
|
||||
certificatesExpiringSoon: 0,
|
||||
}),
|
||||
selectedAgentId: signal(null),
|
||||
lastRefresh: signal(null),
|
||||
uniqueEnvironments: signal(['prod']),
|
||||
uniqueVersions: signal(['2.5.0']),
|
||||
isRealtimeConnected: signal(true),
|
||||
realtimeConnectionStatus: signal('connected'),
|
||||
fetchAgents: jasmine.createSpy('fetchAgents'),
|
||||
fetchSummary: jasmine.createSpy('fetchSummary'),
|
||||
enableRealtime: jasmine.createSpy('enableRealtime'),
|
||||
disableRealtime: jasmine.createSpy('disableRealtime'),
|
||||
reconnectRealtime: jasmine.createSpy('reconnectRealtime'),
|
||||
startAutoRefresh: jasmine.createSpy('startAutoRefresh'),
|
||||
stopAutoRefresh: jasmine.createSpy('stopAutoRefresh'),
|
||||
setSearchFilter: jasmine.createSpy('setSearchFilter'),
|
||||
setStatusFilter: jasmine.createSpy('setStatusFilter'),
|
||||
setEnvironmentFilter: jasmine.createSpy('setEnvironmentFilter'),
|
||||
setVersionFilter: jasmine.createSpy('setVersionFilter'),
|
||||
clearFilters: jasmine.createSpy('clearFilters'),
|
||||
} as unknown as MockStore;
|
||||
}
|
||||
|
||||
describe('AgentFleetDashboardComponent (agent_fleet)', () => {
|
||||
let fixture: ComponentFixture<AgentFleetDashboardComponent>;
|
||||
let component: AgentFleetDashboardComponent;
|
||||
let mockStore: MockStore;
|
||||
describe('TopologyAgentsPageComponent (agent_fleet)', () => {
|
||||
let fixture: ComponentFixture<TopologyAgentsPageComponent>;
|
||||
let component: TopologyAgentsPageComponent;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockStore = createMockStore();
|
||||
const topologyApi = {
|
||||
list: jasmine.createSpy('list').and.callFake((url: string) => {
|
||||
if (url === '/api/v2/topology/agents') {
|
||||
return of([
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
agentName: 'agent-prod-1',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'prod',
|
||||
status: 'Active',
|
||||
capabilities: ['deploy'],
|
||||
assignedTargetCount: 3,
|
||||
lastHeartbeatAt: '2026-02-08T10:00:00Z',
|
||||
},
|
||||
{
|
||||
agentId: 'agent-2',
|
||||
agentName: 'agent-stage-1',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'stage',
|
||||
status: 'Degraded',
|
||||
capabilities: ['scan'],
|
||||
assignedTargetCount: 1,
|
||||
lastHeartbeatAt: '2026-02-08T10:02:00Z',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (url === '/api/v2/topology/targets') {
|
||||
return of([
|
||||
{
|
||||
targetId: 'target-1',
|
||||
name: 'api-prod',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'prod',
|
||||
hostId: 'host-1',
|
||||
agentId: 'agent-1',
|
||||
targetType: 'container',
|
||||
healthStatus: 'healthy',
|
||||
componentVersionId: 'api@1.0.0',
|
||||
imageDigest: 'sha256:111',
|
||||
releaseId: 'rel-1',
|
||||
releaseVersionId: 'rv-1',
|
||||
lastSyncAt: '2026-02-08T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return of([]);
|
||||
}),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AgentFleetDashboardComponent],
|
||||
providers: [provideRouter([]), { provide: AgentStore, useValue: mockStore }],
|
||||
imports: [TopologyAgentsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { queryParamMap: of(convertToParamMap({})) },
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
initialize: jasmine.createSpy('initialize'),
|
||||
contextVersion: signal(0),
|
||||
regionSummary: () => 'All regions',
|
||||
environmentSummary: () => 'All environments',
|
||||
},
|
||||
},
|
||||
{ provide: TopologyDataService, useValue: topologyApi },
|
||||
{
|
||||
provide: StellaHelperContextService,
|
||||
useValue: {
|
||||
setScope: jasmine.createSpy('setScope'),
|
||||
clearScope: jasmine.createSpy('clearScope'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgentFleetDashboardComponent);
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture = TestBed.createComponent(TopologyAgentsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('initializes dashboard by fetching fleet state', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockStore.fetchAgents).toHaveBeenCalled();
|
||||
expect(mockStore.fetchSummary).toHaveBeenCalled();
|
||||
expect(mockStore.enableRealtime).toHaveBeenCalled();
|
||||
expect(mockStore.startAutoRefresh).toHaveBeenCalledWith(60000);
|
||||
});
|
||||
|
||||
it('renders title and KPI strip', () => {
|
||||
fixture.detectChanges();
|
||||
it('renders grouped agent health from topology data', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
expect(text).toContain('Agent Fleet');
|
||||
expect(text).toContain('Total Agents');
|
||||
expect(text).toContain('Avg Capacity');
|
||||
expect(text).toContain('Agents');
|
||||
expect(text).toContain('agent-eu-west-prod');
|
||||
expect(component.filteredGroups().length).toBe(2);
|
||||
expect(component.selectedGroup()?.environmentId).toBe('prod');
|
||||
});
|
||||
|
||||
it('updates search filter through store', () => {
|
||||
it('filters groups by search and switches into agent view deterministically', () => {
|
||||
component.searchQuery.set('stage');
|
||||
fixture.detectChanges();
|
||||
component.onSearchInput({ target: { value: 'agent-prod-1' } } as unknown as Event);
|
||||
|
||||
expect(component.searchQuery()).toBe('agent-prod-1');
|
||||
expect(mockStore.setSearchFilter).toHaveBeenCalledWith('agent-prod-1');
|
||||
});
|
||||
expect(component.filteredGroups().length).toBe(1);
|
||||
expect(component.filteredGroups()[0].environmentId).toBe('stage');
|
||||
|
||||
it('switches view mode deterministically', () => {
|
||||
component.viewMode.set('agents');
|
||||
component.selectedAgentId.set('agent-2');
|
||||
fixture.detectChanges();
|
||||
expect(component.viewMode()).toBe('grid');
|
||||
|
||||
component.setViewMode('table');
|
||||
expect(component.viewMode()).toBe('table');
|
||||
expect(component.selectedAgent()?.agentName).toBe('agent-stage-1');
|
||||
expect(component.filteredAgents().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('ApprovalsInboxComponent (approvals queue)', () => {
|
||||
expect(component.filtered().map((item) => item.id)).toEqual(['apr-1', 'apr-2']);
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Release Run Approvals Queue');
|
||||
expect(text).toContain('Approvals Queue');
|
||||
expect(text).toContain('API Gateway');
|
||||
expect(text).toContain('Scanner Hotfix');
|
||||
});
|
||||
@@ -134,7 +134,7 @@ describe('ApprovalsInboxComponent (approvals queue)', () => {
|
||||
);
|
||||
const openLinks = links.filter((link) => {
|
||||
const href = link.getAttribute('href') ?? '';
|
||||
return href.includes('/releases/runs/') && href.includes('/approvals');
|
||||
return href.includes('/releases/approvals/');
|
||||
});
|
||||
expect(openLinks.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
@@ -18,10 +18,10 @@ describe('Promotion Approval Queue UI (B27-001)', () => {
|
||||
|
||||
it('derives status filter badge counts from current queue data', () => {
|
||||
const filterButtons = fixture.nativeElement.querySelectorAll(
|
||||
'.status-filter__btn'
|
||||
'.chip'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
const counts = Array.from(filterButtons).map((button: HTMLButtonElement) => {
|
||||
const badge = button.querySelector('.status-filter__count') as HTMLSpanElement;
|
||||
const badge = button.querySelector('.chip__count') as HTMLSpanElement;
|
||||
return (badge.textContent ?? '').trim();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
AUDIT_BUNDLES_API_BASE_URL,
|
||||
AuditBundlesHttpClient,
|
||||
} from '../../app/core/api/audit-bundles.client';
|
||||
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
|
||||
import { TenantActivationService } from '../../app/core/auth/tenant-activation.service';
|
||||
import { StellaOpsHeaders } from '../../app/core/http/stella-ops-headers';
|
||||
|
||||
describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||
let client: AuditBundlesHttpClient;
|
||||
@@ -21,14 +21,6 @@ describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AUDIT_BUNDLES_API_BASE_URL, useValue: '/api/exportcenter' },
|
||||
{
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
session: () => ({
|
||||
tokens: { accessToken: 'test-token' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TenantActivationService,
|
||||
useValue: {
|
||||
@@ -51,8 +43,9 @@ describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
|
||||
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
expect(req.request.headers.get('X-Stella-Tenant')).toBe('tenant-demo');
|
||||
expect(req.request.headers.get('Authorization')).toBeNull();
|
||||
expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-demo');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.TraceId)).not.toBeNull();
|
||||
|
||||
req.flush({
|
||||
bundles: [
|
||||
@@ -152,6 +145,7 @@ describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||
const statusReq = httpMock.expectOne('/api/exportcenter/v1/audit-bundles/bundle-002');
|
||||
|
||||
expect(statusReq.request.method).toBe('GET');
|
||||
expect(statusReq.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-demo');
|
||||
statusReq.flush({
|
||||
bundleId: 'bundle-002',
|
||||
status: 'Completed',
|
||||
@@ -180,6 +174,7 @@ describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Accept')).toBe('application/zip');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-demo');
|
||||
|
||||
req.flush(new Blob(['PK\x03\x04'], { type: 'application/zip' }));
|
||||
|
||||
|
||||
@@ -101,6 +101,24 @@ const anomaliesFixture: AuditAnomalyAlert[] = [
|
||||
];
|
||||
|
||||
describe('unified-audit-log-viewer behavior', () => {
|
||||
const previousResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeAll(() => {
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
(globalThis as typeof globalThis & { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
|
||||
ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
previousResizeObserver;
|
||||
});
|
||||
|
||||
it('keeps evidence canonical while preserving admin audit aliases', () => {
|
||||
const canonicalRoute = routes.find((route) => route.path === 'evidence');
|
||||
expect(canonicalRoute).toBeDefined();
|
||||
@@ -109,16 +127,17 @@ describe('unified-audit-log-viewer behavior', () => {
|
||||
const childPaths = auditLogRoutes.map((route) => route.path);
|
||||
expect(childPaths).toEqual([
|
||||
'',
|
||||
'events',
|
||||
'events/:eventId',
|
||||
'timeline',
|
||||
'correlations',
|
||||
'anomalies',
|
||||
'export',
|
||||
'events',
|
||||
'policy',
|
||||
'authority',
|
||||
'vex',
|
||||
'integrations',
|
||||
'trust',
|
||||
'timeline',
|
||||
'correlations',
|
||||
'anomalies',
|
||||
'export',
|
||||
]);
|
||||
|
||||
expect(LEGACY_REDIRECT_ROUTE_TEMPLATES).toEqual(
|
||||
@@ -139,7 +158,8 @@ describe('unified-audit-log-viewer behavior', () => {
|
||||
);
|
||||
|
||||
const adminGroup = NAVIGATION_GROUPS.find((group) => group.id === 'admin');
|
||||
const auditItem = adminGroup?.items.find((item) => item.id === 'audit');
|
||||
const auditItem = adminGroup?.items.find((item) => item.route === '/evidence/audit-log');
|
||||
expect(auditItem?.id).toBe('audit-compliance');
|
||||
expect(auditItem?.route).toBe('/evidence/audit-log');
|
||||
});
|
||||
});
|
||||
@@ -148,6 +168,23 @@ describe('unified-audit-log-viewer dashboard behavior', () => {
|
||||
let fixture: ComponentFixture<AuditLogDashboardComponent>;
|
||||
let component: AuditLogDashboardComponent;
|
||||
let auditClient: jasmine.SpyObj<AuditLogClient>;
|
||||
const previousResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeAll(() => {
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
(globalThis as typeof globalThis & { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
|
||||
ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
previousResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
auditClient = jasmine.createSpyObj('AuditLogClient', [
|
||||
@@ -203,8 +240,8 @@ describe('unified-audit-log-viewer dashboard behavior', () => {
|
||||
'scheduler',
|
||||
]);
|
||||
expect(component.recentEvents().map((event) => event.id)).toEqual([
|
||||
'evt-a',
|
||||
'evt-b',
|
||||
'evt-a',
|
||||
'evt-c',
|
||||
]);
|
||||
expect(component.anomalies().length).toBe(1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -173,7 +174,19 @@ describe('BinaryIndexOpsComponent (binary_index)', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BinaryIndexOpsComponent],
|
||||
providers: [{ provide: BinaryIndexOpsClient, useValue: client as unknown as BinaryIndexOpsClient }],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
},
|
||||
},
|
||||
{ provide: BinaryIndexOpsClient, useValue: client as unknown as BinaryIndexOpsClient },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BinaryIndexOpsComponent);
|
||||
@@ -181,7 +194,7 @@ describe('BinaryIndexOpsComponent (binary_index)', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('loads binary-index health data on initialization', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -48,7 +48,19 @@ describe('Function Map Management UI (binary_index)', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BinaryIndexOpsComponent],
|
||||
providers: [{ provide: BinaryIndexOpsClient, useValue: client as unknown as BinaryIndexOpsClient }],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
},
|
||||
},
|
||||
{ provide: BinaryIndexOpsClient, useValue: client as unknown as BinaryIndexOpsClient },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BinaryIndexOpsComponent);
|
||||
@@ -56,7 +68,7 @@ describe('Function Map Management UI (binary_index)', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('loads function-map operational views and executes benchmark interactions', () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { CompareViewComponent } from '../../app/features/compare/components/compare-view/compare-view.component';
|
||||
@@ -13,12 +12,28 @@ import {
|
||||
DeltaResult,
|
||||
} from '../../app/features/compare/services/compare.service';
|
||||
import { CompareExportService } from '../../app/features/compare/services/compare-export.service';
|
||||
import { UserPreferencesService, ViewMode, ViewRole } from '../../app/features/compare/services/user-preferences.service';
|
||||
|
||||
class StubUserPreferencesService {
|
||||
private readonly roleValue = signal<ViewRole>('developer');
|
||||
private readonly viewModeValue = signal<ViewMode>('side-by-side');
|
||||
|
||||
readonly role = this.roleValue.asReadonly();
|
||||
readonly viewMode = this.viewModeValue.asReadonly();
|
||||
|
||||
setRole(role: ViewRole): void {
|
||||
this.roleValue.set(role);
|
||||
}
|
||||
|
||||
setViewMode(viewMode: ViewMode): void {
|
||||
this.viewModeValue.set(viewMode);
|
||||
}
|
||||
}
|
||||
|
||||
describe('CompareViewComponent (compare)', () => {
|
||||
let fixture: ComponentFixture<CompareViewComponent>;
|
||||
let component: CompareViewComponent;
|
||||
let compareSpy: jasmine.SpyObj<CompareService>;
|
||||
let exportSpy: jasmine.SpyObj<CompareExportService>;
|
||||
let userPreferences: StubUserPreferencesService;
|
||||
|
||||
const currentTarget: CompareTarget = {
|
||||
id: 'cur-1',
|
||||
@@ -70,7 +85,7 @@ describe('CompareViewComponent (compare)', () => {
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
compareSpy = jasmine.createSpyObj('CompareService', [
|
||||
@@ -114,62 +129,36 @@ describe('CompareViewComponent (compare)', () => {
|
||||
'exportJson',
|
||||
]) as jasmine.SpyObj<CompareExportService>;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, CompareViewComponent],
|
||||
providers: [
|
||||
{ provide: CompareService, useValue: compareSpy },
|
||||
{ provide: CompareExportService, useValue: exportSpy },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ currentId: 'cur-1' })),
|
||||
queryParamMap: of(convertToParamMap({ baseline: 'base-1' })),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ currentId: 'cur-1' }),
|
||||
queryParamMap: convertToParamMap({ baseline: 'base-1' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: jasmine.createSpyObj('MatSnackBar', ['open']),
|
||||
},
|
||||
{
|
||||
provide: Clipboard,
|
||||
useValue: jasmine.createSpyObj('Clipboard', ['copy']),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CompareViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
userPreferences = new StubUserPreferencesService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads current and baseline targets from route params', () => {
|
||||
expect(compareSpy.getTarget).toHaveBeenCalledWith('cur-1');
|
||||
expect(compareSpy.getTarget).toHaveBeenCalledWith('base-1');
|
||||
expect(component.currentTarget()?.id).toBe('cur-1');
|
||||
expect(component.baselineTarget()?.id).toBe('base-1');
|
||||
it('reads current and baseline ids from the route signals', () => {
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.resolvedCurrentId()).toBe('cur-1');
|
||||
expect(component.resolvedBaselineId()).toBe('base-1');
|
||||
});
|
||||
|
||||
it('renders delta summary chips for compare verdict view', () => {
|
||||
it('computes the delta summary for an explicitly loaded comparison', () => {
|
||||
const component = createComponent();
|
||||
component.loadTarget('cur-1', 'current');
|
||||
component.loadTarget('base-1', 'baseline');
|
||||
|
||||
expect(component.deltaSummary()?.totalAdded).toBe(1);
|
||||
expect(component.deltaSummary()?.totalChanged).toBe(1);
|
||||
|
||||
const addedChip = fixture.nativeElement.querySelector(
|
||||
'.summary-chip.added'
|
||||
) as HTMLElement;
|
||||
expect(addedChip.textContent).toContain('+1 added');
|
||||
expect(component.filteredItems().map(item => item.id)).toEqual(['item-2']);
|
||||
});
|
||||
|
||||
it('filters items by category and loads evidence for selected item', () => {
|
||||
it('filters items by category and loads evidence for the selected item', () => {
|
||||
const component = createComponent();
|
||||
component.loadTarget('cur-1', 'current');
|
||||
component.loadTarget('base-1', 'baseline');
|
||||
|
||||
component.selectCategory('added');
|
||||
expect(component.filteredItems().length).toBe(1);
|
||||
expect(component.filteredItems()[0].id).toBe('item-1');
|
||||
@@ -179,35 +168,47 @@ describe('CompareViewComponent (compare)', () => {
|
||||
expect(component.evidence()?.title).toBe('Evidence diff');
|
||||
});
|
||||
|
||||
it('toggles view mode and exports compare report', () => {
|
||||
it('toggles view mode and exports the compare report', () => {
|
||||
const component = createComponent();
|
||||
component.loadTarget('cur-1', 'current');
|
||||
component.loadTarget('base-1', 'baseline');
|
||||
|
||||
expect(component.viewMode()).toBe('side-by-side');
|
||||
component.toggleViewMode();
|
||||
expect(component.viewMode()).toBe('unified');
|
||||
|
||||
component.exportReport();
|
||||
expect(exportSpy.exportJson).toHaveBeenCalled();
|
||||
expect(exportSpy.exportJson).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ id: 'cur-1' }),
|
||||
jasmine.objectContaining({ id: 'base-1' }),
|
||||
jasmine.any(Array),
|
||||
jasmine.any(Array)
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-loads the active scan input and recommended baseline for embedded findings usage', () => {
|
||||
compareSpy.getTarget.calls.reset();
|
||||
compareSpy.getBaselineRationale.calls.reset();
|
||||
compareSpy.computeDelta.calls.reset();
|
||||
|
||||
fixture.componentRef.setInput('currentId', 'active-scan');
|
||||
fixture.componentRef.setInput('baselineId', null);
|
||||
fixture.detectChanges();
|
||||
it('uses the route scan id and recommended baseline for active-scan flows', () => {
|
||||
const component = createComponent({
|
||||
currentId: null,
|
||||
baselineId: null,
|
||||
scanId: 'active-scan',
|
||||
});
|
||||
component.loadTarget('active-scan', 'current');
|
||||
const internals = component as unknown as {
|
||||
loadBaselineRecommendation(currentId: string, token: number, allowAutoSelection: boolean): void;
|
||||
loadToken: number;
|
||||
};
|
||||
internals.loadBaselineRecommendation('active-scan', internals.loadToken, true);
|
||||
|
||||
expect(component.resolvedCurrentId()).toBe('active-scan');
|
||||
expect(component.resolvedBaselineId()).toBeNull();
|
||||
expect(compareSpy.getBaselineRationale).toHaveBeenCalledWith('active-scan');
|
||||
expect(compareSpy.getTarget).toHaveBeenCalledWith('base-1');
|
||||
expect(compareSpy.getTarget).toHaveBeenCalledWith('sha256:base-1');
|
||||
expect(component.currentTarget()?.id).toBe('active-scan');
|
||||
expect(component.baselineTarget()?.id).toBe('base-1');
|
||||
expect(component.canExport()).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows an unavailable state and disables export when no baseline exists for the scan', () => {
|
||||
compareSpy.getTarget.calls.reset();
|
||||
compareSpy.getBaselineRationale.calls.reset();
|
||||
compareSpy.computeDelta.calls.reset();
|
||||
it('keeps compare export unavailable when the scan has no recommended baseline', () => {
|
||||
compareSpy.getBaselineRationale.and.returnValue(
|
||||
of({
|
||||
selectedDigest: '',
|
||||
@@ -217,21 +218,69 @@ describe('CompareViewComponent (compare)', () => {
|
||||
})
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('currentId', 'active-scan');
|
||||
fixture.componentRef.setInput('baselineId', null);
|
||||
fixture.detectChanges();
|
||||
const component = createComponent({
|
||||
currentId: null,
|
||||
baselineId: null,
|
||||
scanId: 'active-scan',
|
||||
});
|
||||
component.loadTarget('active-scan', 'current');
|
||||
const internals = component as unknown as {
|
||||
loadBaselineRecommendation(currentId: string, token: number, allowAutoSelection: boolean): void;
|
||||
loadToken: number;
|
||||
};
|
||||
internals.loadBaselineRecommendation('active-scan', internals.loadToken, true);
|
||||
|
||||
expect(compareSpy.getBaselineRationale).toHaveBeenCalledWith('active-scan');
|
||||
expect(compareSpy.computeDelta).not.toHaveBeenCalled();
|
||||
expect(component.deltaSummary()).toBeNull();
|
||||
expect(component.baselineNarrative()).toBe('No baseline recommendations available for this scan');
|
||||
expect(component.canExport()).toBeFalse();
|
||||
expect(fixture.nativeElement.textContent).toContain('No baseline recommendations available for this scan');
|
||||
|
||||
const exportButton = Array.from<HTMLButtonElement>(
|
||||
fixture.nativeElement.querySelectorAll('button')
|
||||
)
|
||||
.find((button) => button.textContent?.includes('Export')) as HTMLButtonElement | undefined;
|
||||
expect(exportButton).toBeDefined();
|
||||
expect(exportButton?.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
function createComponent(route?: {
|
||||
currentId?: string | null;
|
||||
baselineId?: string | null;
|
||||
scanId?: string | null;
|
||||
}): CompareViewComponent {
|
||||
const currentId = route && 'currentId' in route ? route.currentId ?? null : 'cur-1';
|
||||
const baselineId = route && 'baselineId' in route ? route.baselineId ?? null : 'base-1';
|
||||
const scanId = route && 'scanId' in route ? route.scanId ?? null : null;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: CompareService, useValue: compareSpy },
|
||||
{ provide: CompareExportService, useValue: exportSpy },
|
||||
{ provide: UserPreferencesService, useValue: userPreferences },
|
||||
{
|
||||
provide: DomSanitizer,
|
||||
useValue: {
|
||||
bypassSecurityTrustHtml: (value: string) => value as unknown as SafeHtml,
|
||||
} satisfies Pick<DomSanitizer, 'bypassSecurityTrustHtml'>,
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(toParamMap(currentId ? { currentId } : {})),
|
||||
queryParamMap: of(toParamMap({
|
||||
...(baselineId ? { baseline: baselineId } : {}),
|
||||
...(scanId ? { scanId } : {}),
|
||||
})),
|
||||
snapshot: {
|
||||
paramMap: toParamMap(currentId ? { currentId } : {}),
|
||||
queryParamMap: toParamMap({
|
||||
...(baselineId ? { baseline: baselineId } : {}),
|
||||
...(scanId ? { scanId } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return TestBed.runInInjectionContext(() => new CompareViewComponent());
|
||||
}
|
||||
|
||||
function toParamMap(values: Record<string, string>): ParamMap {
|
||||
return convertToParamMap(values);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
@@ -13,10 +11,9 @@ import {
|
||||
DeltaResult,
|
||||
} from '../../app/features/compare/services/compare.service';
|
||||
import { CompareExportService } from '../../app/features/compare/services/compare-export.service';
|
||||
import { UserPreferencesService } from '../../app/features/compare/services/user-preferences.service';
|
||||
|
||||
describe('role-based-views behavior', () => {
|
||||
let fixture: ComponentFixture<CompareViewComponent>;
|
||||
let component: CompareViewComponent;
|
||||
let compareSpy: jasmine.SpyObj<CompareService>;
|
||||
|
||||
const currentTarget: CompareTarget = {
|
||||
@@ -60,7 +57,9 @@ describe('role-based-views behavior', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
compareSpy = jasmine.createSpyObj('CompareService', [
|
||||
'getTarget',
|
||||
'getBaselineRationale',
|
||||
@@ -82,92 +81,52 @@ describe('role-based-views behavior', () => {
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, CompareViewComponent],
|
||||
providers: [
|
||||
{ provide: CompareService, useValue: compareSpy },
|
||||
{
|
||||
provide: CompareExportService,
|
||||
useValue: jasmine.createSpyObj('CompareExportService', ['exportJson']),
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ currentId: 'cur-1' })),
|
||||
queryParamMap: of(convertToParamMap({ baseline: 'base-1' })),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ currentId: 'cur-1' }),
|
||||
queryParamMap: convertToParamMap({ baseline: 'base-1' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: jasmine.createSpyObj('MatSnackBar', ['open']),
|
||||
},
|
||||
{
|
||||
provide: Clipboard,
|
||||
useValue: jasmine.createSpyObj('Clipboard', ['copy']),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CompareViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders role toggle and defaults to developer-gated surfaces', () => {
|
||||
const buttons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.role-toggle-button')
|
||||
) as HTMLButtonElement[];
|
||||
const labels = buttons.map((button) => button.textContent?.trim());
|
||||
expect(labels).toEqual(['Developer', 'Security', 'Audit']);
|
||||
it('defaults to developer persona and developer-gated surfaces', () => {
|
||||
const component = createComponent();
|
||||
|
||||
const developerButton = buttons.find((button) => button.textContent?.includes('Developer'));
|
||||
expect(developerButton?.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
expect(fixture.nativeElement.querySelector('stella-trust-indicators')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('stella-baseline-rationale')).toBeNull();
|
||||
expect(component.roles.map((role) => component.getRoleLabel(role))).toEqual([
|
||||
'Developer',
|
||||
'Security',
|
||||
'Audit',
|
||||
]);
|
||||
expect(component.currentRole()).toBe('developer');
|
||||
expect(component.roleView().showTrustIndicators).toBeFalse();
|
||||
expect(component.roleView().showBaselineRationale).toBeFalse();
|
||||
});
|
||||
|
||||
it('updates visible surfaces and persisted role when switching persona', () => {
|
||||
component.setRole('security');
|
||||
fixture.detectChanges();
|
||||
it('updates visible persona state and clears item evidence when switching roles', () => {
|
||||
const component = createComponent();
|
||||
seedComparisonState(component);
|
||||
|
||||
component.setRole('security');
|
||||
expect(component.currentRole()).toBe('security');
|
||||
expect(fixture.nativeElement.querySelector('stella-trust-indicators')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('stella-baseline-rationale')).toBeNull();
|
||||
expect(component.roleView().showTrustIndicators).toBeTrue();
|
||||
expect(component.roleView().showBaselineRationale).toBeFalse();
|
||||
|
||||
component.selectItem(delta.items[0]);
|
||||
expect(component.selectedItem()?.id).toBe('item-1');
|
||||
expect(component.evidence()?.title).toBe('Evidence diff');
|
||||
|
||||
component.setRole('audit');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.currentRole()).toBe('audit');
|
||||
expect(component.selectedItem()).toBeNull();
|
||||
expect(component.evidence()).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('stella-trust-indicators')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('stella-baseline-rationale')).not.toBeNull();
|
||||
expect(component.roleView().showTrustIndicators).toBeTrue();
|
||||
expect(component.roleView().showBaselineRationale).toBeTrue();
|
||||
|
||||
TestBed.flushEffects();
|
||||
const stored = JSON.parse(localStorage.getItem('stellaops.compare.preferences') || '{}');
|
||||
expect(stored.role).toBe('audit');
|
||||
});
|
||||
|
||||
it('falls back to developer when stored role is unauthorized', async () => {
|
||||
it('falls back to developer when stored role is unauthorized', () => {
|
||||
localStorage.setItem(
|
||||
'stellaops.compare.preferences',
|
||||
JSON.stringify({
|
||||
@@ -176,11 +135,50 @@ describe('role-based-views behavior', () => {
|
||||
})
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await setup();
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.currentRole()).toBe('developer');
|
||||
expect(fixture.nativeElement.querySelector('stella-trust-indicators')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('stella-baseline-rationale')).toBeNull();
|
||||
expect(component.roleView().showTrustIndicators).toBeFalse();
|
||||
expect(component.roleView().showBaselineRationale).toBeFalse();
|
||||
});
|
||||
|
||||
function createComponent(): CompareViewComponent {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: CompareService, useValue: compareSpy },
|
||||
{
|
||||
provide: CompareExportService,
|
||||
useValue: jasmine.createSpyObj('CompareExportService', ['exportJson']),
|
||||
},
|
||||
UserPreferencesService,
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({})),
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({}),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomSanitizer,
|
||||
useValue: {
|
||||
bypassSecurityTrustHtml: (value: string) => value as unknown as SafeHtml,
|
||||
} satisfies Pick<DomSanitizer, 'bypassSecurityTrustHtml'>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return TestBed.runInInjectionContext(() => new CompareViewComponent());
|
||||
}
|
||||
|
||||
function seedComparisonState(component: CompareViewComponent): void {
|
||||
component.currentTarget.set(currentTarget);
|
||||
component.baselineTarget.set(baselineTarget);
|
||||
component.baselineRecommendation.set(rationale);
|
||||
component.categories.set(delta.categories);
|
||||
component.items.set(delta.items);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Location } from '@angular/common';
|
||||
import { Router, Routes, provideRouter } from '@angular/router';
|
||||
|
||||
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
|
||||
@@ -27,6 +28,7 @@ async function waitForCondition(predicate: () => boolean): Promise<void> {
|
||||
|
||||
describe('PlatformContextUrlSyncService', () => {
|
||||
let router: Router;
|
||||
let location: Location;
|
||||
let service: PlatformContextUrlSyncService;
|
||||
let contextStore: {
|
||||
initialize: jasmine.Spy;
|
||||
@@ -68,6 +70,7 @@ describe('PlatformContextUrlSyncService', () => {
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
location = TestBed.inject(Location);
|
||||
service = TestBed.inject(PlatformContextUrlSyncService);
|
||||
service.initialize();
|
||||
router.initialNavigation();
|
||||
@@ -97,13 +100,14 @@ describe('PlatformContextUrlSyncService', () => {
|
||||
await settleRouter();
|
||||
|
||||
contextStore.contextVersion.update((value) => value + 1);
|
||||
await waitForCondition(() => router.url.includes('regions=us-east'));
|
||||
await waitForCondition(() => location.path().includes('regions=us-east'));
|
||||
|
||||
expect(router.url).toContain('/mission-control');
|
||||
expect(router.url).toContain('tenant=tenant-alpha');
|
||||
expect(router.url).toContain('regions=us-east');
|
||||
expect(router.url).toContain('environments=prod');
|
||||
expect(router.url).toContain('timeWindow=7d');
|
||||
expect(location.path()).toContain('/mission-control');
|
||||
expect(location.path()).toContain('tenant=tenant-alpha');
|
||||
expect(location.path()).toContain('regions=us-east');
|
||||
expect(location.path()).toContain('environments=prod');
|
||||
expect(location.path()).toContain('timeWindow=7d');
|
||||
});
|
||||
|
||||
it('skips setup-wizard route from scope sync management', async () => {
|
||||
|
||||
@@ -1,77 +1,246 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AUTH_SERVICE, type AuthService } from '../../app/core/auth/auth.service';
|
||||
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
|
||||
import { PageActionService } from '../../app/core/services/page-action.service';
|
||||
import { VULNERABILITY_API, type VulnerabilityApi } from '../../app/core/api/vulnerability.client';
|
||||
import { DashboardV3Component } from '../../app/features/dashboard-v3/dashboard-v3.component';
|
||||
import {
|
||||
SourceManagementApi,
|
||||
type SourceStatusResponse,
|
||||
} from '../../app/features/integrations/advisory-vex-sources/source-management.api';
|
||||
import { StellaHelperContextService } from '../../app/shared/components/stella-helper/stella-helper-context.service';
|
||||
import { StellaPreferencesService } from '../../app/shared/components/stella-helper/stella-preferences.service';
|
||||
|
||||
const contextStoreStub: Pick<
|
||||
PlatformContextStore,
|
||||
'initialize' | 'initialized' | 'environments' | 'selectedRegions' | 'error' | 'tenantId'
|
||||
> = {
|
||||
initialize: jasmine.createSpy('initialize'),
|
||||
initialized: signal(true),
|
||||
environments: signal([
|
||||
{
|
||||
environmentId: 'dev',
|
||||
regionId: 'us-east',
|
||||
environmentType: 'development',
|
||||
displayName: 'Development',
|
||||
sortOrder: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
environmentId: 'stage',
|
||||
regionId: 'us-east',
|
||||
environmentType: 'staging',
|
||||
displayName: 'Staging',
|
||||
sortOrder: 20,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'production',
|
||||
displayName: 'Production',
|
||||
sortOrder: 30,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
selectedRegions: signal([]),
|
||||
error: signal<string | null>(null),
|
||||
tenantId: signal('acme-tenant'),
|
||||
};
|
||||
|
||||
const vulnerabilityApiStub: Pick<VulnerabilityApi, 'getStats'> = {
|
||||
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<SourceManagementApi, 'getStatus'> = {
|
||||
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<PageActionService, 'set' | 'clear'> = {
|
||||
set: jasmine.createSpy('set'),
|
||||
clear: jasmine.createSpy('clear'),
|
||||
};
|
||||
|
||||
const helperContextStub: Pick<StellaHelperContextService, 'setScope' | 'clearScope'> = {
|
||||
setScope: jasmine.createSpy('setScope'),
|
||||
clearScope: jasmine.createSpy('clearScope'),
|
||||
};
|
||||
|
||||
const preferencesStub: Pick<StellaPreferencesService, 'isBannerDismissed' | 'dismissBanner'> = {
|
||||
isBannerDismissed: () => true,
|
||||
dismissBanner: jasmine.createSpy('dismissBanner'),
|
||||
};
|
||||
|
||||
describe('DashboardV3Component', () => {
|
||||
let fixture: ComponentFixture<DashboardV3Component>;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
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(async () => {
|
||||
(contextStoreStub.initialize as jasmine.Spy).calls.reset();
|
||||
(vulnerabilityApiStub.getStats as jasmine.Spy).calls.reset();
|
||||
(sourceManagementApiStub.getStatus as jasmine.Spy).calls.reset();
|
||||
(pageActionStub.set as jasmine.Spy).calls.reset();
|
||||
(pageActionStub.clear as jasmine.Spy).calls.reset();
|
||||
(helperContextStub.setScope as jasmine.Spy).calls.reset();
|
||||
(helperContextStub.clearScope as jasmine.Spy).calls.reset();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardV3Component],
|
||||
providers: [provideRouter([])],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: PlatformContextStore, useValue: contextStoreStub },
|
||||
{ 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 },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DashboardV3Component);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders Dashboard heading and mission-board subtitle', () => {
|
||||
it('renders the dashboard heading with the active tenant subtitle', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Dashboard');
|
||||
expect(text).toContain('Mission board');
|
||||
expect(text).toContain('Acme Tenant');
|
||||
});
|
||||
|
||||
it('shows SBOM, CritR, and B/I/R metrics in environment cards', () => {
|
||||
it('shows environment cards with the current default posture metrics', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Pending Actions');
|
||||
expect(text).toContain('Development');
|
||||
expect(text).toContain('SBOM');
|
||||
expect(text).toContain('CritR');
|
||||
expect(text).toContain('HighR');
|
||||
expect(text).toContain('B/I/R');
|
||||
expect(text).toContain('3/3');
|
||||
expect(text).toContain('No deployments');
|
||||
});
|
||||
|
||||
it('renders environments-at-risk table with canonical columns and Open actions', () => {
|
||||
it('renders environments-at-risk with the current canonical columns and open actions', () => {
|
||||
const headers = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.risk-table__table th') as NodeListOf<HTMLElement>
|
||||
fixture.nativeElement.querySelectorAll('.risk-table__container th') as NodeListOf<HTMLElement>
|
||||
).map((node) => (node.textContent ?? '').trim());
|
||||
|
||||
expect(headers).toEqual([
|
||||
'Region/Env',
|
||||
'Deploy Health',
|
||||
'SBOM Status',
|
||||
'Crit Reach',
|
||||
'Hybrid B/I/R',
|
||||
'Last SBOM',
|
||||
'Action',
|
||||
]);
|
||||
expect(headers).toEqual(['Region/Env', 'Health', 'SBOM', 'CritR', 'Action']);
|
||||
|
||||
const openLinks = fixture.nativeElement.querySelectorAll('.risk-table__table td a');
|
||||
const openLinks = fixture.nativeElement.querySelectorAll('.risk-table__container td a');
|
||||
expect(openLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders SBOM findings snapshot with critical findings filter link', () => {
|
||||
const links = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('a.card-action')
|
||||
) as HTMLAnchorElement[];
|
||||
const openFindingsLink = links.find((link) =>
|
||||
(link.textContent ?? '').includes('Open Findings')
|
||||
);
|
||||
it('renders vulnerability and feed status cards with their live action links', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Vulnerability Summary');
|
||||
expect(text).toContain('Feed Status');
|
||||
expect(text).toContain('View Findings');
|
||||
expect(text).toContain('Manage Sources');
|
||||
|
||||
expect(openFindingsLink).toBeTruthy();
|
||||
expect(openFindingsLink?.getAttribute('href')).toContain('/security-risk/findings');
|
||||
expect(openFindingsLink?.getAttribute('href')).toContain('reachability=critical');
|
||||
const links = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>
|
||||
);
|
||||
const findingsLink = links.find((link) => (link.textContent ?? '').includes('View Findings'));
|
||||
const sourcesLink = links.find((link) => (link.textContent ?? '').includes('Manage Sources'));
|
||||
|
||||
expect(findingsLink?.getAttribute('href')).toContain('/triage/artifacts');
|
||||
expect(sourcesLink?.getAttribute('href')).toContain(
|
||||
'/setup/integrations/advisory-vex-sources'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nightly ops signals card with four status rows and data integrity link', () => {
|
||||
const cardHeaders = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.card-title') as NodeListOf<HTMLElement>
|
||||
).map((node) => (node.textContent ?? '').trim());
|
||||
expect(cardHeaders).toContain('Nightly Ops Signals');
|
||||
|
||||
const signalRows = fixture.nativeElement.querySelectorAll('.integrity-stat');
|
||||
expect(signalRows.length).toBe(4);
|
||||
|
||||
it('renders the current health and diagnostics sections instead of the removed ops-signals card', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Open Data Integrity');
|
||||
expect(text).toContain('Environment Health');
|
||||
expect(text).toContain('Environments at Risk');
|
||||
expect(text).toContain('Diagnostics');
|
||||
expect(text).not.toContain('Nightly Ops Signals');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,48 +7,35 @@ import {
|
||||
|
||||
import { DeployDiffPanelComponent } from '../../app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component';
|
||||
import { DeployDiffService } from '../../app/features/deploy-diff/services/deploy-diff.service';
|
||||
import { SbomDiffResult } from '../../app/features/deploy-diff/models/deploy-diff.models';
|
||||
import { LineageDiffResponse } from '../../app/features/lineage/models/lineage.models';
|
||||
|
||||
const mockDiff: SbomDiffResult = {
|
||||
added: [
|
||||
const mockDiff: LineageDiffResponse = {
|
||||
fromDigest: 'sha256:from',
|
||||
toDigest: 'sha256:to',
|
||||
computedAt: '2026-02-10T21:00:00Z',
|
||||
componentDiff: {
|
||||
added: [
|
||||
{
|
||||
purl: 'pkg:apk/alpine/openssl@3.0.13-r0',
|
||||
name: 'openssl',
|
||||
currentVersion: '3.0.13',
|
||||
currentLicense: 'Apache-2.0',
|
||||
changeType: 'added',
|
||||
},
|
||||
],
|
||||
removed: [],
|
||||
changed: [],
|
||||
sourceTotal: 24,
|
||||
targetTotal: 25,
|
||||
},
|
||||
reachabilityDeltas: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
changeType: 'added',
|
||||
name: 'openssl',
|
||||
fromVersion: null,
|
||||
toVersion: '3.0.13',
|
||||
licenseChanged: false,
|
||||
cve: 'CVE-2026-0001',
|
||||
currentReachable: true,
|
||||
currentPathCount: 2,
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
removed: [],
|
||||
changed: [],
|
||||
unchanged: 24,
|
||||
policyHits: [
|
||||
{
|
||||
id: 'hit-1',
|
||||
gate: 'critical-cve',
|
||||
severity: 'high',
|
||||
result: 'fail',
|
||||
message: 'Critical CVE remains reachable',
|
||||
componentIds: ['comp-1'],
|
||||
},
|
||||
],
|
||||
policyResult: {
|
||||
allowed: false,
|
||||
overrideAvailable: true,
|
||||
failCount: 1,
|
||||
warnCount: 0,
|
||||
passCount: 3,
|
||||
},
|
||||
metadata: {
|
||||
fromDigest: 'sha256:from',
|
||||
toDigest: 'sha256:to',
|
||||
fromLabel: 'v1.0.0',
|
||||
toLabel: 'v1.1.0',
|
||||
computedAt: '2026-02-10T21:00:00Z',
|
||||
fromTotalComponents: 24,
|
||||
toTotalComponents: 25,
|
||||
},
|
||||
};
|
||||
|
||||
describe('DeployDiffPanelComponent (deploy_diff)', () => {
|
||||
@@ -78,10 +65,20 @@ describe('DeployDiffPanelComponent (deploy_diff)', () => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
function expectCompareRequest() {
|
||||
return httpMock.expectOne((request) => (
|
||||
request.method === 'GET'
|
||||
&& request.url === '/api/sbomservice/api/v1/lineage/compare'
|
||||
&& request.params.get('a') === 'sha256:from'
|
||||
&& request.params.get('b') === 'sha256:to'
|
||||
&& request.params.get('tenant') === 'demo-prod'
|
||||
));
|
||||
}
|
||||
|
||||
it('loads diff data and renders summary strip', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
|
||||
const req = expectCompareRequest();
|
||||
req.flush(mockDiff);
|
||||
|
||||
await fixture.whenStable();
|
||||
@@ -96,7 +93,7 @@ describe('DeployDiffPanelComponent (deploy_diff)', () => {
|
||||
it('renders error state when diff request fails', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
|
||||
const req = expectCompareRequest();
|
||||
req.flush({ message: 'boom' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
await fixture.whenStable();
|
||||
@@ -110,7 +107,7 @@ describe('DeployDiffPanelComponent (deploy_diff)', () => {
|
||||
it('shows action bar after successful load', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/sbom/diff?from=sha256:from&to=sha256:to');
|
||||
const req = expectCompareRequest();
|
||||
req.flush(mockDiff);
|
||||
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
|
||||
import { DEPLOYMENT_API } from '../../app/core/api/deployment.client';
|
||||
import type { ManagedRelease, RegistryImage } from '../../app/core/api/release-management.models';
|
||||
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
|
||||
import { BundleOrganizerApi } from '../../app/features/bundles/bundle-organizer.api';
|
||||
import { CreateDeploymentComponent } from '../../app/features/release-orchestrator/releases/create-deployment/create-deployment.component';
|
||||
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
|
||||
|
||||
const RELEASE: ManagedRelease = {
|
||||
id: 'rel-1',
|
||||
name: 'checkout-api',
|
||||
version: '2026.04.05',
|
||||
description: 'Checkout API release',
|
||||
status: 'ready',
|
||||
releaseType: 'standard',
|
||||
slug: 'checkout-api',
|
||||
digest: 'sha256:release',
|
||||
currentStage: null,
|
||||
currentEnvironment: null,
|
||||
targetEnvironment: 'env-prod-eu',
|
||||
targetRegion: 'eu',
|
||||
componentCount: 1,
|
||||
gateStatus: 'pass',
|
||||
gateBlockingCount: 0,
|
||||
gatePendingApprovals: 0,
|
||||
gateBlockingReasons: [],
|
||||
riskCriticalReachable: 0,
|
||||
riskHighReachable: 0,
|
||||
riskTrend: 'stable',
|
||||
riskTier: 'low',
|
||||
evidencePosture: 'verified',
|
||||
needsApproval: false,
|
||||
blocked: false,
|
||||
hotfixLane: false,
|
||||
replayMismatch: false,
|
||||
createdAt: '2026-04-05T10:00:00Z',
|
||||
createdBy: 'qa',
|
||||
updatedAt: '2026-04-05T10:00:00Z',
|
||||
lastActor: 'qa',
|
||||
deployedAt: null,
|
||||
deploymentStrategy: 'rolling',
|
||||
};
|
||||
|
||||
describe('CreateDeploymentComponent', () => {
|
||||
let fixture: ComponentFixture<CreateDeploymentComponent>;
|
||||
let component: CreateDeploymentComponent;
|
||||
let router: Router;
|
||||
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let releaseSignal: ReturnType<typeof signal<ManagedRelease | null>>;
|
||||
let searchResultsSignal: ReturnType<typeof signal<RegistryImage[]>>;
|
||||
let bundleApi: {
|
||||
listBundleVersions: jasmine.Spy;
|
||||
getBundleVersion: jasmine.Spy;
|
||||
createBundle: jasmine.Spy;
|
||||
publishBundleVersion: jasmine.Spy;
|
||||
materializeBundleVersion: jasmine.Spy;
|
||||
listBundles: jasmine.Spy;
|
||||
};
|
||||
let deploymentApi: {
|
||||
createDeployment: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
queryParamMap$ = new BehaviorSubject(convertToParamMap({ releaseId: RELEASE.id }));
|
||||
releaseSignal = signal<ManagedRelease | null>(RELEASE);
|
||||
searchResultsSignal = signal<RegistryImage[]>([]);
|
||||
|
||||
bundleApi = {
|
||||
listBundleVersions: jasmine.createSpy('listBundleVersions').and.returnValue(of([
|
||||
{
|
||||
id: 'ver-1',
|
||||
bundleId: RELEASE.id,
|
||||
versionNumber: 7,
|
||||
digest: 'sha256:bundle-version',
|
||||
status: 'published',
|
||||
componentsCount: 1,
|
||||
changelog: null,
|
||||
createdAt: '2026-04-05T11:00:00Z',
|
||||
publishedAt: '2026-04-05T11:05:00Z',
|
||||
createdBy: 'qa',
|
||||
},
|
||||
])),
|
||||
getBundleVersion: jasmine.createSpy('getBundleVersion').and.returnValue(of({
|
||||
id: 'ver-1',
|
||||
bundleId: RELEASE.id,
|
||||
versionNumber: 7,
|
||||
digest: 'sha256:bundle-version',
|
||||
status: 'published',
|
||||
componentsCount: 1,
|
||||
changelog: null,
|
||||
createdAt: '2026-04-05T11:00:00Z',
|
||||
publishedAt: '2026-04-05T11:05:00Z',
|
||||
createdBy: 'qa',
|
||||
components: [
|
||||
{
|
||||
componentVersionId: 'checkout-api@2026.04.05',
|
||||
componentName: 'checkout-api',
|
||||
imageDigest: 'sha256:image',
|
||||
deployOrder: 10,
|
||||
metadataJson: '{}',
|
||||
},
|
||||
],
|
||||
})),
|
||||
createBundle: jasmine.createSpy('createBundle'),
|
||||
publishBundleVersion: jasmine.createSpy('publishBundleVersion'),
|
||||
materializeBundleVersion: jasmine.createSpy('materializeBundleVersion'),
|
||||
listBundles: jasmine.createSpy('listBundles'),
|
||||
};
|
||||
|
||||
deploymentApi = {
|
||||
createDeployment: jasmine.createSpy('createDeployment').and.returnValue(of({
|
||||
id: 'dep-123',
|
||||
releaseId: RELEASE.id,
|
||||
releaseName: RELEASE.name,
|
||||
releaseVersion: RELEASE.version,
|
||||
environmentId: 'env-prod-eu',
|
||||
environmentName: 'EU Production',
|
||||
status: 'pending',
|
||||
strategy: 'all_at_once',
|
||||
progress: 0,
|
||||
startedAt: '2026-04-05T11:10:00Z',
|
||||
completedAt: null,
|
||||
initiatedBy: 'qa',
|
||||
targetCount: 4,
|
||||
completedTargets: 0,
|
||||
failedTargets: 0,
|
||||
targets: [],
|
||||
currentStep: 'Queued for rollout',
|
||||
canPause: false,
|
||||
canResume: false,
|
||||
canCancel: true,
|
||||
canRollback: false,
|
||||
})),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreateDeploymentComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ReleaseManagementStore,
|
||||
useValue: {
|
||||
selectedRelease: releaseSignal,
|
||||
searchResults: searchResultsSignal,
|
||||
selectRelease: jasmine.createSpy('selectRelease').and.callFake((releaseId: string) => {
|
||||
releaseSignal.set(releaseId === RELEASE.id ? RELEASE : null);
|
||||
}),
|
||||
searchImages: jasmine.createSpy('searchImages'),
|
||||
clearSearchResults: jasmine.createSpy('clearSearchResults').and.callFake(() => searchResultsSignal.set([])),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
initialize: jasmine.createSpy('initialize'),
|
||||
regions: signal([
|
||||
{ regionId: 'eu', displayName: 'Europe', sortOrder: 1, enabled: true },
|
||||
]),
|
||||
environments: signal([
|
||||
{
|
||||
environmentId: 'env-prod-eu',
|
||||
regionId: 'eu',
|
||||
environmentType: 'production',
|
||||
displayName: 'EU Production',
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
{ provide: BundleOrganizerApi, useValue: bundleApi },
|
||||
{ provide: DEPLOYMENT_API, useValue: deploymentApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CreateDeploymentComponent);
|
||||
component = fixture.componentInstance;
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('loads live bundle versions for the linked release', () => {
|
||||
expect(component.linkedRelease()?.id).toBe(RELEASE.id);
|
||||
expect(bundleApi.listBundleVersions).toHaveBeenCalledWith(RELEASE.id, 50, 0);
|
||||
expect(bundleApi.getBundleVersion).toHaveBeenCalledWith(RELEASE.id, 'ver-1');
|
||||
expect(component.availableVersions().length).toBe(1);
|
||||
expect(component.availableVersions()[0].version).toBe('Version 7');
|
||||
});
|
||||
|
||||
it('submits a live deployment create request and navigates to the created deployment', () => {
|
||||
component.selectVersion(component.availableVersions()[0]);
|
||||
component.promotionStages[0].environmentId = 'env-prod-eu';
|
||||
component.promotionStages[1].environmentId = '';
|
||||
component.promotionStages[2].environmentId = '';
|
||||
component.deploymentStrategy = 'all_at_once';
|
||||
component.createConfirmed = true;
|
||||
|
||||
component.createDeployment();
|
||||
|
||||
expect(deploymentApi.createDeployment).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
releaseId: RELEASE.id,
|
||||
environmentId: 'env-prod-eu',
|
||||
environmentName: 'EU Production',
|
||||
strategy: 'all_at_once',
|
||||
packageType: 'version',
|
||||
packageRefId: 'ver-1',
|
||||
packageRefName: RELEASE.name,
|
||||
promotionStages: [{ name: 'Development', environmentId: 'env-prod-eu' }],
|
||||
}));
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/releases/deployments', 'dep-123'],
|
||||
{ queryParamsHandling: 'merge' },
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces backend errors instead of pretending deployment creation succeeded', () => {
|
||||
deploymentApi.createDeployment.and.returnValue(throwError(() => ({ status: 503 })));
|
||||
component.selectVersion(component.availableVersions()[0]);
|
||||
component.promotionStages[0].environmentId = 'env-prod-eu';
|
||||
component.promotionStages[1].environmentId = '';
|
||||
component.promotionStages[2].environmentId = '';
|
||||
component.createConfirmed = true;
|
||||
|
||||
component.createDeployment();
|
||||
|
||||
expect(component.submitError()).toContain('backend is unavailable');
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component';
|
||||
@@ -19,6 +19,12 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ deploymentId: 'DEP-UNIT-1' }),
|
||||
paramMap: of(convertToParamMap({ deploymentId: 'DEP-UNIT-1' })),
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ deploymentId: 'DEP-UNIT-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -79,6 +85,7 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||
releaseId: 'v1.2.5',
|
||||
returnTo: '/releases/deployments/DEP-UNIT-1',
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -113,6 +114,15 @@ describe('DeveloperWorkspaceComponent (developer workspace)', () => {
|
||||
providers: [
|
||||
{ provide: DeveloperWorkspaceService, useValue: workspaceSpy },
|
||||
{ provide: EvidenceRibbonService, useValue: evidenceSpy },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
|
||||
import { DoctorNotificationService } from '../../app/core/doctor/doctor-notification.service';
|
||||
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
|
||||
import { ToastService } from '../../app/core/services/toast.service';
|
||||
@@ -33,6 +34,7 @@ describe('DoctorNotificationService', () => {
|
||||
provideRouter([]),
|
||||
DoctorNotificationService,
|
||||
ToastService,
|
||||
{ provide: AuthSessionStore, useValue: { isAuthenticated: () => true } },
|
||||
{ provide: DOCTOR_API, useValue: mockApi },
|
||||
],
|
||||
});
|
||||
@@ -42,6 +44,7 @@ describe('DoctorNotificationService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
localStorage.removeItem('stellaops_doctor_last_seen_report');
|
||||
localStorage.removeItem('stellaops_doctor_notifications_muted');
|
||||
@@ -60,16 +63,16 @@ describe('DoctorNotificationService', () => {
|
||||
expect(service.muted()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not show toast when no reports exist', () => {
|
||||
it('should not show toast when no reports exist', async () => {
|
||||
spyOn(toastService, 'show');
|
||||
service.start();
|
||||
vi.advanceTimersByTime(10000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(mockApi.listReports).toHaveBeenCalled();
|
||||
expect(toastService.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show toast when new report has failures', () => {
|
||||
it('should show toast when new report has failures', async () => {
|
||||
const report = {
|
||||
runId: 'run-new',
|
||||
summary: { passed: 3, info: 0, warnings: 0, failed: 2, skipped: 0, total: 5 },
|
||||
@@ -78,17 +81,17 @@ describe('DoctorNotificationService', () => {
|
||||
|
||||
spyOn(toastService, 'show');
|
||||
service.start();
|
||||
vi.advanceTimersByTime(10000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(toastService.show).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
title: 'Doctor Run Complete',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show toast for same report twice', () => {
|
||||
it('should not show toast for same report twice', async () => {
|
||||
const report = {
|
||||
runId: 'run-1',
|
||||
summary: { passed: 3, info: 0, warnings: 1, failed: 0, skipped: 0, total: 4 },
|
||||
@@ -98,12 +101,12 @@ describe('DoctorNotificationService', () => {
|
||||
|
||||
spyOn(toastService, 'show');
|
||||
service.start();
|
||||
vi.advanceTimersByTime(10000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(toastService.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show toast for passing reports', () => {
|
||||
it('should not show toast for passing reports', async () => {
|
||||
const report = {
|
||||
runId: 'run-pass',
|
||||
summary: { passed: 5, info: 0, warnings: 0, failed: 0, skipped: 0, total: 5 },
|
||||
@@ -112,7 +115,7 @@ describe('DoctorNotificationService', () => {
|
||||
|
||||
spyOn(toastService, 'show');
|
||||
service.start();
|
||||
vi.advanceTimersByTime(10000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(toastService.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
|
||||
import { DoctorTrendService } from '../../app/core/doctor/doctor-trend.service';
|
||||
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
|
||||
import { DoctorTrendResponse } from '../../app/core/doctor/doctor-trend.models';
|
||||
@@ -43,6 +44,12 @@ describe('DoctorTrendService', () => {
|
||||
providers: [
|
||||
DoctorTrendService,
|
||||
{ provide: DOCTOR_API, useValue: mockApi },
|
||||
{
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
isAuthenticated: () => true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,35 +1,146 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
|
||||
import { EnvironmentDetailPageComponent } from '../../app/features/environments/environment-detail-page.component';
|
||||
import { EnvironmentsListPageComponent } from '../../app/features/environments/environments-list-page.component';
|
||||
import { TopologyDataService } from '../../app/features/topology/topology-data.service';
|
||||
import { TopologyOverviewPageComponent } from '../../app/features/topology/topology-overview-page.component';
|
||||
|
||||
describe('Environment management UI', () => {
|
||||
describe('EnvironmentsListPageComponent', () => {
|
||||
let fixture: ComponentFixture<EnvironmentsListPageComponent>;
|
||||
let component: EnvironmentsListPageComponent;
|
||||
describe('TopologyOverviewPageComponent', () => {
|
||||
let fixture: ComponentFixture<TopologyOverviewPageComponent>;
|
||||
let component: TopologyOverviewPageComponent;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
const topologyApi = {
|
||||
list: jasmine.createSpy('list').and.callFake((url: string) => {
|
||||
switch (url) {
|
||||
case '/api/v2/topology/regions':
|
||||
return of([
|
||||
{
|
||||
regionId: 'eu-west',
|
||||
displayName: 'EU West',
|
||||
sortOrder: 1,
|
||||
environmentCount: 2,
|
||||
targetCount: 3,
|
||||
hostCount: 2,
|
||||
agentCount: 1,
|
||||
lastSyncAt: null,
|
||||
},
|
||||
]);
|
||||
case '/api/v2/topology/environments':
|
||||
return of([
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'production',
|
||||
displayName: 'Production',
|
||||
sortOrder: 1,
|
||||
targetCount: 2,
|
||||
hostCount: 1,
|
||||
agentCount: 1,
|
||||
promotionPathCount: 1,
|
||||
workflowCount: 1,
|
||||
lastSyncAt: null,
|
||||
},
|
||||
]);
|
||||
case '/api/v2/topology/targets':
|
||||
return of([
|
||||
{
|
||||
targetId: 'target-1',
|
||||
name: 'api-prod',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'prod',
|
||||
hostId: 'host-1',
|
||||
agentId: 'agent-1',
|
||||
targetType: 'container',
|
||||
healthStatus: 'healthy',
|
||||
componentVersionId: 'api@1.0.0',
|
||||
imageDigest: 'sha256:111',
|
||||
releaseId: 'rel-1',
|
||||
releaseVersionId: 'rv-1',
|
||||
lastSyncAt: null,
|
||||
},
|
||||
]);
|
||||
case '/api/v2/topology/hosts':
|
||||
return of([]);
|
||||
case '/api/v2/topology/agents':
|
||||
return of([
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
agentName: 'agent-prod-1',
|
||||
regionId: 'eu-west',
|
||||
environmentId: 'prod',
|
||||
status: 'Active',
|
||||
capabilities: ['deploy'],
|
||||
assignedTargetCount: 2,
|
||||
lastHeartbeatAt: null,
|
||||
},
|
||||
]);
|
||||
case '/api/v2/topology/promotion-paths':
|
||||
return of([
|
||||
{
|
||||
pathId: 'path-1',
|
||||
regionId: 'eu-west',
|
||||
sourceEnvironmentId: 'stage',
|
||||
targetEnvironmentId: 'prod',
|
||||
pathMode: 'manual',
|
||||
status: 'running',
|
||||
requiredApprovals: 1,
|
||||
workflowId: 'wf-1',
|
||||
gateProfileId: 'gp-1',
|
||||
lastPromotedAt: null,
|
||||
},
|
||||
]);
|
||||
default:
|
||||
return of([]);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EnvironmentsListPageComponent],
|
||||
providers: [provideRouter([])],
|
||||
imports: [TopologyOverviewPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
initialize: jasmine.createSpy('initialize'),
|
||||
contextVersion: signal(0),
|
||||
regionSummary: () => 'All regions',
|
||||
environmentSummary: () => 'All environments',
|
||||
},
|
||||
},
|
||||
{ provide: TopologyDataService, useValue: topologyApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EnvironmentsListPageComponent);
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture = TestBed.createComponent(TopologyOverviewPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders environment cards and wires create action', () => {
|
||||
const cards = fixture.nativeElement.querySelectorAll('.env-card');
|
||||
expect(cards.length).toBe(4);
|
||||
it('renders environment overview cards and routes search hits into the canonical topology detail page', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Regions');
|
||||
expect(text).toContain('Environment Health');
|
||||
expect(text).toContain('Production');
|
||||
|
||||
const createSpy = spyOn(component, 'createEnvironment');
|
||||
const createButton = fixture.nativeElement.querySelector('.btn--primary') as HTMLButtonElement;
|
||||
createButton.click();
|
||||
component.searchQuery.set('prod');
|
||||
component.openFirstHit();
|
||||
|
||||
expect(createSpy).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/setup/topology/environments', 'prod', 'posture'],
|
||||
{ queryParamsHandling: 'merge' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +160,10 @@ describe('Environment management UI', () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: params$.asObservable(),
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -45,14 +45,7 @@ describe('EvidenceAuditOverviewComponent (evidence-audit)', () => {
|
||||
|
||||
it('supports degraded state banner', () => {
|
||||
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const degradedButton = (Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.mode-toggle button')
|
||||
) as HTMLElement[]).find((button) => button.textContent?.includes('Degraded')) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
degradedButton?.click();
|
||||
fixture.componentInstance.setMode('degraded');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement.textContent as string)).toContain('Evidence index is degraded');
|
||||
@@ -60,14 +53,7 @@ describe('EvidenceAuditOverviewComponent (evidence-audit)', () => {
|
||||
|
||||
it('supports deterministic empty quick views state', () => {
|
||||
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyButton = (Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.mode-toggle button')
|
||||
) as HTMLElement[]).find((button) => button.textContent?.includes('Empty')) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
emptyButton?.click();
|
||||
fixture.componentInstance.setMode('empty');
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
@@ -13,6 +13,9 @@ describe('EVIDENCE_ROUTES (pre-alpha)', () => {
|
||||
expect(paths).toEqual([
|
||||
'',
|
||||
'overview',
|
||||
'threads',
|
||||
'workspaces/auditor',
|
||||
'workspaces/developer',
|
||||
'capsules',
|
||||
'capsules/:capsuleId',
|
||||
'verify-replay',
|
||||
@@ -20,6 +23,8 @@ describe('EVIDENCE_ROUTES (pre-alpha)', () => {
|
||||
'exports',
|
||||
'proof-chain',
|
||||
'audit-log',
|
||||
'bundles',
|
||||
'bundles/new',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -29,9 +34,11 @@ describe('EVIDENCE_ROUTES (pre-alpha)', () => {
|
||||
);
|
||||
|
||||
expect(breadcrumbByPath.get('')).toBe('Overview');
|
||||
expect(breadcrumbByPath.get('threads')).toBe('Threads');
|
||||
expect(breadcrumbByPath.get('capsules')).toBe('Capsules');
|
||||
expect(breadcrumbByPath.get('verify-replay')).toBe('Verify & Replay');
|
||||
expect(breadcrumbByPath.get('verify-replay')).toBe('Replay & Verify');
|
||||
expect(breadcrumbByPath.get('audit-log')).toBe('Audit Log');
|
||||
expect(breadcrumbByPath.get('bundles/new')).toBe('Create Bundle');
|
||||
});
|
||||
|
||||
it('has no redirects', () => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../app/core/api/evidence.client';
|
||||
import { AUDIT_BUNDLES_API } from '../../app/core/api/audit-bundles.client';
|
||||
import { EvidencePanelMetricsService } from '../../app/core/analytics/evidence-panel-metrics.service';
|
||||
import { EvidenceCenterPageComponent } from '../../app/features/evidence/evidence-center-page.component';
|
||||
import { EvidencePanelComponent } from '../../app/features/evidence/evidence-panel.component';
|
||||
import { EvidenceStore } from '../../app/features/release-orchestrator/evidence/evidence.store';
|
||||
|
||||
describe('Evidence card UI export behavior', () => {
|
||||
describe('EvidenceCenterPageComponent', () => {
|
||||
@@ -12,9 +16,27 @@ describe('Evidence card UI export behavior', () => {
|
||||
let component: EvidenceCenterPageComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
const evidenceStore = {
|
||||
loading: signal(false).asReadonly(),
|
||||
error: signal<string | null>(null).asReadonly(),
|
||||
packets: signal([]).asReadonly(),
|
||||
selectedPacket: signal(null).asReadonly(),
|
||||
loadPackets: jasmine.createSpy('loadPackets'),
|
||||
loadPacket: jasmine.createSpy('loadPacket'),
|
||||
verifyEvidence: jasmine.createSpy('verifyEvidence'),
|
||||
downloadRaw: jasmine.createSpy('downloadRaw'),
|
||||
};
|
||||
const auditBundlesApi = {
|
||||
createBundle: jasmine.createSpy('createBundle').and.returnValue(of({ bundleId: 'bundle-1' })),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceCenterPageComponent],
|
||||
providers: [provideRouter([])],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EvidenceStore, useValue: evidenceStore },
|
||||
{ provide: AUDIT_BUNDLES_API, useValue: auditBundlesApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceCenterPageComponent);
|
||||
|
||||
@@ -1,10 +1,134 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
|
||||
import { AUDIT_BUNDLES_API } from '../../app/core/api/audit-bundles.client';
|
||||
import { RELEASE_EVIDENCE_API } from '../../app/core/api/release-evidence.client';
|
||||
import { EvidenceCenterPageComponent } from '../../app/features/evidence/evidence-center-page.component';
|
||||
import { EvidencePacketPageComponent } from '../../app/features/evidence/evidence-packet-page.component';
|
||||
|
||||
const EVIDENCE_PACKETS = [
|
||||
{
|
||||
id: 'EVD-2026-045',
|
||||
deploymentId: 'dep-045',
|
||||
releaseId: 'rel-045',
|
||||
releaseName: 'checkout-api',
|
||||
releaseVersion: '2026.04.05',
|
||||
environmentId: 'env-prod',
|
||||
environmentName: 'Production',
|
||||
status: 'complete',
|
||||
signatureStatus: 'valid',
|
||||
contentHash: 'sha256:bundle-045',
|
||||
signedAt: '2026-04-05T10:00:00Z',
|
||||
signedBy: 'release-bot',
|
||||
createdAt: '2026-04-05T10:00:00Z',
|
||||
size: 2048,
|
||||
contentTypes: ['promotion', 'attestation'],
|
||||
},
|
||||
{
|
||||
id: 'EVD-2026-044',
|
||||
deploymentId: 'dep-044',
|
||||
releaseId: 'rel-044',
|
||||
releaseName: 'payments-worker',
|
||||
releaseVersion: '2026.04.04',
|
||||
environmentId: 'env-stage',
|
||||
environmentName: 'Staging',
|
||||
status: 'complete',
|
||||
signatureStatus: 'valid',
|
||||
contentHash: 'sha256:bundle-044',
|
||||
signedAt: '2026-04-04T09:00:00Z',
|
||||
signedBy: 'release-bot',
|
||||
createdAt: '2026-04-04T09:00:00Z',
|
||||
size: 1024,
|
||||
contentTypes: ['deployment-log'],
|
||||
},
|
||||
{
|
||||
id: 'EVD-2026-043',
|
||||
deploymentId: 'dep-043',
|
||||
releaseId: 'rel-043',
|
||||
releaseName: 'gateway-hotfix',
|
||||
releaseVersion: 'hf-2026.04.03',
|
||||
environmentId: 'env-stage-eu',
|
||||
environmentName: 'EU Stage',
|
||||
status: 'complete',
|
||||
signatureStatus: 'unsigned',
|
||||
contentHash: 'sha256:bundle-043',
|
||||
signedAt: null,
|
||||
signedBy: null,
|
||||
createdAt: '2026-04-03T08:00:00Z',
|
||||
size: 900,
|
||||
contentTypes: ['scan-result'],
|
||||
},
|
||||
{
|
||||
id: 'EVD-2026-042',
|
||||
deploymentId: 'dep-042',
|
||||
releaseId: 'rel-042',
|
||||
releaseName: 'catalog-service',
|
||||
releaseVersion: '2026.04.02',
|
||||
environmentId: 'env-qa',
|
||||
environmentName: 'QA',
|
||||
status: 'complete',
|
||||
signatureStatus: 'valid',
|
||||
contentHash: 'sha256:bundle-042',
|
||||
signedAt: '2026-04-02T07:00:00Z',
|
||||
signedBy: 'release-bot',
|
||||
createdAt: '2026-04-02T07:00:00Z',
|
||||
size: 1536,
|
||||
contentTypes: ['deployment-log'],
|
||||
},
|
||||
{
|
||||
id: 'EVD-2026-041',
|
||||
deploymentId: 'dep-041',
|
||||
releaseId: 'rel-041',
|
||||
releaseName: 'checkout-api',
|
||||
releaseVersion: '2026.04.01',
|
||||
environmentId: 'env-dev',
|
||||
environmentName: 'Development',
|
||||
status: 'complete',
|
||||
signatureStatus: 'invalid',
|
||||
contentHash: 'sha256:bundle-041',
|
||||
signedAt: '2026-04-01T06:00:00Z',
|
||||
signedBy: 'release-bot',
|
||||
createdAt: '2026-04-01T06:00:00Z',
|
||||
size: 1100,
|
||||
contentTypes: ['promotion'],
|
||||
},
|
||||
];
|
||||
|
||||
const EVIDENCE_PACKET_DETAIL = {
|
||||
...EVIDENCE_PACKETS[0],
|
||||
content: {
|
||||
metadata: {
|
||||
deploymentId: 'dep-045',
|
||||
releaseId: 'rel-045',
|
||||
environmentId: 'env-prod',
|
||||
startedAt: '2026-04-05T09:55:00Z',
|
||||
completedAt: '2026-04-05T10:00:00Z',
|
||||
initiatedBy: 'release-bot',
|
||||
outcome: 'success',
|
||||
},
|
||||
release: {
|
||||
name: 'checkout-api',
|
||||
version: '2026.04.05',
|
||||
components: [{ name: 'checkout-api', digest: 'sha256:image-045', version: '2026.04.05' }],
|
||||
},
|
||||
workflow: { id: 'wf-045', name: 'Promote', version: 1, stepsExecuted: 3, stepsFailed: 0 },
|
||||
targets: [{ id: 't-045', name: 'prod-01', type: 'docker_host', outcome: 'success', duration: 5000 }],
|
||||
approvals: [],
|
||||
gateResults: [],
|
||||
artifacts: [{ name: 'manifest.json', type: 'manifest', digest: 'sha256:manifest-045', size: 512 }],
|
||||
},
|
||||
signature: {
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-045',
|
||||
signature: 'sig-045',
|
||||
signedAt: '2026-04-05T10:00:00Z',
|
||||
signedBy: 'release-bot',
|
||||
certificate: null,
|
||||
},
|
||||
verificationResult: null,
|
||||
};
|
||||
|
||||
describe('Evidence center hub components', () => {
|
||||
describe('EvidenceCenterPageComponent', () => {
|
||||
let fixture: ComponentFixture<EvidenceCenterPageComponent>;
|
||||
@@ -13,7 +137,36 @@ describe('Evidence center hub components', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceCenterPageComponent],
|
||||
providers: [provideRouter([])],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: RELEASE_EVIDENCE_API,
|
||||
useValue: {
|
||||
getEvidencePackets: () => of({ items: EVIDENCE_PACKETS, total: EVIDENCE_PACKETS.length, page: 1, pageSize: 20 }),
|
||||
getEvidencePacket: () => of(EVIDENCE_PACKET_DETAIL),
|
||||
getTimeline: () => of([]),
|
||||
verifyEvidence: () => of({
|
||||
valid: true,
|
||||
message: 'ok',
|
||||
details: {
|
||||
signatureValid: true,
|
||||
contentHashValid: true,
|
||||
certificateValid: true,
|
||||
timestampValid: true,
|
||||
},
|
||||
verifiedAt: '2026-04-05T10:01:00Z',
|
||||
}),
|
||||
exportEvidence: () => of(new Blob()),
|
||||
downloadRaw: () => of(new Blob()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AUDIT_BUNDLES_API,
|
||||
useValue: {
|
||||
createBundle: () => of({ bundleId: 'audit-045' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceCenterPageComponent);
|
||||
@@ -38,6 +191,7 @@ describe('Evidence center hub components', () => {
|
||||
|
||||
it('wires drawer-open and download actions from row buttons', () => {
|
||||
const downloadSpy = spyOn(component, 'downloadPacket');
|
||||
downloadSpy.and.stub();
|
||||
|
||||
fixture.detectChanges();
|
||||
const actionButtons = fixture.nativeElement.querySelectorAll('tbody tr .action-buttons .btn');
|
||||
@@ -57,10 +211,24 @@ describe('Evidence center hub components', () => {
|
||||
let component: EvidencePacketPageComponent;
|
||||
let params$: BehaviorSubject<Record<string, string>>;
|
||||
let queryParams$: BehaviorSubject<Record<string, string>>;
|
||||
let routeStub: {
|
||||
params: ReturnType<typeof params$['asObservable']>;
|
||||
queryParams: ReturnType<typeof queryParams$['asObservable']>;
|
||||
snapshot: {
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
params$ = new BehaviorSubject<Record<string, string>>({ packetId: 'EVD-2026-045' });
|
||||
queryParams$ = new BehaviorSubject<Record<string, string>>({ tab: 'summary' });
|
||||
routeStub = {
|
||||
params: params$.asObservable(),
|
||||
queryParams: queryParams$.asObservable(),
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({ tab: 'summary' }),
|
||||
},
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePacketPageComponent],
|
||||
@@ -68,10 +236,7 @@ describe('Evidence center hub components', () => {
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: params$.asObservable(),
|
||||
queryParams: queryParams$.asObservable(),
|
||||
},
|
||||
useValue: routeStub,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -83,6 +248,7 @@ describe('Evidence center hub components', () => {
|
||||
|
||||
it('reacts to packet id and tab query param changes', () => {
|
||||
params$.next({ packetId: 'EVD-2026-999' });
|
||||
routeStub.snapshot.queryParamMap = convertToParamMap({ tab: 'proof-chain' });
|
||||
queryParams$.next({ tab: 'proof-chain' });
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -97,6 +263,8 @@ describe('Evidence center hub components', () => {
|
||||
|
||||
const exportSpy = spyOn(component, 'exportProofChain');
|
||||
const verifySpy = spyOn(component, 'verifyChain');
|
||||
exportSpy.and.stub();
|
||||
verifySpy.and.stub();
|
||||
|
||||
const actionButtons = fixture.nativeElement.querySelectorAll('.proof-actions .btn');
|
||||
expect(actionButtons.length).toBe(2);
|
||||
|
||||
@@ -145,6 +145,7 @@ describe('Evidence thread browser', () => {
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], {
|
||||
queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ExportCenterHttpClient,
|
||||
} from '../../app/core/api/export-center.client';
|
||||
import type { ExportRunEvent } from '../../app/core/api/export-center.models';
|
||||
import { StellaOpsHeaders } from '../../app/core/http/stella-ops-headers';
|
||||
|
||||
describe('web-gateway-export-center-client behavior', () => {
|
||||
let client: ExportCenterHttpClient;
|
||||
@@ -64,9 +65,9 @@ describe('web-gateway-export-center-client behavior', () => {
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.params.get('pageToken')).toBe('token-1');
|
||||
expect(req.request.params.get('pageSize')).toBe('25');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-list-1');
|
||||
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-list-1');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-default');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-list-1');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.RequestId)).toBe('trace-list-1');
|
||||
expect(tenantService.authorize).toHaveBeenCalledWith(
|
||||
'export',
|
||||
'read',
|
||||
@@ -138,8 +139,8 @@ describe('web-gateway-export-center-client behavior', () => {
|
||||
const req = httpMock.expectOne('/api/export-center/runs');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.headers.get('Idempotency-Key')).toBe('idem-123');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-override');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-run-ok');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-override');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-run-ok');
|
||||
expect(req.request.body.profileId).toBe('profile-1');
|
||||
expect(req.request.body.retentionDays).toBe(14);
|
||||
expect(req.request.body.encryption.enabled).toBeTrue();
|
||||
@@ -255,8 +256,8 @@ describe('web-gateway-export-center-client behavior', () => {
|
||||
|
||||
const req = httpMock.expectOne('/api/export-center/distributions/distribution%2Fa%2Bb');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-dist-1');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-default');
|
||||
expect(req.request.headers.get(StellaOpsHeaders.TraceId)).toBe('trace-dist-1');
|
||||
req.flush({ message: 'upstream unavailable' }, { status: 503, statusText: 'Service Unavailable' });
|
||||
|
||||
expect(errorMessage).toContain('[trace-dist-1] Export Center error');
|
||||
|
||||
@@ -865,7 +865,7 @@ describe('GlobalSearchComponent', () => {
|
||||
}));
|
||||
expect(assistantDrawer.open).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
initialUserMessage: jasmine.stringMatching(/searched for "mismatch"/i),
|
||||
source: 'global_search_entry',
|
||||
source: 'global_search',
|
||||
}));
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('GraphExportService (graph_export)', () => {
|
||||
it('uses deterministic defaults for SVG exports including background, legend, and metadata', () => {
|
||||
const svg = service.exportToSvg(createDiff());
|
||||
|
||||
expect(svg).toContain('style="background: #1C1200"');
|
||||
expect(svg).toContain('style="background: var(--color-surface-inverse)"');
|
||||
expect(svg).toContain('Added (+)');
|
||||
expect(svg).toContain('Base: sha256:base-0123...');
|
||||
expect(svg).toContain('Head: sha256:head-0123...');
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('GraphCanvasComponent (graph_reachability_overlay)', () => {
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders reachability halo using lattice-state color mapping', () => {
|
||||
it('renders overlay halo using live policy overlay mapping', () => {
|
||||
component.nodes = [
|
||||
{
|
||||
id: 'comp-log4j',
|
||||
@@ -26,49 +26,52 @@ describe('GraphCanvasComponent (graph_reachability_overlay)', () => {
|
||||
},
|
||||
];
|
||||
component.edges = [];
|
||||
component.overlayState = createOverlayState('comp-log4j', 'SR');
|
||||
component.overlayState = createOverlayState('comp-log4j');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const halo = fixture.nativeElement.querySelector('.reachability-halo') as SVGRectElement | null;
|
||||
const halo = fixture.nativeElement.querySelector('.overlay-halo') as SVGRectElement | null;
|
||||
expect(halo).not.toBeNull();
|
||||
expect(halo?.getAttribute('stroke')).toBe('#16a34a');
|
||||
expect(halo?.getAttribute('stroke')).toBe('#d97706');
|
||||
|
||||
const title = halo?.querySelector('title');
|
||||
expect(title?.textContent ?? '').toContain('SR');
|
||||
expect(title?.textContent ?? '').toContain('Policy warn');
|
||||
});
|
||||
|
||||
it('exposes deterministic halo colors for each lattice state', () => {
|
||||
expect(component.getReachabilityHaloStroke('SR')).toBe('#16a34a');
|
||||
expect(component.getReachabilityHaloStroke('SU')).toBe('#65a30d');
|
||||
expect(component.getReachabilityHaloStroke('RO')).toBe('#0284c7');
|
||||
expect(component.getReachabilityHaloStroke('RU')).toBe('#0ea5e9');
|
||||
expect(component.getReachabilityHaloStroke('CR')).toBe('#f59e0b');
|
||||
expect(component.getReachabilityHaloStroke('CU')).toBe('#f97316');
|
||||
expect(component.getReachabilityHaloStroke('X')).toBe('#94a3b8');
|
||||
it('prioritizes policy overlays ahead of vex and aoc overlays', () => {
|
||||
component.overlayState = {
|
||||
policy: new Map([
|
||||
['comp-log4j', { nodeId: 'comp-log4j', badge: 'fail', policyId: 'policy://comp-log4j' }],
|
||||
]),
|
||||
vex: new Map([
|
||||
['comp-log4j', { nodeId: 'comp-log4j', state: 'not_affected', statementId: 'vex://comp-log4j' }],
|
||||
]),
|
||||
aoc: new Map([
|
||||
['comp-log4j', { nodeId: 'comp-log4j', status: 'pass' }],
|
||||
]),
|
||||
};
|
||||
|
||||
expect(component.getOverlayIndicator('comp-log4j')).toEqual({
|
||||
stroke: '#dc2626',
|
||||
summary: 'Policy fail',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createOverlayState(
|
||||
nodeId: string,
|
||||
latticeState: 'SR' | 'SU' | 'RO' | 'RU' | 'CR' | 'CU' | 'X'
|
||||
): GraphOverlayState {
|
||||
function createOverlayState(nodeId: string): GraphOverlayState {
|
||||
return {
|
||||
policy: new Map(),
|
||||
evidence: new Map(),
|
||||
license: new Map(),
|
||||
exposure: new Map(),
|
||||
reachability: new Map([
|
||||
policy: new Map([
|
||||
[
|
||||
nodeId,
|
||||
{
|
||||
nodeId,
|
||||
latticeState,
|
||||
status: latticeState === 'X' ? 'unknown' : 'reachable',
|
||||
confidence: 0.9,
|
||||
observedAt: '2025-12-12T00:00:00.000Z',
|
||||
badge: 'warn',
|
||||
policyId: `policy://${nodeId}`,
|
||||
verdictAt: '2026-04-04T10:00:00Z',
|
||||
},
|
||||
],
|
||||
]),
|
||||
vex: new Map(),
|
||||
aoc: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user