Add advisory source catalog UI, mirror wizard, and mirror dashboard
Source catalog component: browsable catalog of 75 advisory sources grouped by 14 categories with search, filter, enable/disable toggles, batch operations, health checks, and category descriptions. Mirror domain builder: 3-step wizard (select sources → configure domain → review & create) with category-level selection, auto-naming, format choice, rate limits, signing options, and optional immediate generation. Mirror dashboard: domain cards with staleness indicators, regenerate and delete actions, consumer config panel, endpoint viewer, and empty-state CTA leading to the wizard. Catalog mirror header: mode badge, domain stats, and quick-access buttons for mirror configuration integrated into the source catalog. Supporting: source management API client (9 endpoints), mirror management API client (12 endpoints), integration hub route wiring, onboarding hub advisory section, security page health display fix, E2E Playwright tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,30 @@ public sealed class MirrorDistributionOptions
|
||||
{
|
||||
public const string SectionName = "Excititor:Mirror";
|
||||
|
||||
/// <summary>
|
||||
/// All source categories recognized by the mirror export system. This list must stay
|
||||
/// in sync with <c>SourceCategory</c> in Concelier.Core. Used by the
|
||||
/// <c>sourceCategory</c> filter shorthand in <see cref="MirrorExportOptions.ResolveFilters"/>.
|
||||
/// Operators can specify one or more comma-separated values from this set.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> SupportedCategories = new[]
|
||||
{
|
||||
"Primary",
|
||||
"Vendor",
|
||||
"Distribution",
|
||||
"Ecosystem",
|
||||
"Cert",
|
||||
"Csaf",
|
||||
"Threat",
|
||||
"Exploit",
|
||||
"Container",
|
||||
"Hardware",
|
||||
"Ics",
|
||||
"PackageManager",
|
||||
"Mirror",
|
||||
"Other",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Global enable flag for mirror distribution surfaces and bundle generation.
|
||||
/// </summary>
|
||||
@@ -94,6 +118,12 @@ public sealed class MirrorExportOptions
|
||||
/// into normalized multi-value lists. Source definitions are required for resolving
|
||||
/// <c>sourceCategory</c> and <c>sourceTag</c> shorthands; pass <c>null</c> when
|
||||
/// category/tag expansion is not needed.
|
||||
/// <para>
|
||||
/// Both <c>sourceCategory</c> and <c>sourceTag</c> accept comma-separated values,
|
||||
/// e.g. <c>"Exploit,Container,Ics,PackageManager"</c>. All matching source IDs are
|
||||
/// merged into the resolved <c>sourceVendor</c> list.
|
||||
/// See <see cref="MirrorDistributionOptions.SupportedCategories"/> for valid category names.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="sourceDefinitions">
|
||||
/// Optional catalog of source definitions used to resolve <c>sourceCategory</c> and
|
||||
@@ -116,9 +146,13 @@ public sealed class MirrorExportOptions
|
||||
|
||||
if (key.Equals("sourceCategory", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null)
|
||||
{
|
||||
// Resolve category to source IDs
|
||||
// Resolve one or more comma-separated categories to source IDs.
|
||||
// Supports both single ("Exploit") and multi-value ("Exploit,Container,Ics,PackageManager").
|
||||
var categories = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var categorySet = new HashSet<string>(categories, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchingIds = sourceDefinitions
|
||||
.Where(s => s.Category.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(s => categorySet.Contains(s.Category))
|
||||
.Select(s => s.Id)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -130,9 +164,12 @@ public sealed class MirrorExportOptions
|
||||
}
|
||||
else if (key.Equals("sourceTag", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null)
|
||||
{
|
||||
// Resolve tag to source IDs
|
||||
// Resolve one or more comma-separated tags to source IDs.
|
||||
var tags = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var tagSet = new HashSet<string>(tags, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchingIds = sourceDefinitions
|
||||
.Where(s => s.Tags.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
.Where(s => s.Tags.Any(t => tagSet.Contains(t)))
|
||||
.Select(s => s.Id)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Advisory & VEX Source Management — E2E Tests
|
||||
*
|
||||
* Verifies the source catalog UI, enable/disable toggles, connectivity checks,
|
||||
* onboarding hub section, and security page health display.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: mock API responses for deterministic E2E
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CATALOG = {
|
||||
items: [
|
||||
{ id: 'nvd', displayName: 'NVD', category: 'Primary', type: 'Upstream', description: 'NIST National Vulnerability Database', baseEndpoint: 'https://services.nvd.nist.gov', requiresAuth: true, credentialEnvVar: 'NVD_API_KEY', credentialUrl: 'https://nvd.nist.gov/developers/request-an-api-key', documentationUrl: 'https://nvd.nist.gov/developers', defaultPriority: 10, regions: [], tags: ['cve'], enabledByDefault: true },
|
||||
{ id: 'osv', displayName: 'OSV', category: 'Primary', type: 'Upstream', description: 'Open Source Vulnerabilities', baseEndpoint: 'https://api.osv.dev', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: 'https://osv.dev', defaultPriority: 20, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'ghsa', displayName: 'GitHub Security Advisories', category: 'Primary', type: 'Upstream', description: 'GitHub Advisory Database', baseEndpoint: 'https://api.github.com', requiresAuth: true, credentialEnvVar: 'GITHUB_TOKEN', credentialUrl: null, documentationUrl: null, defaultPriority: 30, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'redhat', displayName: 'Red Hat Security', category: 'Vendor', type: 'Upstream', description: 'Red Hat Product Security advisories', baseEndpoint: 'https://access.redhat.com', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 50, regions: [], tags: ['vendor'], enabledByDefault: true },
|
||||
{ id: 'debian', displayName: 'Debian Security Tracker', category: 'Distribution', type: 'Upstream', description: 'Debian Security Bug Tracker', baseEndpoint: 'https://security-tracker.debian.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 60, regions: [], tags: ['distro'], enabledByDefault: true },
|
||||
{ id: 'ubuntu', displayName: 'Ubuntu Security', category: 'Distribution', type: 'Upstream', description: 'Ubuntu CVE Tracker', baseEndpoint: 'https://ubuntu.com/security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 61, regions: [], tags: ['distro'], enabledByDefault: false },
|
||||
{ id: 'npm-audit', displayName: 'npm Audit', category: 'Ecosystem', type: 'Upstream', description: 'npm advisory feed', baseEndpoint: 'https://registry.npmjs.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 70, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'certfr', displayName: 'CERT-FR', category: 'Cert', type: 'Upstream', description: 'French national CERT advisories', baseEndpoint: 'https://www.cert.ssi.gouv.fr', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 80, regions: ['eu'], tags: ['cert'], enabledByDefault: false },
|
||||
{ id: 'csaf-aggregator', displayName: 'CSAF Trusted Provider', category: 'Csaf', type: 'Upstream', description: 'OASIS CSAF trusted provider', baseEndpoint: 'https://csaf.data.security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 90, regions: [], tags: ['csaf'], enabledByDefault: true },
|
||||
{ id: 'kev', displayName: 'CISA KEV', category: 'Threat', type: 'Upstream', description: 'Known Exploited Vulnerabilities catalog', baseEndpoint: 'https://www.cisa.gov', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 15, regions: ['us'], tags: ['exploit'], enabledByDefault: true },
|
||||
{ id: 'stella-mirror', displayName: 'StellaOps Mirror', category: 'Mirror', type: 'Mirror', description: 'Local offline mirror', baseEndpoint: 'http://mirror.local', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 100, regions: [], tags: ['mirror'], enabledByDefault: false },
|
||||
],
|
||||
totalCount: 11,
|
||||
};
|
||||
|
||||
const MOCK_STATUS = {
|
||||
sources: MOCK_CATALOG.items.map((s) => ({
|
||||
sourceId: s.id,
|
||||
enabled: s.enabledByDefault,
|
||||
lastCheck: s.enabledByDefault
|
||||
? { sourceId: s.id, status: 'Healthy', checkedAt: new Date().toISOString(), latency: '00:00:00.1500000', isHealthy: true, possibleReasons: [], remediationSteps: [] }
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const MOCK_ADVISORY_SOURCES = {
|
||||
items: [
|
||||
{ sourceId: 'aaa-1111', sourceKey: 'nvd', sourceName: 'NVD', sourceFamily: 'primary', sourceUrl: null, priority: 10, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 600, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 42, errorCount: 0, totalAdvisories: 1000, signedAdvisories: 0, unsignedAdvisories: 1000, signatureFailureCount: 0 },
|
||||
{ sourceId: 'bbb-2222', sourceKey: 'osv', sourceName: 'OSV', sourceFamily: 'primary', sourceUrl: null, priority: 20, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 1200, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 30, errorCount: 0, totalAdvisories: 500, signedAdvisories: 0, unsignedAdvisories: 500, signatureFailureCount: 0 },
|
||||
{ sourceId: 'ccc-3333', sourceKey: 'ghsa', sourceName: 'GitHub Advisory DB', sourceFamily: 'primary', sourceUrl: null, priority: 30, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 7200, freshnessSlaSeconds: 3600, freshnessStatus: 'warning', signatureStatus: 'unsigned', lastError: null, syncCount: 10, errorCount: 1, totalAdvisories: 200, signedAdvisories: 0, unsignedAdvisories: 200, signatureFailureCount: 0 },
|
||||
],
|
||||
totalCount: 3,
|
||||
dataAsOf: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function setupSourceApiMocks(page: import('@playwright/test').Page) {
|
||||
// Source management API mocks
|
||||
page.route('**/api/v1/sources/catalog', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CATALOG) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/status', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/enable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/disable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalChecked: 11, healthyCount: 8, failedCount: 3 }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const url = route.request().url();
|
||||
const sourceId = url.split('/sources/')[1]?.split('/check')[0] ?? 'unknown';
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId,
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.0850000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check-result', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId: 'nvd',
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.1000000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-enable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'ubuntu', success: true }, { sourceId: 'certfr', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-disable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'nvd', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Advisory sources API mock (for security page)
|
||||
page.route('**/api/v1/advisory-sources?*', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ADVISORY_SOURCES) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/advisory-sources/summary', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalSources: 3, healthySources: 2, warningSources: 1, staleSources: 0, unavailableSources: 0, disabledSources: 0, conflictingSources: 0, dataAsOf: new Date().toISOString() }),
|
||||
});
|
||||
});
|
||||
|
||||
// Integration service provider catalog
|
||||
page.route('**/api/v1/integrations/providers', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ provider: 'Harbor', name: 'Harbor', type: 'Registry' },
|
||||
{ provider: 'GitHubApp', name: 'GitHub App', type: 'Scm' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all for other security/integration endpoints
|
||||
page.route('**/api/v2/security/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], table: [] }) });
|
||||
});
|
||||
|
||||
page.route('**/api/v2/integrations/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
}
|
||||
|
||||
function setupErrorCollector(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Source catalog renders with categories and source rows
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Advisory Source Catalog', () => {
|
||||
test('renders catalog with category sections and source rows', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify page heading
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toContain('advisory');
|
||||
|
||||
// Verify category sections exist
|
||||
for (const category of ['Primary', 'Vendor', 'Distribution', 'Ecosystem', 'Cert', 'Csaf', 'Threat', 'Mirror']) {
|
||||
const categoryText = await page.locator('body').innerText();
|
||||
expect(categoryText).toContain(category);
|
||||
}
|
||||
|
||||
// Verify source names render
|
||||
expect(body).toContain('NVD');
|
||||
expect(body).toContain('OSV');
|
||||
expect(body).toContain('GitHub Security Advisories');
|
||||
expect(body).toContain('Red Hat Security');
|
||||
|
||||
// Verify stats bar shows counts
|
||||
expect(body).toMatch(/\d+\s*(enabled|healthy|failed)/i);
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('enable/disable toggle updates source state', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a toggle/button for a disabled source (ubuntu)
|
||||
const ubuntuRow = page.locator('text=Ubuntu Security').locator('..');
|
||||
await expect(ubuntuRow).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Look for a toggle or enable button in the row
|
||||
const toggle = ubuntuRow.locator('button, input[type="checkbox"], .toggle').first();
|
||||
if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('check button shows connectivity result', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a "Check" button on a source row
|
||||
const checkBtn = page.locator('button').filter({ hasText: /check/i }).first();
|
||||
if (await checkBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify status badge appears after check
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toMatch(/healthy|degraded|failed|unchecked/);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Check All button triggers bulk connectivity check', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const checkAllBtn = page.locator('button').filter({ hasText: /check all/i }).first();
|
||||
if (await checkAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkAllBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Enable All for a category enables all sources in that category', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find an "Enable All" button within a category section
|
||||
const enableAllBtn = page.locator('button').filter({ hasText: /enable all/i }).first();
|
||||
if (await enableAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await enableAllBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Onboarding hub shows Advisory & VEX Sources section
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Onboarding Hub - Advisory Sources', () => {
|
||||
test('shows Advisory & VEX Sources section with Configure button', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/setup/integrations/onboarding', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toContain('Advisory & VEX Sources');
|
||||
|
||||
// Verify the "Configure Sources" button exists
|
||||
const configureBtn = page.locator('button').filter({ hasText: /configure sources/i });
|
||||
await expect(configureBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click it and verify navigation to catalog
|
||||
await configureBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
expect(page.url()).toContain('advisory-vex-sources');
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Security page shows real source names with status
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Security Page - Advisory Health', () => {
|
||||
test('Advisories & VEX Health shows real source names', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/security', { timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
|
||||
// Verify the section exists
|
||||
expect(body).toContain('Advisories & VEX Health');
|
||||
|
||||
// Verify real source names from mock (not "offline - unknown")
|
||||
const healthSection = page.locator('text=Advisories & VEX Health').locator('..').locator('..');
|
||||
const healthText = await healthSection.innerText().catch(() => body);
|
||||
|
||||
// Should contain real source names from the advisory-sources API
|
||||
const hasRealSources = healthText.includes('NVD') || healthText.includes('OSV') || healthText.includes('GitHub');
|
||||
const hasOfflineUnknown = (healthText.match(/offline.*unknown/gi) || []).length;
|
||||
|
||||
// We expect real sources OR at least no "offline - unknown" for all entries
|
||||
expect(hasRealSources || hasOfflineUnknown === 0).toBeTruthy();
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* Integration Hub Routes
|
||||
* Updated: SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck
|
||||
* Updated: Sprint 023 - Registry admin mounted under integrations (FE-URG-002)
|
||||
* Updated: Sprint 007 - Mirror dashboard route (TASK-007b)
|
||||
*
|
||||
* Canonical Integrations taxonomy:
|
||||
* '' - Hub overview with health summary and category navigation
|
||||
@@ -82,9 +83,23 @@ export const integrationHubRoutes: Routes = [
|
||||
{
|
||||
path: 'advisory-vex-sources',
|
||||
title: 'Advisory & VEX Sources',
|
||||
data: { breadcrumb: 'Advisory & VEX Sources', type: 'FeedMirror' },
|
||||
data: { breadcrumb: 'Advisory & VEX Sources' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
import('../integrations/advisory-vex-sources/advisory-source-catalog.component').then((m) => m.AdvisorySourceCatalogComponent),
|
||||
},
|
||||
{
|
||||
path: 'advisory-vex-sources/mirror',
|
||||
title: 'Mirror Dashboard',
|
||||
data: { breadcrumb: 'Mirror Dashboard' },
|
||||
loadComponent: () =>
|
||||
import('../integrations/advisory-vex-sources/mirror-dashboard.component').then((m) => m.MirrorDashboardComponent),
|
||||
},
|
||||
{
|
||||
path: 'advisory-vex-sources/mirror/new',
|
||||
title: 'Create Mirror Domain',
|
||||
data: { breadcrumb: 'Create Mirror Domain' },
|
||||
loadComponent: () =>
|
||||
import('../integrations/advisory-vex-sources/mirror-domain-builder.component').then((m) => m.MirrorDomainBuilderComponent),
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,785 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
MirrorManagementApi,
|
||||
MirrorConfigResponse,
|
||||
MirrorDomainResponse,
|
||||
MirrorDomainEndpointDto,
|
||||
MirrorHealthSummary,
|
||||
} from './mirror-management.api';
|
||||
|
||||
// ── Local helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function domainStaleness(domain: MirrorDomainResponse): 'fresh' | 'stale' | 'never' {
|
||||
if (!domain.createdAt) return 'never';
|
||||
const age = Date.now() - new Date(domain.createdAt).getTime();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
return age > oneDay ? 'stale' : 'fresh';
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Component({
|
||||
selector: 'app-mirror-dashboard',
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mirror-dashboard">
|
||||
<!-- Top bar -->
|
||||
<header class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h1>Mirror Dashboard</h1>
|
||||
<p>Manage advisory mirror domains, monitor health, and control distribution.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@if (config()) {
|
||||
<span class="mode-badge" [class]="'mode-badge--' + config()!.mode.toLowerCase()">
|
||||
{{ config()!.mode }}
|
||||
</span>
|
||||
}
|
||||
<button class="btn btn-primary" type="button" (click)="onCreateDomain()">
|
||||
Create Domain
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="banner">Loading mirror configuration...</div>
|
||||
} @else {
|
||||
<!-- Health summary -->
|
||||
@if (health()) {
|
||||
<div class="health-bar">
|
||||
<span class="health-stat">
|
||||
<strong>{{ health()!.totalDomains }}</strong> domains
|
||||
</span>
|
||||
<span class="health-stat health-stat--fresh">
|
||||
<strong>{{ health()!.freshCount }}</strong> fresh
|
||||
</span>
|
||||
<span class="health-stat health-stat--stale">
|
||||
<strong>{{ health()!.staleCount }}</strong> stale
|
||||
</span>
|
||||
<span class="health-stat health-stat--never">
|
||||
<strong>{{ health()!.neverGeneratedCount }}</strong> never generated
|
||||
</span>
|
||||
<span class="health-stat">
|
||||
<strong>{{ health()!.totalAdvisoryCount }}</strong> advisories in bundles
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Consumer config panel (Hybrid/Mirror only) -->
|
||||
@if (showConsumerPanel()) {
|
||||
<section class="consumer-panel">
|
||||
<h3>Consumer Mirror Connection</h3>
|
||||
<div class="consumer-grid">
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Mirror URL</span>
|
||||
<span class="consumer-value code">{{ config()!.consumerMirrorUrl ?? 'Not configured' }}</span>
|
||||
</div>
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Connection Status</span>
|
||||
<span class="consumer-value"
|
||||
[class]="config()!.consumerConnected ? 'consumer-value status--connected' : 'consumer-value status--disconnected'">
|
||||
{{ config()!.consumerConnected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (config()!.lastConsumerSync) {
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Last Sync</span>
|
||||
<span class="consumer-value">{{ config()!.lastConsumerSync }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Domain cards -->
|
||||
@if (domains().length === 0) {
|
||||
<section class="empty-state">
|
||||
<div class="empty-icon">⚙</div>
|
||||
<h2>Create your first mirror domain</h2>
|
||||
<p>Mirror domains bundle advisory data from enabled sources for offline distribution or downstream consumption.</p>
|
||||
<button class="btn btn-primary" type="button" (click)="onCreateDomain()">
|
||||
Create Mirror Domain
|
||||
</button>
|
||||
</section>
|
||||
} @else {
|
||||
<div class="domain-grid">
|
||||
@for (domain of domains(); track domain.id) {
|
||||
<div class="domain-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-group">
|
||||
<h3 class="card-title">{{ domain.displayName }}</h3>
|
||||
<span class="card-id">{{ domain.domainId }}</span>
|
||||
</div>
|
||||
<span class="staleness-badge" [class]="'staleness-badge staleness--' + getDomainStaleness(domain)">
|
||||
{{ getDomainStaleness(domain) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-stats">
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Exports</span>
|
||||
<span class="card-stat-value">{{ domain.sourceIds.length }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Format</span>
|
||||
<span class="card-stat-value">{{ domain.exportFormat }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Sources</span>
|
||||
<span class="card-stat-value">{{ domain.sourceIds.length }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Created</span>
|
||||
<span class="card-stat-value">{{ formatTimestamp(domain.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Status</span>
|
||||
<span class="card-stat-value">{{ domain.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (domain.sourceIds.length > 0) {
|
||||
<div class="card-sources">
|
||||
@for (sid of domain.sourceIds.slice(0, 5); track sid) {
|
||||
<span class="source-pill">{{ sid }}</span>
|
||||
}
|
||||
@if (domain.sourceIds.length > 5) {
|
||||
<span class="source-pill source-pill--more">+{{ domain.sourceIds.length - 5 }} more</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Endpoints panel (expandable) -->
|
||||
@if (expandedEndpoints() === domain.id) {
|
||||
<div class="card-endpoints">
|
||||
<h4>Endpoints</h4>
|
||||
@if (domainEndpoints().length === 0) {
|
||||
<p class="endpoints-empty">Loading endpoints...</p>
|
||||
} @else {
|
||||
@for (ep of domainEndpoints(); track ep.path) {
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-method">{{ ep.method }}</span>
|
||||
<span class="endpoint-path code">{{ ep.path }}</span>
|
||||
<span class="endpoint-desc">{{ ep.description }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-sm btn-primary" type="button"
|
||||
[disabled]="regeneratingId() === domain.id"
|
||||
(click)="onRegenerate(domain.id)">
|
||||
@if (regeneratingId() === domain.id) {
|
||||
Regenerating...
|
||||
} @else {
|
||||
Regenerate
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm" type="button" (click)="onViewEndpoints(domain.id)">
|
||||
@if (expandedEndpoints() === domain.id) {
|
||||
Hide Endpoints
|
||||
} @else {
|
||||
View Endpoints
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm" type="button" (click)="onEditDomain(domain.id)">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button"
|
||||
[disabled]="deletingId() === domain.id"
|
||||
(click)="onDeleteDomain(domain.id, domain.displayName)">
|
||||
@if (deletingId() === domain.id) {
|
||||
Deleting...
|
||||
} @else {
|
||||
Delete
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.mirror-dashboard {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* -- Header ----------------------------------------------------------- */
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mode-badge--direct {
|
||||
color: #22c55e;
|
||||
border-color: #22c55e40;
|
||||
background: #22c55e10;
|
||||
}
|
||||
|
||||
.mode-badge--mirror {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f640;
|
||||
background: #3b82f610;
|
||||
}
|
||||
|
||||
.mode-badge--hybrid {
|
||||
color: #a855f7;
|
||||
border-color: #a855f740;
|
||||
background: #a855f710;
|
||||
}
|
||||
|
||||
/* -- Banner / Loading ------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* -- Health Bar -------------------------------------------------------- */
|
||||
|
||||
.health-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-stat strong {
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.health-stat--fresh strong {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.health-stat--stale strong {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.health-stat--never strong {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* -- Consumer Panel --------------------------------------------------- */
|
||||
|
||||
.consumer-panel {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.consumer-panel h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.consumer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.consumer-field {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.consumer-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.consumer-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.consumer-value.code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status--connected {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status--disconnected {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* -- Empty State ------------------------------------------------------ */
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* -- Domain Grid ------------------------------------------------------ */
|
||||
|
||||
.domain-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.domain-card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
padding: 1rem 1.25rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-title-group {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.staleness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: lowercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.staleness--fresh {
|
||||
color: #22c55e;
|
||||
border-color: #22c55e40;
|
||||
}
|
||||
|
||||
.staleness--stale {
|
||||
color: #eab308;
|
||||
border-color: #eab30840;
|
||||
}
|
||||
|
||||
.staleness--never {
|
||||
color: #9ca3af;
|
||||
border-color: #9ca3af40;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-stat {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.card-stat-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-stat-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.card-sources {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 1.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.source-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.68rem;
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-pill--more {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* -- Endpoints Panel -------------------------------------------------- */
|
||||
|
||||
.card-endpoints {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-endpoints h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.endpoints-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.endpoint-method {
|
||||
display: inline-flex;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.65rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-heading);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.endpoint-desc {
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* -- Card Actions ----------------------------------------------------- */
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
/* -- Buttons ---------------------------------------------------------- */
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-sm.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef444420;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef444440;
|
||||
}
|
||||
|
||||
/* -- Responsive ------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.consumer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class MirrorDashboardComponent implements OnInit {
|
||||
private readonly api = inject(MirrorManagementApi);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly config = signal<MirrorConfigResponse | null>(null);
|
||||
readonly domains = signal<MirrorDomainResponse[]>([]);
|
||||
readonly health = signal<MirrorHealthSummary | null>(null);
|
||||
readonly regeneratingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly expandedEndpoints = signal<string | null>(null);
|
||||
readonly domainEndpoints = signal<MirrorDomainEndpointDto[]>([]);
|
||||
|
||||
readonly showConsumerPanel = computed(() => {
|
||||
const cfg = this.config();
|
||||
return cfg != null && (cfg.mode === 'Hybrid' || cfg.mode === 'Mirror');
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
onCreateDomain(): void {
|
||||
this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', 'create']);
|
||||
}
|
||||
|
||||
onRegenerate(domainId: string): void {
|
||||
this.regeneratingId.set(domainId);
|
||||
|
||||
this.api.generateDomain(domainId).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.regeneratingId.set(null);
|
||||
this.reloadDomains();
|
||||
},
|
||||
error: () => {
|
||||
this.regeneratingId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEditDomain(domainId: string): void {
|
||||
this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', domainId, 'edit']);
|
||||
}
|
||||
|
||||
onDeleteDomain(domainId: string, displayName: string): void {
|
||||
if (!confirm('Delete mirror domain "' + displayName + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deletingId.set(domainId);
|
||||
|
||||
this.api.deleteDomain(domainId).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.deletingId.set(null);
|
||||
this.domains.update((current) => current.filter((d) => d.id !== domainId));
|
||||
this.reloadHealth();
|
||||
},
|
||||
error: () => {
|
||||
this.deletingId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onViewEndpoints(domainId: string): void {
|
||||
if (this.expandedEndpoints() === domainId) {
|
||||
this.expandedEndpoints.set(null);
|
||||
this.domainEndpoints.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.expandedEndpoints.set(domainId);
|
||||
this.domainEndpoints.set([]);
|
||||
|
||||
this.api.getDomainEndpoints(domainId).pipe(take(1)).subscribe({
|
||||
next: (response) => {
|
||||
this.domainEndpoints.set(response.endpoints ?? []);
|
||||
},
|
||||
error: () => {
|
||||
this.domainEndpoints.set([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDomainStaleness(domain: MirrorDomainResponse): string {
|
||||
return domainStaleness(domain);
|
||||
}
|
||||
|
||||
formatTimestamp(ts: string | null | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
try {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
forkJoin({
|
||||
config: this.api.getConfig().pipe(take(1)),
|
||||
domains: this.api.listDomains().pipe(take(1)),
|
||||
health: this.api.getHealthSummary().pipe(take(1)),
|
||||
}).subscribe({
|
||||
next: ({ config, domains, health }) => {
|
||||
this.config.set(config);
|
||||
this.domains.set(domains.domains ?? []);
|
||||
this.health.set(health);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reloadDomains(): void {
|
||||
this.api.listDomains().pipe(take(1)).subscribe({
|
||||
next: (response) => {
|
||||
this.domains.set(response.domains ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reloadHealth(): void {
|
||||
this.api.getHealthSummary().pipe(take(1)).subscribe({
|
||||
next: (summary) => {
|
||||
this.health.set(summary);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MirrorMode = 'Direct' | 'Mirror' | 'Hybrid';
|
||||
|
||||
export interface MirrorDomainSourceRef {
|
||||
sourceId: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainRateLimits {
|
||||
indexRequestsPerHour: number;
|
||||
downloadRequestsPerHour: number;
|
||||
}
|
||||
|
||||
export interface MirrorDomainSigning {
|
||||
enabled: boolean;
|
||||
algorithm: string;
|
||||
keyId: string;
|
||||
}
|
||||
|
||||
export interface CreateMirrorDomainRequest {
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
}
|
||||
|
||||
export interface MirrorDomainResponse {
|
||||
id: string;
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
domainUrl: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainGenerateResponse {
|
||||
domainId: string;
|
||||
jobId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainConfigResponse {
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
resolvedFilter: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MirrorDomainListResponse {
|
||||
domains: MirrorDomainResponse[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface MirrorConfigResponse {
|
||||
mode: MirrorMode;
|
||||
consumerMirrorUrl: string | null;
|
||||
consumerConnected: boolean;
|
||||
lastConsumerSync: string | null;
|
||||
}
|
||||
|
||||
export interface MirrorHealthSummary {
|
||||
totalDomains: number;
|
||||
freshCount: number;
|
||||
staleCount: number;
|
||||
neverGeneratedCount: number;
|
||||
totalAdvisoryCount: number;
|
||||
}
|
||||
|
||||
export interface MirrorDomainEndpointDto {
|
||||
path: string;
|
||||
method: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainEndpointsResponse {
|
||||
domainId: string;
|
||||
endpoints: MirrorDomainEndpointDto[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MirrorManagementApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = '/api/v1/mirror';
|
||||
|
||||
// ── Mirror Config ──────────────────────────────────────────────────────────
|
||||
|
||||
getConfig(): Observable<MirrorConfigResponse> {
|
||||
return this.http.get<MirrorConfigResponse>(`${this.baseUrl}/config`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getHealthSummary(): Observable<MirrorHealthSummary> {
|
||||
return this.http.get<MirrorHealthSummary>(`${this.baseUrl}/health`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Domains ────────────────────────────────────────────────────────────────
|
||||
|
||||
listDomains(): Observable<MirrorDomainListResponse> {
|
||||
return this.http.get<MirrorDomainListResponse>(`${this.baseUrl}/domains`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getDomain(domainId: string): Observable<MirrorDomainResponse> {
|
||||
return this.http.get<MirrorDomainResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
createDomain(request: CreateMirrorDomainRequest): Observable<MirrorDomainResponse> {
|
||||
return this.http.post<MirrorDomainResponse>(`${this.baseUrl}/domains`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteDomain(domainId: string): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
generateDomain(domainId: string): Observable<MirrorDomainGenerateResponse> {
|
||||
return this.http.post<MirrorDomainGenerateResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/generate`,
|
||||
null,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
getDomainConfig(domainId: string): Observable<MirrorDomainConfigResponse> {
|
||||
return this.http.get<MirrorDomainConfigResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/config`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
getDomainEndpoints(domainId: string): Observable<MirrorDomainEndpointsResponse> {
|
||||
return this.http.get<MirrorDomainEndpointsResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/endpoints`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
export interface SourceCatalogItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
type: string;
|
||||
description: string;
|
||||
baseEndpoint: string;
|
||||
requiresAuth: boolean;
|
||||
credentialEnvVar?: string | null;
|
||||
credentialUrl?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
defaultPriority: number;
|
||||
regions: string[];
|
||||
tags: string[];
|
||||
enabledByDefault: boolean;
|
||||
}
|
||||
|
||||
export interface SourceCatalogResponse {
|
||||
items: SourceCatalogItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface SourceStatusItem {
|
||||
sourceId: string;
|
||||
enabled: boolean;
|
||||
lastCheck?: SourceConnectivityResultDto | null;
|
||||
}
|
||||
|
||||
export interface SourceStatusResponse {
|
||||
sources: SourceStatusItem[];
|
||||
}
|
||||
|
||||
export interface SourceConnectivityResultDto {
|
||||
sourceId: string;
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
latency?: string | null;
|
||||
errorMessage?: string | null;
|
||||
errorCode?: string | null;
|
||||
httpStatusCode?: number | null;
|
||||
possibleReasons: string[];
|
||||
remediationSteps: RemediationStepDto[];
|
||||
isHealthy: boolean;
|
||||
}
|
||||
|
||||
export interface RemediationStepDto {
|
||||
order: number;
|
||||
description: string;
|
||||
command?: string | null;
|
||||
commandType: string;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchSourceRequest {
|
||||
sourceIds: string[];
|
||||
}
|
||||
|
||||
export interface BatchSourceResultItem {
|
||||
sourceId: string;
|
||||
success: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchSourceResponse {
|
||||
results: BatchSourceResultItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceManagementApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = '/api/v1/sources';
|
||||
|
||||
getCatalog(): Observable<SourceCatalogResponse> {
|
||||
return this.http.get<SourceCatalogResponse>(`${this.baseUrl}/catalog`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): Observable<SourceStatusResponse> {
|
||||
return this.http.get<SourceStatusResponse>(`${this.baseUrl}/status`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
enableSource(sourceId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/${encodeURIComponent(sourceId)}/enable`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
disableSource(sourceId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/${encodeURIComponent(sourceId)}/disable`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
checkAll(): Observable<any> {
|
||||
return this.http.post<any>(`${this.baseUrl}/check`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
checkSource(sourceId: string): Observable<SourceConnectivityResultDto> {
|
||||
return this.http.post<SourceConnectivityResultDto>(
|
||||
`${this.baseUrl}/${encodeURIComponent(sourceId)}/check`,
|
||||
null,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
batchEnable(sourceIds: string[]): Observable<BatchSourceResponse> {
|
||||
return this.http.post<BatchSourceResponse>(`${this.baseUrl}/batch-enable`, { sourceIds } as BatchSourceRequest, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
batchDisable(sourceIds: string[]): Observable<BatchSourceResponse> {
|
||||
return this.http.post<BatchSourceResponse>(`${this.baseUrl}/batch-disable`, { sourceIds } as BatchSourceRequest, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getCheckResult(sourceId: string): Observable<SourceConnectivityResultDto> {
|
||||
return this.http.get<SourceConnectivityResultDto>(
|
||||
`${this.baseUrl}/${encodeURIComponent(sourceId)}/check-result`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,19 @@ import {
|
||||
</div>
|
||||
<p class="category-empty">No runtime-host connector plugins are currently available.</p>
|
||||
</section>
|
||||
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>Advisory & VEX Sources</h2>
|
||||
<p class="category-desc">Browse, enable, and health-check the 47 upstream advisory and VEX data sources.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openAdvisorySourceCatalog()">
|
||||
Configure Sources
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-empty">Manage NVD, OSV, GHSA, vendor CERTs, CSAF feeds, and StellaOps mirrors from one catalog.</p>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@@ -282,6 +295,12 @@ export class IntegrationsHubComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
openAdvisorySourceCatalog(): void {
|
||||
void this.router.navigate(this.integrationCommands('advisory-vex-sources'), {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
openWizard(type: IntegrationOnboardingType): void {
|
||||
if (this.providersForType(type).length === 0) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SupportedProviderInfo,
|
||||
} from '../../integration-hub/integration.models';
|
||||
|
||||
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host';
|
||||
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host' | 'advisory-vex';
|
||||
export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review';
|
||||
|
||||
export interface ProviderField {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, take } from 'rxjs/operators';
|
||||
import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
|
||||
@@ -64,8 +65,8 @@ interface PlatformListResponse<T> {
|
||||
<section class="overview">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Security / Posture</h1>
|
||||
<p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p>
|
||||
<h1>Security Posture</h1>
|
||||
<p>Release-blocking posture, advisory freshness, and disposition confidence for the selected scope.</p>
|
||||
</div>
|
||||
<div class="scope">
|
||||
<span>Scope</span>
|
||||
@@ -275,6 +276,7 @@ interface PlatformListResponse<T> {
|
||||
})
|
||||
export class SecurityRiskOverviewComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly advisorySourcesApi = inject(AdvisorySourcesApi);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly loading = signal(false);
|
||||
@@ -418,12 +420,18 @@ export class SecurityRiskOverviewComponent {
|
||||
const sbom$ = this.http
|
||||
.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params: params.set('mode', 'table') })
|
||||
.pipe(map((res) => res.table ?? []), catchError(() => of([] as SecuritySbomExplorerResponse['table'])));
|
||||
const feedHealth$ = this.http
|
||||
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/feeds', { params })
|
||||
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
|
||||
const vexHealth$ = this.http
|
||||
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/vex-sources', { params })
|
||||
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
|
||||
const feedHealth$ = this.advisorySourcesApi.listSources(true).pipe(
|
||||
map((items: AdvisorySourceListItemDto[]) => items.map(item => ({
|
||||
sourceId: item.sourceKey,
|
||||
sourceName: item.sourceName,
|
||||
status: item.freshnessStatus,
|
||||
freshness: item.freshnessStatus === 'healthy' ? 'fresh' : item.freshnessStatus,
|
||||
freshnessMinutes: item.freshnessAgeSeconds > 0 ? Math.round(item.freshnessAgeSeconds / 60) : null,
|
||||
slaMinutes: Math.round(item.freshnessSlaSeconds / 60),
|
||||
} as IntegrationHealthRow))),
|
||||
catchError(() => of([] as IntegrationHealthRow[]))
|
||||
);
|
||||
const vexHealth$ = of([] as IntegrationHealthRow[]);
|
||||
|
||||
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ })
|
||||
.pipe(take(1))
|
||||
@@ -452,4 +460,3 @@ export class SecurityRiskOverviewComponent {
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user