diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/FeedMirrorManagementEndpoints.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/FeedMirrorManagementEndpoints.cs index f87028974..c8a5273dd 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/FeedMirrorManagementEndpoints.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/FeedMirrorManagementEndpoints.cs @@ -2,6 +2,8 @@ using HttpResults = Microsoft.AspNetCore.Http.Results; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Concelier.WebService.Services; +using StellaOps.Excititor.Core; namespace StellaOps.Concelier.WebService.Extensions; @@ -180,9 +182,10 @@ internal static class FeedMirrorManagementEndpoints [FromQuery] string? feedTypes, [FromQuery] string? syncStatuses, [FromQuery] bool? enabled, - [FromQuery] string? search) + [FromQuery] string? search, + [FromServices] IFeedMirrorConfigStore store) { - var result = MirrorSeedData.Mirrors.AsEnumerable(); + var result = store.GetAll().AsEnumerable(); if (!string.IsNullOrWhiteSpace(feedTypes)) { @@ -209,24 +212,33 @@ internal static class FeedMirrorManagementEndpoints return HttpResults.Ok(result.ToList()); } - private static IResult GetMirror(string mirrorId) + private static IResult GetMirror(string mirrorId, [FromServices] IFeedMirrorConfigStore store) { - var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId); + var mirror = store.Get(mirrorId); return mirror is not null ? HttpResults.Ok(mirror) : HttpResults.NotFound(); } - private static IResult UpdateMirrorConfig(string mirrorId, [FromBody] MirrorConfigUpdateDto config) + private static async Task UpdateMirrorConfig( + string mirrorId, + [FromBody] MirrorConfigUpdateDto config, + [FromServices] IFeedMirrorConfigStore store, + [FromServices] SchedulerClient scheduler, + [FromServices] IMirrorSchedulerSignal signal, + HttpContext httpContext) { - var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId); - if (mirror is null) return HttpResults.NotFound(); + var updated = store.Update(mirrorId, config); + if (updated is null) return HttpResults.NotFound(); - return HttpResults.Ok(mirror with - { - Enabled = config.Enabled ?? mirror.Enabled, - SyncIntervalMinutes = config.SyncIntervalMinutes ?? mirror.SyncIntervalMinutes, - UpstreamUrl = config.UpstreamUrl ?? mirror.UpstreamUrl, - UpdatedAt = DateTimeOffset.UtcNow.ToString("o"), - }); + // Propagate to central scheduler with deterministic schedule ID + var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "demo-prod"; + await scheduler.UpsertMirrorScheduleAsync( + tenantId, mirrorId, updated.Name, updated.SyncIntervalMinutes, updated.Enabled, + httpContext.RequestAborted); + + // Wake the local export scheduler immediately + signal.SignalReconfigured(); + + return HttpResults.Ok(updated); } private static IResult TriggerSync(string mirrorId) diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index d90e13571..efb23a9f8 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -661,7 +661,23 @@ builder.Services.AddHttpClient("MirrorConsumer"); // Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b) builder.Services.Configure(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName)); -builder.Services.AddHostedService(); + +// Feed mirror config store (replaces stateless MirrorSeedData for PATCH persistence) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); + +// Scheduler HTTP client for propagating mirror config to central Scheduler +builder.Services.AddHttpClient("Scheduler", client => +{ + client.BaseAddress = new Uri( + builder.Configuration["Scheduler:BaseUrl"] ?? "http://scheduler.stella-ops.local"); +}); +builder.Services.AddSingleton(); + +// MirrorExportScheduler: singleton + signal interface + hosted service +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions(); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/InMemoryFeedMirrorConfigStore.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/InMemoryFeedMirrorConfigStore.cs new file mode 100644 index 000000000..bab986e7c --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/InMemoryFeedMirrorConfigStore.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; +using static StellaOps.Concelier.WebService.Extensions.FeedMirrorManagementEndpoints; + +namespace StellaOps.Concelier.WebService.Services; + +/// +/// Thread-safe in-memory store for feed mirror configuration. +/// Seeded from on construction. +/// Replaces the stateless seed-data pattern so that PATCH updates are persisted in-process. +/// Future: replace with DB-backed store. +/// +internal sealed class InMemoryFeedMirrorConfigStore : IFeedMirrorConfigStore +{ + private readonly ConcurrentDictionary _mirrors; + + public InMemoryFeedMirrorConfigStore() + { + _mirrors = new ConcurrentDictionary( + MirrorSeedData.Mirrors.ToDictionary(m => m.MirrorId, m => m), + StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList GetAll() + => _mirrors.Values.OrderBy(m => m.Name).ToList(); + + public FeedMirrorDto? Get(string mirrorId) + => _mirrors.TryGetValue(mirrorId, out var mirror) ? mirror : null; + + public FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update) + { + if (!_mirrors.TryGetValue(mirrorId, out var existing)) + return null; + + var updated = existing with + { + Enabled = update.Enabled ?? existing.Enabled, + SyncIntervalMinutes = update.SyncIntervalMinutes ?? existing.SyncIntervalMinutes, + UpstreamUrl = update.UpstreamUrl ?? existing.UpstreamUrl, + UpdatedAt = DateTimeOffset.UtcNow.ToString("o"), + }; + + _mirrors[mirrorId] = updated; + return updated; + } +} + +/// +/// Abstraction for feed mirror config persistence. +/// +internal interface IFeedMirrorConfigStore +{ + IReadOnlyList GetAll(); + FeedMirrorDto? Get(string mirrorId); + FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update); +} diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/SchedulerClient.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/SchedulerClient.cs new file mode 100644 index 000000000..dcb5e9312 --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/SchedulerClient.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace StellaOps.Concelier.WebService.Services; + +/// +/// HTTP client for the central Scheduler WebService. +/// Manages mirror sync schedules using deterministic IDs: sys-{tenantId}-mirror-{mirrorId}. +/// +public sealed class SchedulerClient +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public SchedulerClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// Creates or updates the scheduler schedule for a feed mirror. + /// Uses deterministic schedule ID: sys-{tenantId}-mirror-{mirrorId}. + /// + public async Task UpsertMirrorScheduleAsync( + string tenantId, + string mirrorId, + string mirrorName, + int intervalMinutes, + bool enabled, + CancellationToken ct = default) + { + var scheduleId = BuildScheduleId(tenantId, mirrorId); + var cron = IntervalToCron(intervalMinutes); + var client = _httpClientFactory.CreateClient("Scheduler"); + + // Add tenant header required by the scheduler service + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Tenant-Id", tenantId); + + // Try PATCH first (update existing schedule) + var patchBody = new + { + name = $"Mirror Sync: {mirrorName}", + cronExpression = cron, + timezone = "UTC", + enabled, + }; + + var patchResponse = await client.PatchAsJsonAsync( + $"/api/v1/scheduler/schedules/{scheduleId}", patchBody, JsonOptions, ct); + + if (patchResponse.IsSuccessStatusCode) + { + _logger.LogInformation( + "Updated scheduler schedule {ScheduleId} for mirror {MirrorId}: cron={Cron}, enabled={Enabled}", + scheduleId, mirrorId, cron, enabled); + return true; + } + + if (patchResponse.StatusCode != HttpStatusCode.NotFound) + { + _logger.LogWarning( + "Failed to update scheduler schedule {ScheduleId}: {Status}", + scheduleId, patchResponse.StatusCode); + return false; + } + + // Schedule doesn't exist yet — create it + var createBody = new + { + name = $"Mirror Sync: {mirrorName}", + cronExpression = cron, + timezone = "UTC", + enabled, + mode = "contentRefresh", + selection = new { scope = "allImages", tenantId }, + source = "system", + }; + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/scheduler/schedules/", createBody, JsonOptions, ct); + + if (createResponse.IsSuccessStatusCode) + { + _logger.LogInformation( + "Created scheduler schedule for mirror {MirrorId}: id={ScheduleId}, cron={Cron}", + mirrorId, scheduleId, cron); + return true; + } + + _logger.LogWarning( + "Failed to create scheduler schedule for mirror {MirrorId}: {Status}", + mirrorId, createResponse.StatusCode); + return false; + } + + /// + /// Builds the deterministic schedule ID for a mirror. + /// + public static string BuildScheduleId(string tenantId, string mirrorId) + => $"sys-{tenantId}-mirror-{mirrorId}"; + + /// + /// Converts an interval in minutes to a cron expression. + /// + public static string IntervalToCron(int minutes) => minutes switch + { + <= 0 => "0 */1 * * *", // fallback: every hour + < 60 => $"*/{minutes} * * * *", // every N minutes + 60 => "0 */1 * * *", // every hour + < 1440 => $"0 */{minutes / 60} * * *", // every N hours + _ => "0 0 * * *", // daily + }; +} diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs index 88747c589..342b40c6a 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs @@ -13,19 +13,32 @@ using StellaOps.Excititor.Core.Storage; namespace StellaOps.Excititor.Core; +/// +/// Signal interface to wake the from its sleep cycle +/// when mirror configuration changes at runtime (e.g. sync interval update). +/// +public interface IMirrorSchedulerSignal +{ + /// + /// Interrupts the current sleep cycle so the scheduler re-reads its options immediately. + /// + void SignalReconfigured(); +} + /// /// Background service that periodically checks configured mirror domains for stale /// export bundles and triggers regeneration when source data has been updated since /// the last bundle generation. Designed for air-gap awareness: can be fully disabled /// via . /// -public sealed class MirrorExportScheduler : BackgroundService +public sealed class MirrorExportScheduler : BackgroundService, IMirrorSchedulerSignal { private readonly IServiceScopeFactory _scopeFactory; private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _domainStatus = new(StringComparer.OrdinalIgnoreCase); + private CancellationTokenSource _wakeupCts = new(); /// /// Initializes a new instance of . @@ -48,6 +61,15 @@ public sealed class MirrorExportScheduler : BackgroundService public IReadOnlyDictionary GetDomainStatuses() => _domainStatus.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); + /// + public void SignalReconfigured() + { + var old = Interlocked.Exchange(ref _wakeupCts, new CancellationTokenSource()); + try { old.Cancel(); } catch (ObjectDisposedException) { } + old.Dispose(); + _logger.LogInformation("MirrorExportScheduler received reconfiguration signal; waking from sleep."); + } + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -109,12 +131,18 @@ public sealed class MirrorExportScheduler : BackgroundService try { - await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken).ConfigureAwait(false); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _wakeupCts.Token); + await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (OperationCanceledException) + { + // Wakeup signal received — loop immediately to re-read options + _logger.LogDebug("MirrorExportScheduler woke up from reconfiguration signal."); + } } _logger.LogInformation("MirrorExportScheduler stopped."); diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 3d02efb21..294591d53 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -92,7 +92,7 @@ import { VEX_DECISIONS_API_BASE_URL, VexDecisionsHttpClient, } from './core/api/vex-decisions.client'; -import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient } from './core/api/vex-hub.client'; +import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; import { AUDIT_BUNDLES_API, AUDIT_BUNDLES_API_BASE_URL, @@ -512,7 +512,7 @@ export const appConfig: ApplicationConfig = { VexHubApiHttpClient, { provide: VEX_HUB_API, - useExisting: VexHubApiHttpClient, + useClass: MockVexHubClient, }, VexEvidenceHttpClient, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts index 7583db012..e1cb0f895 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts @@ -272,7 +272,8 @@ export class MockVexHubClient implements VexHubApi { let filtered = [...this.mockStatements]; if (params.cveId) { - filtered = filtered.filter((s) => s.cveId.includes(params.cveId!)); + const q = params.cveId.toLowerCase(); + filtered = filtered.filter((s) => s.cveId.toLowerCase().includes(q)); } if (params.status) { filtered = filtered.filter((s) => s.status === params.status); diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 70417c912..b340c9fc0 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -17,7 +17,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'home', label: 'Home', - description: 'Daily overview, health signals, and the fastest path back into active work.', + description: '', icon: 'home', items: [ { @@ -65,8 +65,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ label: 'Release Policies', route: '/ops/policy/packs', icon: 'clipboard', - tooltip: 'Define and manage the rules that gate your releases', - requiredScopes: ['policy:author'], + tooltip: 'Policy packs, governance, VEX, and simulation', }, ], }, @@ -81,101 +80,42 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ icon: 'shield', items: [ { - id: 'vulnerabilities', - label: 'Vulnerabilities', + id: 'image-security', + label: 'Image Security', + route: '/security/images', + icon: 'shield', + tooltip: 'Security posture, findings, SBOM, and evidence for container images', + }, + { + id: 'triage-queue', + label: 'Triage Queue', route: '/triage/artifacts', icon: 'alert-triangle', - tooltip: 'Vulnerability triage queue', + tooltip: 'Prioritized vulnerability triage work queue', }, { - id: 'security-posture', - label: 'Security Posture', - route: '/security', - icon: 'shield', - tooltip: 'Security posture overview and trends', - children: [ - { - id: 'supply-chain-data', - label: 'Supply-Chain Data', - route: '/security/supply-chain-data', - tooltip: 'Components, packages, and SBOM-backed inventory', - }, - { - id: 'findings-explorer', - label: 'Findings Explorer', - route: '/security/findings', - tooltip: 'Detailed findings, comparisons, and investigation pivots', - }, - { - id: 'reachability', - label: 'Reachability', - route: '/security/reachability', - tooltip: 'Which vulnerable code paths are actually callable', - }, - { - id: 'unknowns', - label: 'Unknowns', - route: '/security/unknowns', - tooltip: 'Components that still need identification or classification', - }, - ], + id: 'risk-overview', + label: 'Risk Overview', + route: '/security/risk', + icon: 'activity', + tooltip: 'Fleet-wide risk budget and compliance posture', }, { - id: 'scan-image', - label: 'Scan Image', - route: '/security/scan', - icon: 'search', - tooltip: 'Scan container images', - }, - { - id: 'vex-exceptions', - label: 'VEX & Exceptions', - route: '/ops/policy/vex', - icon: 'file-text', - tooltip: 'Manage VEX statements and policy exceptions', + id: 'advisory-sources', + label: 'Advisory Sources', + route: '/security/advisory-sources', + icon: 'rss', + tooltip: 'Feed health, freshness, and SLA compliance', }, ], }, // ------------------------------------------------------------------------- - // 4. Evidence + // 4. Evidence (context-accessed routes — no standalone sidebar items) + // Evidence threads, capsule details, proof chains, workspaces are accessed + // from release/scan/deployment detail pages. Audit, exports, and bundles + // are consolidated under Operations → Audit. // ------------------------------------------------------------------------- - { - id: 'evidence', - label: 'Evidence', - description: 'Verify what happened, inspect proof chains, and export auditor-ready bundles.', - icon: 'file-text', - items: [ - { - id: 'evidence-overview', - label: 'Evidence Overview', - route: '/evidence/overview', - icon: 'file-text', - tooltip: 'Search evidence, proof chains, and verification status', - }, - { - id: 'decision-capsules', - label: 'Decision Capsules', - route: '/evidence/capsules', - icon: 'archive', - tooltip: 'Signed decision records for promotions and exceptions', - }, - { - id: 'audit-log', - label: 'Audit Log', - route: '/evidence/audit-log', - icon: 'log', - tooltip: 'Cross-module audit trail and compliance', - }, - { - id: 'export-center', - label: 'Export Center', - route: '/evidence/exports', - icon: 'download', - tooltip: 'Export evidence for compliance', - }, - ], - }, // ------------------------------------------------------------------------- // 5. Operations @@ -187,11 +127,11 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ icon: 'server', items: [ { - id: 'scheduled-jobs', - label: 'Scheduled Jobs', - route: OPERATIONS_PATHS.jobsQueues, - icon: 'workflow', - tooltip: 'Queue health, execution state, and recovery work', + id: 'schedules', + label: 'Schedules', + route: OPERATIONS_PATHS.jobEngine, + icon: 'clock', + tooltip: 'Scheduled scans, runs, and worker fleet', }, { id: 'feeds-airgap', @@ -214,6 +154,13 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ icon: 'activity', tooltip: 'Service health, drift signals, and operational checks', }, + { + id: 'audit', + label: 'Audit', + route: '/ops/operations/audit', + icon: 'log', + tooltip: 'Audit trail, evidence exports, compliance bundles', + }, ], }, @@ -294,9 +241,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ icon: 'database', }, { - id: 'scanner-ops', - label: 'Scanner Ops', - route: SCANNER_OPS_ROOT, + id: 'scanner-config', + label: 'Scanner Config', + route: '/setup/integrations/scanner-config', icon: 'scan', }, ], @@ -329,23 +276,23 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'audit-compliance', label: 'Audit & Compliance', - route: '/evidence/audit-log', + route: '/ops/operations/audit', icon: 'log', children: [ { id: 'audit-dashboard', label: 'Dashboard', - route: '/evidence/audit-log', + route: '/ops/operations/audit', }, { id: 'audit-events', label: 'All Events', - route: '/evidence/audit-log/events', + route: '/ops/operations/audit?tab=all-events', }, { id: 'audit-export', - label: 'Export', - route: '/evidence/audit-log/export', + label: 'Exports', + route: '/ops/operations/audit?tab=exports', }, ], }, diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-dashboard.component.html index 69b281b63..e525ed3c6 100644 --- a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-dashboard.component.html +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-dashboard.component.html @@ -1,15 +1,8 @@
-
-
-

Exception Center

-

Manage policy exceptions with auditable workflows.

-
-
- Approval Queue - - -
-
+
+ Approval Queue + +
@if (error()) {
-
`, styles: [` - .mirror-detail { - display: grid; - gap: 1.5rem; - } - - .detail-header { - display: flex; - align-items: center; - } + .mirror-detail { display: grid; gap: 1rem; } + /* ---- Back button ---- */ .back-btn { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: transparent; - border: 1px solid var(--color-text-primary); - border-radius: var(--radius-md); - color: var(--color-text-muted); - font-size: 0.875rem; - cursor: pointer; - transition: all 0.15s; - - &:hover { - background: var(--color-surface-inverse); - color: rgba(212, 201, 168, 0.3); - } + display: inline-flex; align-items: center; gap: 0.375rem; + padding: 0.375rem 0.75rem; background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + color: var(--color-text-primary); font-size: 0.8125rem; cursor: pointer; + transition: opacity 150ms ease; } + .back-btn:hover { opacity: 0.85; } + /* ---- Info Card ---- */ .info-card { - background: var(--color-text-heading); - border: 1px solid var(--color-surface-inverse); - border-radius: var(--radius-lg); - padding: 1.5rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); padding: 1.25rem; } - .info-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.5rem; - } - - .info-title { - display: flex; - flex-direction: column; - gap: 0.5rem; - - h2 { - margin: 0; - font-size: 1.25rem; - font-weight: var(--font-weight-semibold); - } + display: flex; align-items: flex-start; justify-content: space-between; + gap: 1rem; margin-bottom: 1.25rem; } + .info-title { display: flex; flex-direction: column; gap: 0.5rem; } + .info-title h2 { margin: 0; font-size: 1.125rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); } + /* ---- Feed type badges ---- */ .feed-type-badge { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); - font-size: 0.625rem; - font-weight: var(--font-weight-bold); - letter-spacing: 0.05em; - width: fit-content; - } - - .feed-type--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); } - .feed-type--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); } - .feed-type--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); } - .feed-type--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); } - .feed-type--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); } - .feed-type--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); } - .feed-type--custom { background: rgba(100, 116, 139, 0.2); color: var(--color-text-muted); } - - .toggle-switch { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - - input { - display: none; - } + display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; + font-size: 0.625rem; font-weight: var(--font-weight-bold); letter-spacing: 0.05em; width: fit-content; } + .feed-type--nvd { background: var(--color-status-info-bg); color: var(--color-status-info-text); } + .feed-type--ghsa { background: var(--color-brand-soft, var(--color-surface-tertiary)); color: var(--color-status-excepted, var(--color-text-link)); } + .feed-type--oval { background: var(--color-brand-soft, var(--color-surface-tertiary)); color: var(--color-status-excepted, var(--color-text-link)); } + .feed-type--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .feed-type--epss { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .feed-type--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .feed-type--custom { background: var(--color-surface-tertiary); color: var(--color-text-secondary); } + /* ---- Toggle switch ---- */ + .toggle-switch { display: flex; align-items: center; gap: 0.75rem; cursor: pointer; } + .toggle-switch input { display: none; } .toggle-slider { - position: relative; - width: 44px; - height: 24px; - background: var(--color-text-primary); - border-radius: var(--radius-xl); - transition: background 0.2s; - - &::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 20px; - height: 20px; - background: var(--color-surface-primary); - border-radius: var(--radius-full); - transition: transform 0.2s; - } - - input:checked + & { - background: var(--color-status-success); - - &::after { - transform: translateX(20px); - } - } + position: relative; width: 44px; height: 24px; + background: var(--color-border-primary); border-radius: var(--radius-xl); transition: background 0.2s; } - - .toggle-label { - font-size: 0.875rem; - color: var(--color-text-muted); + .toggle-slider::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 20px; height: 20px; background: var(--color-surface-primary); + border-radius: var(--radius-full); transition: transform 0.2s; } + input:checked + .toggle-slider { background: var(--color-status-success); } + input:checked + .toggle-slider::after { transform: translateX(20px); } + .toggle-label { font-size: 0.8125rem; color: var(--color-text-secondary); } + /* ---- Info grid ---- */ .info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; - } - - .info-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .info-label { - font-size: 0.6875rem; - text-transform: uppercase; - color: var(--color-text-secondary); - letter-spacing: 0.05em; - } - - .info-value { - font-size: 0.9375rem; - } - - .info-code { - font-family: 'JetBrains Mono', monospace; - font-size: 0.8125rem; - color: var(--color-text-muted); - word-break: break-all; + display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; margin-bottom: 1rem; } + .info-item { display: flex; flex-direction: column; gap: 0.25rem; } + .info-label { font-size: 0.6875rem; text-transform: uppercase; color: var(--color-text-secondary); letter-spacing: 0.04em; } + .info-value { font-size: 0.8125rem; color: var(--color-text-primary); } + .info-code { font-family: ui-monospace, monospace; font-size: 0.8125rem; color: var(--color-text-muted); word-break: break-all; } + /* ---- Status badges ---- */ .status-badge { - display: inline-block; - padding: 0.25rem 0.625rem; - border-radius: var(--radius-sm); - font-size: 0.6875rem; - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - width: fit-content; + display: inline-block; padding: 0.125rem 0.625rem; border-radius: 9999px; + font-size: 0.6875rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; width: fit-content; } + .status--synced { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .status--syncing { background: var(--color-status-info-bg); color: var(--color-status-info-text); } + .status--stale { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .status--error { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .status--disabled { background: var(--color-surface-tertiary); color: var(--color-text-secondary); } + .status--pending { background: var(--color-surface-tertiary); color: var(--color-text-muted); } - .status--synced { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); } - .status--syncing { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); } - .status--stale { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); } - .status--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); } - .status--disabled { background: rgba(100, 116, 139, 0.2); color: var(--color-text-secondary); } - .status--pending { background: rgba(148, 163, 184, 0.2); color: var(--color-text-muted); } - + /* ---- Error banner ---- */ .error-banner { - display: flex; - align-items: flex-start; - gap: 0.75rem; - padding: 1rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: var(--radius-md); - margin-bottom: 1rem; + display: flex; align-items: flex-start; gap: 0.75rem; padding: 1rem; + background: var(--color-status-error-bg); border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-md); margin-bottom: 1rem; } - .error-icon { - flex-shrink: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - background: var(--color-status-error); - color: white; - border-radius: var(--radius-full); - font-size: 0.75rem; - font-weight: var(--font-weight-bold); - } - - .error-content { - strong { - display: block; - color: var(--color-status-error-border); - margin-bottom: 0.25rem; - } - - p { - margin: 0; - font-size: 0.875rem; - color: var(--color-status-error-border); - } - } - - .action-row { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; + flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; + background: var(--color-status-error); color: #fff; border-radius: var(--radius-full); + font-size: 0.75rem; font-weight: var(--font-weight-bold); } + .error-content strong { display: block; color: var(--color-status-error-text); margin-bottom: 0.25rem; } + .error-content p { margin: 0; font-size: 0.8125rem; color: var(--color-status-error-text); } + /* ---- Action buttons (canonical) ---- */ + .action-row { display: flex; gap: 0.75rem; flex-wrap: wrap; } .action-btn { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1.25rem; - border: none; - border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all 0.15s; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &--primary { - background: var(--color-status-info-text); - color: white; - - &:hover:not(:disabled) { - background: var(--color-status-info-text); - } - } - - &--secondary { - background: var(--color-surface-inverse); - color: rgba(212, 201, 168, 0.3); - border: 1px solid var(--color-text-primary); - - &:hover:not(:disabled) { - background: var(--color-text-primary); - } - } + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.375rem 0.75rem; border-radius: var(--radius-md); + font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary); } + .action-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); } + .action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } + .action-btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .action-btn--secondary { background: var(--color-surface-secondary); border-color: var(--color-border-primary); color: var(--color-text-primary); } - .btn-spinner { - width: 14px; - height: 14px; - border: 2px solid currentColor; - border-top-color: transparent; - border-radius: var(--radius-full); - animation: spin 0.8s linear infinite; - } - - .settings-panel { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid var(--color-surface-inverse); - - h3 { - margin: 0 0 1rem; - font-size: 0.9375rem; - font-weight: var(--font-weight-semibold); - } - } - - .settings-form { - display: grid; - gap: 1rem; - } - - .form-group { - display: flex; - flex-direction: column; - gap: 0.375rem; - - label { - font-size: 0.8125rem; - color: var(--color-text-muted); - } - } + .btn-spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: var(--radius-full); animation: spin 0.8s linear infinite; } + /* ---- Settings panel ---- */ + .settings-panel { margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--color-border-primary); } + .settings-panel h3 { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); } + .settings-form { display: grid; gap: 1rem; } + .form-group { display: flex; flex-direction: column; gap: 0.375rem; } + .form-group label { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } .form-input { - padding: 0.625rem 1rem; - background: var(--color-text-heading); - border: 1px solid var(--color-text-primary); - border-radius: var(--radius-md); - color: rgba(212, 201, 168, 0.3); - font-size: 0.875rem; - - &:focus { - outline: none; - border-color: var(--color-status-info); - } + padding: 0.5rem 0.75rem; background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + color: var(--color-text-primary); font-size: 0.875rem; + transition: border-color 150ms ease, box-shadow 150ms ease; } - - .form-actions { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - margin-top: 0.5rem; - } - + .form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); } + .form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; } .btn { - padding: 0.5rem 1rem; - border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all 0.15s; - - &--primary { - background: var(--color-status-info-text); - border: none; - color: white; - - &:hover { - background: var(--color-status-info-text); - } - } - - &--secondary { - background: transparent; - border: 1px solid var(--color-text-primary); - color: var(--color-text-muted); - - &:hover { - background: var(--color-surface-inverse); - color: rgba(212, 201, 168, 0.3); - } - } + display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); font-size: 0.8125rem; font-weight: var(--font-weight-medium); + cursor: pointer; transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary); } + .btn:hover { opacity: 0.9; transform: translateY(-1px); } + .btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn--secondary { background: var(--color-surface-secondary); border-color: var(--color-border-primary); color: var(--color-text-primary); } - // Snapshots Section - .snapshots-section, - .retention-section { - background: var(--color-text-heading); - border: 1px solid var(--color-surface-inverse); - border-radius: var(--radius-lg); - overflow: hidden; + /* ---- Sections (canonical table container) ---- */ + .snapshots-section, .retention-section { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); overflow: hidden; } - .section-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--color-surface-inverse); - - h3 { - margin: 0; - font-size: 0.9375rem; - font-weight: var(--font-weight-semibold); - } + display: flex; align-items: center; justify-content: space-between; + padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); } + .section-header h3 { margin: 0; font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .snapshot-count { font-size: 0.75rem; color: var(--color-text-secondary); } - .snapshot-count { - font-size: 0.75rem; - color: var(--color-text-secondary); + /* ---- Loading ---- */ + .loading-container { display: flex; flex-direction: column; align-items: center; padding: 2rem; color: var(--color-text-muted); } + .loading-container p { margin: 0.5rem 0 0; font-size: 0.8125rem; } + .loading-spinner { width: 24px; height: 24px; border: 2px solid var(--color-border-primary); border-top-color: var(--color-brand-primary); border-radius: var(--radius-full); animation: spin 1s linear infinite; } + + /* ---- Snapshots table (canonical data-table) ---- */ + .snapshots-table-container { overflow-x: auto; } + .snapshots-table { width: 100%; border-collapse: collapse; } + .snapshots-table th { + text-align: left; padding: 0.5rem 0.75rem; + font-size: 0.6875rem; font-weight: var(--font-weight-semibold); + text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-secondary); background: var(--color-surface-secondary); + border-bottom: 1px solid var(--color-border-primary); } + .snapshots-table td { padding: 0.5rem 0.75rem; font-size: 0.8125rem; border-bottom: 1px solid var(--color-border-primary); } + .snapshots-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .snapshots-table tbody tr:hover { background: var(--color-nav-hover); } + .snapshots-table tbody tr:last-child td { border-bottom: none; } + .row--latest { border-left: 3px solid var(--color-status-success); } + .row--pinned { border-left: 3px solid var(--color-status-info); } - .loading-container { - display: flex; - flex-direction: column; - align-items: center; - padding: 2rem; - color: var(--color-text-muted); - - p { - margin: 0.5rem 0 0; - font-size: 0.875rem; - } - } - - .loading-spinner { - width: 24px; - height: 24px; - border: 2px solid var(--color-text-primary); - border-top-color: var(--color-status-info); - border-radius: var(--radius-full); - animation: spin 1s linear infinite; - } - - .snapshots-table-container { - overflow-x: auto; - } - - .snapshots-table { - width: 100%; - border-collapse: collapse; - - th { - text-align: left; - padding: 0.75rem 1rem; - font-size: 0.6875rem; - text-transform: uppercase; - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - border-bottom: 1px solid var(--color-surface-inverse); - } - - td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - border-bottom: 1px solid var(--color-surface-inverse); - } - - tbody tr:last-child td { - border-bottom: none; - } - - .row--latest { - background: rgba(34, 197, 94, 0.05); - } - - .row--pinned { - background: rgba(59, 130, 246, 0.05); - } - } - - .version-cell { - display: flex; - align-items: center; - gap: 0.5rem; - - code { - font-family: 'JetBrains Mono', monospace; - font-size: 0.8125rem; - } - } + .version-cell { display: flex; align-items: center; gap: 0.5rem; } + .version-cell code { font-family: ui-monospace, monospace; font-size: 0.8125rem; } + /* ---- Badges (canonical pill style) ---- */ .badge { - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); - font-size: 0.5625rem; - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - - &--latest { - background: rgba(34, 197, 94, 0.2); - color: var(--color-status-success); - } - - &--pinned { - background: rgba(59, 130, 246, 0.2); - color: var(--color-status-info); - } + padding: 0.125rem 0.375rem; border-radius: 9999px; + font-size: 0.5625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; } + .badge--latest { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .badge--pinned { background: var(--color-status-info-bg); color: var(--color-status-info-text); } - .expiry-info { - font-size: 0.75rem; - color: var(--color-status-warning); - } + .expiry-info { font-size: 0.75rem; color: var(--color-status-warning-text); } + .no-expiry { font-size: 0.75rem; color: var(--color-text-secondary); } + .no-data { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } - .no-expiry { - font-size: 0.75rem; - color: var(--color-text-secondary); - } + /* ---- Retention ---- */ + .retention-config { padding: 1rem 1.25rem; } + .retention-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; } + .retention-item { display: flex; flex-direction: column; gap: 0.25rem; } + .retention-label { font-size: 0.6875rem; text-transform: uppercase; color: var(--color-text-secondary); letter-spacing: 0.04em; } + .retention-value { font-size: 0.8125rem; color: var(--color-text-primary); } - .no-data { - text-align: center; - color: var(--color-text-secondary); - padding: 2rem !important; - } - - // Retention Section - .retention-config { - padding: 1rem 1.25rem; - } - - .retention-info { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; - } - - .retention-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .retention-label { - font-size: 0.6875rem; - text-transform: uppercase; - color: var(--color-text-secondary); - } - - .retention-value { - font-size: 0.9375rem; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } + @keyframes spin { to { transform: rotate(360deg); } } `], changeDetection: ChangeDetectionStrategy.OnPush }) export class MirrorDetailComponent implements OnInit, OnChanges { private readonly feedMirrorApi = inject(FEED_MIRROR_API); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private mirrorState: FeedMirror = EMPTY_FEED_MIRROR; @Input() set mirror(value: FeedMirror | null | undefined) { @@ -817,6 +506,14 @@ export class MirrorDetailComponent implements OnInit, OnChanges { @Output() back = new EventEmitter(); + goBack(): void { + if (this.back.observed) { + this.back.emit(); + } else { + this.router.navigate(['/ops/operations/feeds']); + } + } + readonly snapshots = signal([]); readonly retentionConfig = signal(null); readonly loadingSnapshots = signal(true); diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/image-scope-bar.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/image-scope-bar.component.ts new file mode 100644 index 000000000..b9e00eac0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/image-scope-bar.component.ts @@ -0,0 +1,201 @@ +import { ChangeDetectionStrategy, Component, OnInit, inject, signal, computed } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { ImageSecurityScopeService } from './image-security-scope.service'; + +@Component({ + selector: 'app-image-scope-bar', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {{ scope.scopeLabel() }} +
+
+ `, + styles: [` + .scope-bar { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + align-items: flex-end; + flex-wrap: wrap; + } + .scope-bar__field { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + flex: 1; + } + .scope-bar__label { + font-size: 0.625rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .scope-bar__select { + padding: 0.375rem 1.5rem 0.375rem 0.5rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.8125rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + min-width: 0; + transition: border-color 150ms ease; + } + .scope-bar__select:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + .scope-bar__summary { + display: flex; + align-items: center; + margin-left: auto; + } + .scope-bar__badge { + font-size: 0.6875rem; + font-family: ui-monospace, monospace; + color: var(--color-text-secondary); + padding: 0.25rem 0.5rem; + background: var(--color-surface-tertiary); + border-radius: var(--radius-md); + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + } + `], +}) +export class ImageScopeBarComponent implements OnInit { + readonly scope = inject(ImageSecurityScopeService); + + // Local UI state + readonly repos = signal([]); + readonly allImages = signal([]); + readonly releases = signal<{ id: string; version: string }[]>([]); + readonly environments = signal(['Dev', 'QA', 'Staging', 'Prod']); + + readonly selectedRepo = signal(''); + readonly selectedImage = signal(''); + readonly selectedRelease = signal(''); + readonly selectedEnv = signal(''); + + readonly filteredImages = computed(() => { + const repo = this.selectedRepo(); + if (!repo) return this.allImages(); + return this.allImages().filter(img => img.startsWith(repo)); + }); + + ngOnInit(): void { + this.loadSeedData(); + } + + private loadSeedData(): void { + // Seed data from known images in the system + // In production, these come from release-management + scanner APIs + const images = [ + 'docker.io/acme/web:1.2.3', + 'docker.io/acme/web:latest', + 'docker.io/beta/api:3.0.0', + 'docker.io/beta/api:latest', + 'docker.io/gamma/lib:2.1.0', + ]; + this.allImages.set(images); + this.repos.set([...new Set(images.map(i => i.split(':')[0]))]); + this.releases.set([ + { id: 'rel-001', version: 'v2.3.1-rc2' }, + { id: 'rel-002', version: 'v2.3.0' }, + { id: 'rel-003', version: 'v2.2.5' }, + ]); + } + + onRepoChange(repo: string): void { + this.selectedRepo.set(repo); + this.selectedImage.set(''); + this.scope.repository.set(repo || null); + this.scope.imageRef.set(null); + } + + onImageChange(imageRef: string): void { + this.selectedImage.set(imageRef); + if (imageRef) { + this.scope.setImageRef(imageRef); + } else { + this.scope.imageRef.set(null); + } + } + + onReleaseChange(releaseId: string): void { + this.selectedRelease.set(releaseId); + this.scope.releaseId.set(releaseId || null); + } + + onEnvChange(env: string): void { + this.selectedEnv.set(env); + this.scope.environment.set(env || null); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/image-security-scope.service.ts b/src/Web/StellaOps.Web/src/app/features/image-security/image-security-scope.service.ts new file mode 100644 index 000000000..0b1bd12af --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/image-security-scope.service.ts @@ -0,0 +1,56 @@ +import { Injectable, computed, signal } from '@angular/core'; + +export interface ImageSecurityScope { + repo: string | null; + imageRef: string | null; + scanId: string | null; + releaseId: string | null; + environment: string | null; +} + +@Injectable() +export class ImageSecurityScopeService { + readonly repository = signal(null); + readonly imageRef = signal(null); + readonly scanId = signal(null); + readonly releaseId = signal(null); + readonly environment = signal(null); + + readonly scopeLabel = computed(() => { + if (this.imageRef()) return this.imageRef(); + if (this.repository()) return this.repository() + ':*'; + if (this.releaseId()) return 'Release ' + this.releaseId(); + return 'All images'; + }); + + readonly effectiveScope = computed(() => ({ + repo: this.repository(), + imageRef: this.imageRef(), + scanId: this.scanId(), + releaseId: this.releaseId(), + environment: this.environment(), + })); + + setFromRelease(releaseId: string, imageRefs: string[]): void { + this.releaseId.set(releaseId); + if (imageRefs.length === 1) { + this.setImageRef(imageRefs[0]); + } + } + + setImageRef(ref: string): void { + this.imageRef.set(ref); + const colonIdx = ref.lastIndexOf(':'); + if (colonIdx > 0) { + this.repository.set(ref.substring(0, colonIdx)); + } + } + + reset(): void { + this.repository.set(null); + this.imageRef.set(null); + this.scanId.set(null); + this.releaseId.set(null); + this.environment.set(null); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/image-security-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/image-security-shell.component.ts new file mode 100644 index 000000000..16064476e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/image-security-shell.component.ts @@ -0,0 +1,87 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; +import { ImageScopeBarComponent } from './image-scope-bar.component'; +import { ImageSecurityScopeService } from './image-security-scope.service'; + +type ImageTab = 'summary' | 'findings' | 'sbom' | 'reachability' | 'vex' | 'evidence'; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'summary', label: 'Summary', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, + { id: 'findings', label: 'Findings', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'sbom', label: 'SBOM', icon: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'reachability', label: 'Reachability', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, + { id: 'vex', label: 'VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, +]; + +@Component({ + selector: 'app-image-security-shell', + standalone: true, + imports: [RouterOutlet, StellaPageTabsComponent, ImageScopeBarComponent], + providers: [ImageSecurityScopeService], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+

Image Security

+

Security posture, findings, SBOM, and evidence for container images

+
+
+ + + + + + +
+ `, + styles: [` + .image-security { display: grid; gap: 0.85rem; } + .image-security__header { margin-bottom: 0.5rem; } + .image-security__title { font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); margin: 0 0 0.25rem; } + .image-security__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; } + `], +}) +export class ImageSecurityShellComponent { + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('summary'); + + constructor() { + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.activeTab.set(this.readTab())); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as ImageTab); + const segments = tabId === 'summary' + ? ['/security/images'] + : ['/security/images', tabId]; + void this.router.navigate(segments, { queryParamsHandling: 'preserve' }); + } + + private readTab(): ImageTab { + const url = this.router.url.split('?')[0] ?? ''; + if (url.includes('/images/findings')) return 'findings'; + if (url.includes('/images/sbom')) return 'sbom'; + if (url.includes('/images/reachability')) return 'reachability'; + if (url.includes('/images/vex')) return 'vex'; + if (url.includes('/images/evidence')) return 'evidence'; + return 'summary'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/image-security.routes.ts b/src/Web/StellaOps.Web/src/app/features/image-security/image-security.routes.ts new file mode 100644 index 000000000..38789910e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/image-security.routes.ts @@ -0,0 +1,47 @@ +import { Routes } from '@angular/router'; + +export const imageSecurityRoutes: Routes = [ + { + path: '', + loadComponent: () => + import('./image-security-shell.component').then((m) => m.ImageSecurityShellComponent), + children: [ + { path: '', redirectTo: 'summary', pathMatch: 'full' }, + { + path: 'summary', + loadComponent: () => + import('./tabs/image-summary-tab.component').then((m) => m.ImageSummaryTabComponent), + }, + { + path: 'findings', + loadComponent: () => + import('./tabs/image-findings-tab.component').then((m) => m.ImageFindingsTabComponent), + }, + { + path: 'findings/:findingId', + loadComponent: () => + import('../security-risk/finding-detail-page.component').then((m) => m.FindingDetailPageComponent), + }, + { + path: 'sbom', + loadComponent: () => + import('./tabs/image-sbom-tab.component').then((m) => m.ImageSbomTabComponent), + }, + { + path: 'reachability', + loadComponent: () => + import('./tabs/image-reachability-tab.component').then((m) => m.ImageReachabilityTabComponent), + }, + { + path: 'vex', + loadComponent: () => + import('./tabs/image-vex-tab.component').then((m) => m.ImageVexTabComponent), + }, + { + path: 'evidence', + loadComponent: () => + import('./tabs/image-evidence-tab.component').then((m) => m.ImageEvidenceTabComponent), + }, + ], + }, +]; diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-evidence-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-evidence-tab.component.ts new file mode 100644 index 000000000..601495ebd --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-evidence-tab.component.ts @@ -0,0 +1,82 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; + +interface EvidencePacket { + id: string; + type: 'deployment' | 'scan' | 'attestation' | 'gate-result'; + release: string; + environment: string; + status: 'verified' | 'partial' | 'pending'; + signedAt: string; + signedBy: string; +} + +@Component({ + selector: 'app-image-evidence-tab', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ + + + + + + + + + + + + @for (p of packets(); track p.id) { + + + + + + + + + } @empty { + + } + +
TypeReleaseEnvironmentStatusSignedBy
{{ p.type }}{{ p.release }}{{ p.environment }} + {{ p.status }} + {{ p.signedAt }}{{ p.signedBy }}
No evidence packets for current scope.
+
+
+ `, + styles: [` + .evidence-tab { display: grid; gap: 1rem; } + .table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } + .data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + .mono { font-family: ui-monospace, monospace; } + .muted { color: var(--color-text-secondary); font-size: 0.8125rem; } + .type-badge { font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-secondary); } + .status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; } + .status-badge--verified { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .status-badge--partial { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .status-badge--pending { background: var(--color-surface-tertiary); color: var(--color-text-secondary); } + .empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } + `], +}) +export class ImageEvidenceTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + readonly packets = signal([ + { id: 'ep-001', type: 'deployment', release: 'v2.3.1-rc2', environment: 'Staging', status: 'verified', signedAt: '2025-12-29 08:00', signedBy: 'system' }, + { id: 'ep-002', type: 'scan', release: 'v2.3.1-rc2', environment: 'Dev', status: 'verified', signedAt: '2025-12-28 14:30', signedBy: 'scanner-01' }, + { id: 'ep-003', type: 'gate-result', release: 'v2.3.0', environment: 'Prod', status: 'verified', signedAt: '2025-12-20 10:00', signedBy: 'policy-engine' }, + { id: 'ep-004', type: 'attestation', release: 'v2.3.0', environment: 'Prod', status: 'partial', signedAt: '2025-12-20 10:05', signedBy: 'ci-pipeline' }, + ]); +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-findings-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-findings-tab.component.ts new file mode 100644 index 000000000..db92c4868 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-findings-tab.component.ts @@ -0,0 +1,150 @@ +import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; +import { StellaFilterMultiComponent, type FilterMultiOption } from '../../../shared/components/stella-filter-multi/stella-filter-multi.component'; + +interface Finding { + id: string; + cveId: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + packageName: string; + version: string; + fixedVersion: string | null; + reachable: boolean | null; + vexStatus: string | null; + firstSeen: string; +} + +@Component({ + selector: 'app-image-findings-tab', + standalone: true, + imports: [CommonModule, RouterLink, StellaFilterMultiComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ + + {{ filteredFindings().length }} findings +
+ +
+ + + + + + + + + + + + + + @for (f of filteredFindings(); track f.id) { + + + + + + + + + + } @empty { + + } + +
CVESeverityPackageVersionFixedReachableVEX
{{ f.cveId }}{{ f.severity | uppercase }}{{ f.packageName }}{{ f.version }}{{ f.fixedVersion ?? '—' }} + @if (f.reachable === true) { Reachable } + @else if (f.reachable === false) { Unreachable } + @else { Unknown } + {{ f.vexStatus ?? '—' }}
No findings match the current scope and filters.
+
+
+ `, + styles: [` + .findings { display: grid; gap: 1rem; } + .filter-bar { display: flex; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); align-items: center; } + .filter-bar__count { font-size: 0.75rem; color: var(--color-text-secondary); margin-left: auto; } + .table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } + .data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + .cve-link { font-family: ui-monospace, monospace; font-weight: var(--font-weight-semibold); color: var(--color-text-link); text-decoration: none; } + .cve-link:hover { text-decoration: underline; } + .mono { font-family: ui-monospace, monospace; font-size: 0.8125rem; } + .severity-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); } + .severity-badge--critical { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .severity-badge--high { background: var(--color-severity-high-bg, var(--color-severity-medium-bg)); color: var(--color-severity-high, var(--color-status-warning-text)); } + .severity-badge--medium { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .severity-badge--low { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .reach { font-size: 0.6875rem; font-weight: var(--font-weight-semibold); } + .reach--yes { color: var(--color-status-error-text); } + .reach--no { color: var(--color-status-success-text); } + .reach--unknown { color: var(--color-text-secondary); } + .vex-chip { font-size: 0.6875rem; color: var(--color-text-secondary); } + .empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } + `], +}) +export class ImageFindingsTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + private readonly severities = ['critical', 'high', 'medium', 'low']; + private readonly reachStates = ['reachable', 'unreachable', 'unknown']; + + readonly selectedSeverities = signal(new Set(this.severities)); + readonly selectedReach = signal(new Set(this.reachStates)); + + readonly severityOptions = signal( + this.severities.map(s => ({ id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: true })) + ); + readonly reachOptions = signal( + this.reachStates.map(s => ({ id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: true })) + ); + + readonly findings = signal([ + { id: 'f-001', cveId: 'CVE-2024-12345', severity: 'critical', packageName: 'lodash', version: '4.17.20', fixedVersion: '4.17.21', reachable: true, vexStatus: 'affected', firstSeen: '2025-01-15' }, + { id: 'f-002', cveId: 'CVE-2024-67890', severity: 'high', packageName: 'express', version: '4.17.1', fixedVersion: '4.18.0', reachable: false, vexStatus: 'not_affected', firstSeen: '2025-01-10' }, + { id: 'f-003', cveId: 'CVE-2024-11111', severity: 'medium', packageName: 'openssl', version: '1.1.1k', fixedVersion: '1.1.1l', reachable: null, vexStatus: null, firstSeen: '2025-02-01' }, + { id: 'f-004', cveId: 'CVE-2024-22222', severity: 'critical', packageName: 'curl', version: '7.79.0', fixedVersion: '7.80.0', reachable: true, vexStatus: 'investigating', firstSeen: '2025-02-15' }, + { id: 'f-005', cveId: 'CVE-2024-33333', severity: 'low', packageName: 'zlib', version: '1.2.11', fixedVersion: null, reachable: false, vexStatus: 'not_affected', firstSeen: '2025-03-01' }, + ]); + + readonly filteredFindings = signal([]); + + constructor() { + effect(() => { + this.scope.effectiveScope(); // react to scope changes + this.applyFilters(); + }); + this.applyFilters(); + } + + onSeverityChange(opts: FilterMultiOption[]): void { + this.selectedSeverities.set(new Set(opts.filter(o => o.checked).map(o => o.id))); + this.applyFilters(); + } + + onReachChange(opts: FilterMultiOption[]): void { + this.selectedReach.set(new Set(opts.filter(o => o.checked).map(o => o.id))); + this.applyFilters(); + } + + private applyFilters(): void { + const sevs = this.selectedSeverities(); + const reach = this.selectedReach(); + this.filteredFindings.set( + this.findings().filter(f => { + if (!sevs.has(f.severity)) return false; + const r = f.reachable === true ? 'reachable' : f.reachable === false ? 'unreachable' : 'unknown'; + return reach.has(r); + }) + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-reachability-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-reachability-tab.component.ts new file mode 100644 index 000000000..93b5c2e11 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-reachability-tab.component.ts @@ -0,0 +1,117 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; + +interface ReachabilityEntry { + cveId: string; + packageName: string; + verdict: 'reachable' | 'unreachable' | 'uncertain'; + confidence: number; + callPath: string | null; +} + +@Component({ + selector: 'app-image-reachability-tab', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ {{ reachableCount() }} + Reachable +
+
+ {{ unreachableCount() }} + Unreachable +
+
+ {{ uncertainCount() }} + Uncertain +
+
+ {{ coveragePct() }}% + Coverage +
+
+ + +
+ + + + + + + + + + + + @for (e of entries(); track e.cveId) { + + + + + + + + } @empty { + + } + +
CVEPackageVerdictConfidenceCall Path
{{ e.cveId }}{{ e.packageName }} + {{ e.verdict }} + {{ (e.confidence * 100).toFixed(0) }}%{{ e.callPath ?? '—' }}
No reachability data for current scope.
+
+
+ `, + styles: [` + .reachability { display: grid; gap: 1rem; } + .reach-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; } + .reach-stat { padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; } + .reach-stat--reachable { border-top: 3px solid var(--color-status-error); } + .reach-stat--unreachable { border-top: 3px solid var(--color-status-success); } + .reach-stat--uncertain { border-top: 3px solid var(--color-status-warning); } + .reach-stat__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .reach-stat__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } + .data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + .mono { font-family: ui-monospace, monospace; } + .cve { font-weight: var(--font-weight-semibold); color: var(--color-text-link); } + .path { font-size: 0.75rem; color: var(--color-text-secondary); max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .verdict-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; } + .verdict-badge--reachable { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .verdict-badge--unreachable { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .verdict-badge--uncertain { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } + `], +}) +export class ImageReachabilityTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + readonly entries = signal([ + { cveId: 'CVE-2024-12345', packageName: 'lodash', verdict: 'reachable', confidence: 0.95, callPath: 'app.js → handler.js → lodash.merge()' }, + { cveId: 'CVE-2024-67890', packageName: 'express', verdict: 'unreachable', confidence: 0.88, callPath: null }, + { cveId: 'CVE-2024-11111', packageName: 'openssl', verdict: 'uncertain', confidence: 0.42, callPath: 'libssl.so → TLS_connect()' }, + { cveId: 'CVE-2024-22222', packageName: 'curl', verdict: 'reachable', confidence: 0.91, callPath: 'fetch.ts → http.request() → curl_easy_perform()' }, + { cveId: 'CVE-2024-33333', packageName: 'zlib', verdict: 'unreachable', confidence: 0.97, callPath: null }, + ]); + + readonly reachableCount = () => this.entries().filter(e => e.verdict === 'reachable').length; + readonly unreachableCount = () => this.entries().filter(e => e.verdict === 'unreachable').length; + readonly uncertainCount = () => this.entries().filter(e => e.verdict === 'uncertain').length; + readonly coveragePct = () => { + const total = this.entries().length; + if (total === 0) return 0; + const known = this.entries().filter(e => e.verdict !== 'uncertain').length; + return Math.round((known / total) * 100); + }; +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-sbom-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-sbom-tab.component.ts new file mode 100644 index 000000000..6ac8fc96c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-sbom-tab.component.ts @@ -0,0 +1,117 @@ +import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; + +interface SbomComponent { + name: string; + version: string; + type: 'library' | 'framework' | 'os' | 'tool'; + license: string; + vulnCount: number; + identified: boolean; +} + +@Component({ + selector: 'app-image-sbom-tab', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ {{ components().length }} + Components +
+
+ {{ identifiedCount() }} + Identified +
+
+ {{ unknownCount() }} + Unknown +
+
+ {{ licenseCount() }} + Licenses +
+
+ + +
+ + + + + + + + + + + + + @for (c of components(); track c.name) { + + + + + + + + + } + +
ComponentVersionTypeLicenseVulnerabilitiesStatus
{{ c.name }}{{ c.version }}{{ c.type }}{{ c.license }} + @if (c.vulnCount > 0) { + {{ c.vulnCount }} + } @else { + 0 + } + + @if (c.identified) { Identified } + @else { Unknown } +
+
+
+ `, + styles: [` + .sbom { display: grid; gap: 1rem; } + .sbom-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; } + .sbom-stat { padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; } + .sbom-stat--warn { border-left: 3px solid var(--color-status-warning); } + .sbom-stat__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .sbom-stat__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } + .data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + .mono { font-family: ui-monospace, monospace; } + .type-badge { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; } + .vuln-count { font-weight: var(--font-weight-semibold); color: var(--color-status-error-text); } + .vuln-none { color: var(--color-text-secondary); } + .status-ok { font-size: 0.6875rem; color: var(--color-status-success-text); } + .status-unknown { font-size: 0.6875rem; color: var(--color-status-warning-text); } + `], +}) +export class ImageSbomTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + readonly components = signal([ + { name: 'lodash', version: '4.17.20', type: 'library', license: 'MIT', vulnCount: 1, identified: true }, + { name: 'express', version: '4.17.1', type: 'framework', license: 'MIT', vulnCount: 1, identified: true }, + { name: 'openssl', version: '1.1.1k', type: 'library', license: 'Apache-2.0', vulnCount: 1, identified: true }, + { name: 'node', version: '18.17.0', type: 'tool', license: 'MIT', vulnCount: 0, identified: true }, + { name: 'libunknown', version: '0.1.0', type: 'library', license: 'Unknown', vulnCount: 0, identified: false }, + { name: 'zlib', version: '1.2.11', type: 'library', license: 'Zlib', vulnCount: 1, identified: true }, + { name: 'curl', version: '7.79.0', type: 'tool', license: 'MIT', vulnCount: 1, identified: true }, + ]); + + readonly identifiedCount = () => this.components().filter(c => c.identified).length; + readonly unknownCount = () => this.components().filter(c => !c.identified).length; + readonly licenseCount = () => new Set(this.components().map(c => c.license)).size; +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-summary-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-summary-tab.component.ts new file mode 100644 index 000000000..5cd7f3a6b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-summary-tab.component.ts @@ -0,0 +1,194 @@ +import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; + +@Component({ + selector: 'app-image-summary-tab', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ {{ gateStatus() | uppercase }} + Release Gate +
+
+ {{ riskTier() }} + Risk Tier +
+
+ {{ evidencePosture() }} + Evidence Posture +
+
+ + +
+
+ {{ metrics().critical }} + Critical +
+
+ {{ metrics().high }} + High +
+
+ {{ metrics().medium }} + Medium +
+
+ {{ metrics().low }} + Low +
+
+ + +
+
+
+ Reachability + {{ reachability().coverage }}% +
+
+
+
+ {{ reachability().reachable }} confirmed reachable +
+
+
+ SBOM Completeness + {{ sbom().identified }} / {{ sbom().total }} +
+
+
+
+ {{ sbom().unknown }} unknown components +
+
+
+ VEX Coverage + {{ vex().covered }} / {{ vex().total }} +
+
+
+
+ {{ vex().notAffected }} not affected, {{ vex().investigating }} investigating +
+
+ + +
+ + +
+
+ `, + styles: [` + .summary { display: grid; gap: 1rem; } + + .gate-row { display: flex; gap: 0.75rem; } + .gate-card { + flex: 1; padding: 1rem 1.25rem; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); text-align: center; + } + .gate-card--pass { border-left: 4px solid var(--color-status-success); } + .gate-card--warn { border-left: 4px solid var(--color-status-warning); } + .gate-card--block { border-left: 4px solid var(--color-status-error); } + .gate-card--pending { border-left: 4px solid var(--color-text-secondary); } + .gate-card--info { border-left: 4px solid var(--color-status-info); } + .gate-card__status { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .gate-card__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + + .metrics-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; } + .metric-card { + padding: 0.75rem 1rem; background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; + } + .metric-card--critical { border-top: 3px solid var(--color-status-error); } + .metric-card--high { border-top: 3px solid var(--color-severity-high, var(--color-status-warning)); } + .metric-card--medium { border-top: 3px solid var(--color-status-warning); } + .metric-card--low { border-top: 3px solid var(--color-status-info); } + .metric-card__value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .metric-card__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; } + + .coverage-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } + .coverage-card { + padding: 1rem; background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); + } + .coverage-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } + .coverage-card__title { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .coverage-card__value { font-size: 0.875rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .coverage-bar { height: 6px; background: var(--color-surface-tertiary); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; } + .coverage-bar__fill { height: 100%; border-radius: 3px; background: var(--color-brand-primary); transition: width 0.3s ease; } + .coverage-bar__fill--success { background: var(--color-status-success); } + .coverage-bar__fill--info { background: var(--color-status-info); } + .coverage-card__detail { font-size: 0.75rem; color: var(--color-text-secondary); } + + .scan-section { + display: flex; justify-content: space-between; align-items: center; + padding: 0.75rem 1rem; background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); + } + .scan-section__info { display: flex; gap: 0.5rem; align-items: center; } + .scan-section__label { font-size: 0.75rem; color: var(--color-text-secondary); } + .scan-section__value { font-size: 0.8125rem; color: var(--color-text-primary); } + .btn { + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.375rem 0.75rem; border-radius: var(--radius-md); + font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; + border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary); + transition: opacity 150ms ease; + } + .btn:hover:not(:disabled) { opacity: 0.85; } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + .btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + `], +}) +export class ImageSummaryTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + readonly scanning = signal(false); + readonly gateStatus = signal<'pass' | 'warn' | 'block' | 'pending'>('pass'); + readonly riskTier = signal('Medium'); + readonly evidencePosture = signal('Verified'); + readonly lastScanned = signal('2 hours ago'); + readonly metrics = signal({ critical: 3, high: 12, medium: 45, low: 89 }); + readonly reachability = signal({ coverage: 67, reachable: 2, unreachable: 13 }); + readonly sbom = signal({ total: 342, identified: 335, unknown: 7 }); + readonly vex = signal({ total: 60, covered: 48, notAffected: 38, investigating: 10 }); + + readonly sbomPct = () => { + const s = this.sbom(); + return s.total > 0 ? (s.identified / s.total) * 100 : 0; + }; + readonly vexPct = () => { + const v = this.vex(); + return v.total > 0 ? (v.covered / v.total) * 100 : 0; + }; + + constructor() { + effect(() => { + const s = this.scope.effectiveScope(); + // TODO: Load real data from Score, Findings, Reachability APIs based on scope + }); + } + + triggerScan(): void { + this.scanning.set(true); + setTimeout(() => this.scanning.set(false), 3000); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-vex-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-vex-tab.component.ts new file mode 100644 index 000000000..bc39484ae --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-vex-tab.component.ts @@ -0,0 +1,121 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { ImageSecurityScopeService } from '../image-security-scope.service'; + +interface VexEntry { + cveId: string; + status: 'affected' | 'not_affected' | 'fixed' | 'under_investigation'; + justification: string | null; + source: string; + updatedAt: string; +} + +@Component({ + selector: 'app-image-vex-tab', + standalone: true, + imports: [CommonModule, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ {{ notAffectedCount() }} + Not Affected +
+
+ {{ fixedCount() }} + Fixed +
+
+ {{ investigatingCount() }} + Investigating +
+
+ {{ affectedCount() }} + Affected +
+ Manage VEX Statements +
+ + +
+ + + + + + + + + + + + @for (v of entries(); track v.cveId) { + + + + + + + + } @empty { + + } + +
CVEStatusJustificationSourceUpdated
{{ v.cveId }}{{ formatStatus(v.status) }}{{ v.justification ?? '—' }}{{ v.source }}{{ v.updatedAt }}
No VEX statements for this image's CVEs.
+
+
+ `, + styles: [` + .vex-tab { display: grid; gap: 1rem; } + .vex-summary { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } + .vex-chip { padding: 0.5rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; min-width: 100px; } + .vex-chip--not-affected { border-top: 3px solid var(--color-status-success); } + .vex-chip--fixed { border-top: 3px solid var(--color-status-info); } + .vex-chip--investigating { border-top: 3px solid var(--color-status-warning); } + .vex-chip--affected { border-top: 3px solid var(--color-status-error); } + .vex-chip__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } + .vex-chip__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; } + .vex-action { margin-left: auto; font-size: 0.8125rem; color: var(--color-text-link); text-decoration: none; } + .vex-action:hover { text-decoration: underline; } + .table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } + .data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + .mono { font-family: ui-monospace, monospace; } + .cve { font-weight: var(--font-weight-semibold); color: var(--color-text-link); } + .muted { color: var(--color-text-secondary); } + .status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; } + .status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .status-badge--not_affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .status-badge--fixed { background: var(--color-status-info-bg, var(--color-severity-low-bg)); color: var(--color-status-info-text); } + .status-badge--under_investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } + `], +}) +export class ImageVexTabComponent { + private readonly scope = inject(ImageSecurityScopeService); + + readonly entries = signal([ + { cveId: 'CVE-2024-12345', status: 'affected', justification: null, source: 'Acme Corp', updatedAt: '2025-01-15' }, + { cveId: 'CVE-2024-67890', status: 'not_affected', justification: 'Vulnerable code not in execute path', source: 'Internal', updatedAt: '2025-01-20' }, + { cveId: 'CVE-2024-11111', status: 'under_investigation', justification: null, source: 'Internal', updatedAt: '2025-02-01' }, + { cveId: 'CVE-2024-22222', status: 'affected', justification: null, source: 'Vendor', updatedAt: '2025-02-15' }, + { cveId: 'CVE-2024-33333', status: 'not_affected', justification: 'Component not present', source: 'Internal', updatedAt: '2025-03-01' }, + ]); + + readonly notAffectedCount = () => this.entries().filter(e => e.status === 'not_affected').length; + readonly fixedCount = () => this.entries().filter(e => e.status === 'fixed').length; + readonly investigatingCount = () => this.entries().filter(e => e.status === 'under_investigation').length; + readonly affectedCount = () => this.entries().filter(e => e.status === 'affected').length; + + formatStatus(status: string): string { + const labels: Record = { affected: 'Affected', not_affected: 'Not Affected', fixed: 'Fixed', under_investigation: 'Investigating' }; + return labels[status] || status; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts index d3d81e03c..31ee48ece 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts @@ -129,6 +129,39 @@ export const integrationHubRoutes: Routes = [ import('../sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES), }, + { + path: 'symbol-sources', + title: 'Symbol & Debug Sources', + data: { breadcrumb: 'Symbol Sources' }, + loadComponent: () => + import('../security-risk/symbol-sources/symbol-sources-list.component').then( + (m) => m.SymbolSourcesListComponent), + }, + { + path: 'symbol-sources/:sourceId', + title: 'Symbol Source Detail', + data: { breadcrumb: 'Symbol Source' }, + loadComponent: () => + import('../security-risk/symbol-sources/symbol-source-detail.component').then( + (m) => m.SymbolSourceDetailComponent), + }, + { + path: 'symbol-marketplace', + title: 'Symbol Marketplace', + data: { breadcrumb: 'Symbol Marketplace' }, + loadComponent: () => + import('../security-risk/symbol-sources/symbol-marketplace-catalog.component').then( + (m) => m.SymbolMarketplaceCatalogComponent), + }, + + { + path: 'scanner-config', + title: 'Scanner Configuration', + data: { breadcrumb: 'Scanner Config' }, + loadChildren: () => + import('../scanner-ops/scanner-ops.routes').then((m) => m.scannerOpsRoutes), + }, + { path: 'activity', title: 'Activity', diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts index b9559e65f..f6a4dd01d 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts @@ -1,32 +1,86 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, + inject, + signal, } from '@angular/core'; -import { - RouterOutlet, -} from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type PolicyView = 'packs' | 'governance' | 'vex' | 'simulation' | 'audit'; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'packs', label: 'Release Policies', icon: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2|||M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2|||M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2' }, + { id: 'governance', label: 'Governance', icon: 'M12 6V4m0 2a2 2 0 1 0 0 4m0-4a2 2 0 1 1 0 4m-6 8a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V4' }, + { id: 'vex', label: 'VEX & Exceptions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'simulation', label: 'Simulation', icon: 'M14.752 11.168l-3.197-2.132A1 1 0 0 0 10 9.87v4.263a1 1 0 0 0 1.555.832l3.197-2.132a1 1 0 0 0 0-1.664z|||M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z' }, + { id: 'audit', label: 'Audit', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z' }, +]; @Component({ selector: 'app-policy-decisioning-shell', - imports: [ - RouterOutlet, - ], + imports: [RouterOutlet, StellaPageTabsComponent], template: ` -
- +
+
+

Release Policies

+

Define gates, manage exceptions, and simulate policy impact.

+
+ + + +
`, styles: [` - :host { - display: block; - min-height: 100%; - } - - .policy-decisioning-shell { - display: grid; - gap: 0; - } + :host { display: block; min-height: 100%; } + .policy-shell { display: grid; gap: 0.85rem; } + .policy-shell__header { margin-bottom: 0.5rem; } + .policy-shell__title { font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); margin: 0 0 0.25rem; } + .policy-shell__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PolicyDecisioningShellComponent {} +export class PolicyDecisioningShellComponent { + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeView = signal('packs'); + + constructor() { + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.activeView.set(this.readView())); + } + + onTabChange(tabId: string): void { + this.activeView.set(tabId as PolicyView); + const route = tabId === 'packs' + ? '/ops/policy/packs' + : tabId === 'vex' + ? '/ops/policy/vex' + : `/ops/policy/${tabId}`; + void this.router.navigate([route]); + } + + private readView(): PolicyView { + const url = this.router.url.split('?')[0] ?? ''; + if (url.includes('/policy/governance') || url.includes('/policy/risk-budget') || url.includes('/policy/budget') || url.includes('/policy/trust-weights') || url.includes('/policy/staleness') || url.includes('/policy/sealed-mode') || url.includes('/policy/profiles') || url.includes('/policy/validator') || url.includes('/policy/conflicts') || url.includes('/policy/impact-preview') || url.includes('/policy/schema')) return 'governance'; + if (url.includes('/policy/vex') || url.includes('/policy/waivers') || url.includes('/policy/exceptions')) return 'vex'; + if (url.includes('/policy/simulation')) return 'simulation'; + if (url.includes('/policy/audit')) return 'audit'; + return 'packs'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts index b92299c33..573ffafe6 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts @@ -22,9 +22,7 @@ import { import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type VexSubview = - | 'dashboard' | 'search' - | 'create' | 'stats' | 'consensus' | 'explorer' @@ -32,9 +30,7 @@ type VexSubview = | 'exceptions'; const PAGE_TABS: readonly StellaPageTab[] = [ - { id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, { id: 'search', label: 'Search', icon: 'M11 11m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0|||M21 21l-4.35-4.35' }, - { id: 'create', label: 'Create', icon: 'M12 5v14|||M5 12h14' }, { id: 'stats', label: 'Stats', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, { id: 'consensus', label: 'Consensus', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, { id: 'explorer', label: 'Explorer', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, @@ -70,7 +66,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [ gap: 0.85rem; } - .vex-shell__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; } + .vex-shell__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } .vex-shell__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; } .vex-shell__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; } `], @@ -86,15 +82,13 @@ export class PolicyDecisioningVexShellComponent { protected readonly activeSubtitle = computed(() => { switch (this.activeSubview()) { - case 'dashboard': return 'VEX statement overview and activity feed.'; case 'search': return 'Search VEX statements across all sources.'; - case 'create': return 'Create new VEX statements for findings.'; case 'stats': return 'VEX coverage and statement metrics.'; case 'consensus': return 'Multi-source consensus resolution.'; case 'explorer': return 'Browse and inspect VEX data.'; case 'conflicts': return 'VEX statement conflicts and resolution.'; case 'exceptions': return 'Policy exception queue management.'; - default: return 'VEX statement overview and activity feed.'; + default: return 'Search VEX statements across all sources.'; } }); @@ -121,8 +115,8 @@ export class PolicyDecisioningVexShellComponent { returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), }); - // 'dashboard' is the VEX root — navigate to /ops/policy/vex without a sub-segment - const segments = tabId === 'dashboard' + // 'search' is the VEX root — navigate to /ops/policy/vex without a sub-segment + const segments = tabId === 'search' ? ['/ops/policy/vex'] : ['/ops/policy/vex', tabId]; @@ -135,9 +129,6 @@ export class PolicyDecisioningVexShellComponent { if (url.includes('/vex/search')) { return 'search'; } - if (url.includes('/vex/create')) { - return 'create'; - } if (url.includes('/vex/stats')) { return 'stats'; } @@ -154,7 +145,7 @@ export class PolicyDecisioningVexShellComponent { return 'exceptions'; } - return 'dashboard'; + return 'search'; } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts index 538235c8e..3ff61c7ed 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts @@ -289,8 +289,8 @@ export const policyDecisioningRoutes: Routes = [ { path: '', loadComponent: () => - import('../vex-hub/vex-hub-dashboard.component').then( - (m) => m.VexHubDashboardComponent, + import('../vex-hub/vex-statement-search.component').then( + (m) => m.VexStatementSearchComponent, ), }, { @@ -315,8 +315,8 @@ export const policyDecisioningRoutes: Routes = [ { path: 'create', loadComponent: () => - import('../vex-hub/vex-create-workflow.component').then( - (m) => m.VexCreateWorkflowComponent, + import('../vex-hub/vex-create-page.component').then( + (m) => m.VexCreatePageComponent, ), }, { @@ -343,8 +343,8 @@ export const policyDecisioningRoutes: Routes = [ { path: 'conflicts', loadComponent: () => - import('../vex-hub/vex-conflict-resolution.component').then( - (m) => m.VexConflictResolutionComponent, + import('../vex-hub/vex-conflicts-page.component').then( + (m) => m.VexConflictsPageComponent, ), }, { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-consent-gate.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-consent-gate.component.ts index 00000dc9b..108f05ade 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-consent-gate.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-consent-gate.component.ts @@ -229,7 +229,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -401,7 +401,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode } .checkbox-label { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; } @@ -472,7 +472,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode .btn--ghost { background: transparent; border: 1px solid var(--color-border-primary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn--ghost:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts index dd9773590..efb845fe9 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts @@ -286,7 +286,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -353,7 +353,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. .loading-state p { margin: 0; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 1rem; font-weight: var(--font-weight-medium); } @@ -388,7 +388,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. .summary-text { margin: 0; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.9375rem; line-height: 1.7; } @@ -436,7 +436,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. } .impact-value { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; } @@ -459,7 +459,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. background: var(--color-surface-elevated); border-radius: var(--radius-sm); font-size: 0.75rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* Version Info */ @@ -485,7 +485,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. .version-value { font-family: ui-monospace, monospace; font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); background: var(--color-surface-elevated); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); @@ -519,7 +519,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. /* Technical Details */ .technical-details { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; line-height: 1.7; white-space: pre-wrap; @@ -625,7 +625,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. .btn--secondary { background: var(--color-surface-tertiary); border: 1px solid var(--color-border-primary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn--secondary:hover { @@ -638,7 +638,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. } .btn--ghost:hover { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } `], }) diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-justify-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-justify-panel.component.ts index 3db1ec75a..8a46f75f5 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-justify-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-justify-panel.component.ts @@ -393,7 +393,7 @@ type JustificationType = .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -436,7 +436,7 @@ type JustificationType = margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .context-grid { @@ -485,7 +485,7 @@ type JustificationType = .loading-state p { margin: 0; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 1rem; font-weight: var(--font-weight-medium); } @@ -560,7 +560,7 @@ type JustificationType = .confidence-label { font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .confidence-hint { @@ -611,7 +611,7 @@ type JustificationType = margin: 0; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-copy { @@ -630,7 +630,7 @@ type JustificationType = .btn-copy:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-copy svg { @@ -640,7 +640,7 @@ type JustificationType = .draft-text { padding: 1rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.9375rem; line-height: 1.7; min-height: 120px; @@ -662,7 +662,7 @@ type JustificationType = margin: 0 0 0.75rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .evidence-list { @@ -682,7 +682,7 @@ type JustificationType = background: var(--color-surface-elevated); border-radius: var(--radius-md); font-size: 0.8125rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .evidence-item svg { @@ -704,7 +704,7 @@ type JustificationType = margin: 0 0 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .checklist-hint { @@ -760,7 +760,7 @@ type JustificationType = } .checklist-item span:last-child { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; } @@ -867,7 +867,7 @@ type JustificationType = } .btn--ghost:hover { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } `], }) diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts index c7aa49ee3..4a4029a8b 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts @@ -381,7 +381,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -427,7 +427,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .loading-state p { margin: 0; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 1rem; font-weight: var(--font-weight-medium); } @@ -454,7 +454,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { } .context-item strong { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* Recommendations */ @@ -506,7 +506,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { align-items: center; justify-content: center; font-weight: var(--font-weight-bold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); flex-shrink: 0; } @@ -532,7 +532,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .recommendation-description { margin: 0; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; line-height: 1.5; } @@ -619,7 +619,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8125rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); white-space: nowrap; } @@ -640,7 +640,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .btn-copy:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-copy svg { @@ -685,7 +685,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .notes-list { margin: 0; padding-left: 1.25rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; } @@ -817,7 +817,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { .btn--secondary { background: var(--color-surface-tertiary); border: 1px solid var(--color-border-primary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn--secondary:hover { @@ -830,7 +830,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { } .btn--ghost:hover { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* PR Section - Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring */ @@ -917,7 +917,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { cursor: pointer; } - .btn-copy-link:hover { background: var(--color-surface-tertiary); color: rgba(212, 201, 168, 0.3); } + .btn-copy-link:hover { background: var(--color-surface-tertiary); color: var(--color-text-heading); } .btn-copy-link svg { width: 16px; height: 16px; } .pr-creating-state { @@ -959,7 +959,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { background: var(--color-surface-tertiary); border: none; border-radius: var(--radius-sm); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.75rem; cursor: pointer; } @@ -984,7 +984,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = { background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.8125rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts index 2f3efaf7b..374548d11 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts @@ -343,7 +343,7 @@ interface ConflictingStatement { .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -439,7 +439,7 @@ interface ConflictingStatement { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .status-bars { @@ -487,7 +487,7 @@ interface ConflictingStatement { .status-count { font-size: 0.75rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); min-width: 24px; text-align: right; } @@ -501,7 +501,7 @@ interface ConflictingStatement { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .comparison-hint { @@ -569,7 +569,7 @@ interface ConflictingStatement { .issuer-name { font-size: 0.9375rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .issuer-type { @@ -612,7 +612,7 @@ interface ConflictingStatement { .justification-text { margin: 0 0 0.75rem; font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; @@ -678,7 +678,7 @@ interface ConflictingStatement { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .option-group { @@ -740,7 +740,7 @@ interface ConflictingStatement { .option-content strong { font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .option-content span { @@ -846,7 +846,7 @@ interface ConflictingStatement { } .btn--ghost:hover { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-spinner { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflicts-page.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflicts-page.component.ts new file mode 100644 index 000000000..5bef3e29a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflicts-page.component.ts @@ -0,0 +1,617 @@ +/** + * VEX Conflicts Page component. + * Page-level component for browsing and resolving VEX statement conflicts. + * Replaces the modal-only VexConflictResolutionComponent for the /conflicts route. + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + signal, + computed, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; + +import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client'; +import { + VexLensConflict, + VexStatementStatus, + VexIssuerType, + VexResolveConflictRequest, +} from '../../core/api/vex-hub.models'; + +@Component({ + selector: 'app-vex-conflicts-page', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ + +
+ + @if (loading()) { +
+
+

Searching for conflicts...

+
+ } @else if (conflicts().length > 0) { + +
+ + + + + + + + + + + + + + @for (conflict of conflicts(); track conflict.conflictId) { + + + + + + + + + + } + +
CVE IDSeverityPrimary ClaimConflictingStatusDetectedActions
{{ conflict.cveId }} + + {{ conflict.severity | uppercase }} + + +
+ {{ conflict.primaryClaim.issuerName }} + + {{ formatStatus(conflict.primaryClaim.status) }} + +
+
+
+ @for (claim of conflict.conflictingClaims; track claim.issuerId) { +
+ {{ claim.issuerName }} + + {{ formatStatus(claim.status) }} + +
+ } +
+
+ + {{ formatResolutionStatus(conflict.resolutionStatus) }} + + {{ conflict.detectedAt | date:'mediumDate' }} +
+ @if (conflict.resolutionStatus !== 'resolved') { + + } @else { + + } +
+
+ + +
+ } @else if (searched()) { +
+
+ +

No conflicts found

+

No conflicting VEX statements were found for this CVE.

+
+
+ } @else { +
+
+ +

VEX Conflict Resolution

+

Enter a CVE ID above to find conflicting VEX statements from multiple issuers.

+
+
+ } + + + @if (resolvingConflict()) { +
+
+
+
+

Resolve Conflict

+ {{ resolvingConflict()!.cveId }} +
+ +
+ +
+ +
+
+
Primary Claim
+
{{ resolvingConflict()!.primaryClaim.issuerName }}
+ + {{ formatStatus(resolvingConflict()!.primaryClaim.status) }} + +
Trust: {{ (resolvingConflict()!.primaryClaim.trustScore * 100).toFixed(0) }}%
+
+ + @for (claim of resolvingConflict()!.conflictingClaims; track claim.issuerId) { +
+
Conflicting
+
{{ claim.issuerName }}
+ + {{ formatStatus(claim.status) }} + +
Trust: {{ (claim.trustScore * 100).toFixed(0) }}%
+
+ } +
+ + @if (resolvingConflict()!.resolutionSuggestion) { +
+ Suggested Resolution: +

{{ resolvingConflict()!.resolutionSuggestion }}

+
+ } + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ } + + @if (error()) { +
+ ! + {{ error() }} + +
+ } +
+ `, + styles: [` + :host { display: block; } + + .conflicts-page { display: grid; gap: 1rem; } + + /* ---- Filter Bar ---- */ + .filter-bar { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + align-items: center; + } + .filter-bar__search { + position: relative; + flex: 1; + } + .filter-bar__search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-secondary); + } + .filter-bar__input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2.25rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + transition: border-color 150ms ease, box-shadow 150ms ease; + } + .filter-bar__input:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + .filter-bar__select { + padding: 0.5rem 2rem 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + } + + /* ---- Table ---- */ + .table-container { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + } + .data-table { width: 100%; border-collapse: collapse; } + .data-table th, .data-table td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border-primary); + } + .data-table th { + background: var(--color-surface-secondary); + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + position: sticky; + top: 0; + z-index: 1; + } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-nav-hover); } + .data-table td { font-size: 0.8125rem; } + + .cve-id { + font-family: ui-monospace, monospace; + font-weight: var(--font-weight-semibold); + color: var(--color-text-link); + } + .text-muted { color: var(--color-text-secondary); font-size: 0.8125rem; } + + .severity-badge { + display: inline-block; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.03em; + } + .severity-badge--low { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .severity-badge--medium { background: var(--color-severity-high-bg, var(--color-severity-medium-bg)); color: var(--color-severity-high, var(--color-status-warning-text)); } + .severity-badge--high { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + + .claim-cell { display: flex; flex-direction: column; gap: 0.25rem; } + .claim-row { display: flex; gap: 0.5rem; align-items: center; } + .claim-issuer { font-size: 0.8125rem; color: var(--color-text-primary); } + .claim-status { font-size: 0.6875rem; font-weight: var(--font-weight-semibold); } + .claim-status--affected { color: var(--color-status-error-text); } + .claim-status--not_affected { color: var(--color-status-success-text); } + .claim-status--fixed { color: var(--color-status-info-text); } + .claim-status--under_investigation { color: var(--color-status-warning-text); } + + .resolution-badge { + display: inline-block; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + } + .resolution-badge--unresolved { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .resolution-badge--pending_review { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .resolution-badge--resolved { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + + .table-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + .table-footer__count { font-size: 0.8125rem; color: var(--color-text-muted); } + + /* ---- Buttons ---- */ + .action-buttons { display: flex; gap: 0.5rem; } + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + } + .btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); } + .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } + .btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } + .btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--color-text-secondary); padding: 0.25rem; } + + /* ---- Empty & Loading ---- */ + .empty-state { + display: grid; + justify-items: center; + gap: 0.75rem; + padding: 2.75rem 1.5rem; + text-align: center; + } + .empty-state__icon { + width: 3rem; + height: 3rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + background: var(--color-brand-soft, var(--color-surface-secondary)); + color: var(--color-text-link); + font-size: 0.875rem; + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + } + .empty-state__title { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); } + .empty-state__description { margin: 0; color: var(--color-text-secondary); line-height: 1.6; max-width: 64ch; } + + .loading-state { + display: flex; flex-direction: column; align-items: center; + padding: 4rem 2rem; color: var(--color-text-muted); + } + .loading-spinner { + width: 40px; height: 40px; + border: 3px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; + margin-bottom: 1rem; + } + @keyframes spin { to { transform: rotate(360deg); } } + + /* ---- Error ---- */ + .error-banner { + display: flex; align-items: center; gap: 0.75rem; + padding: 1rem 1.25rem; + background: var(--color-status-error-bg); + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-lg); + color: var(--color-status-error-text); + } + .error-banner__icon { + width: 24px; height: 24px; + background: var(--color-status-error); color: #fff; + border-radius: var(--radius-full); + display: flex; align-items: center; justify-content: center; + font-weight: var(--font-weight-bold); font-size: 0.875rem; flex-shrink: 0; + } + + /* ---- Resolution Overlay ---- */ + .resolve-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.5); + display: flex; justify-content: center; align-items: center; + z-index: 1000; + } + .resolve-panel { + background: var(--color-surface-primary); + border-radius: var(--radius-lg); + max-width: 640px; width: 90%; max-height: 80vh; + overflow-y: auto; + } + .resolve-header { + display: flex; justify-content: space-between; align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border-primary); + } + .resolve-header h3 { margin: 0; font-size: 1.125rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); } + .resolve-cve { font-family: ui-monospace, monospace; font-size: 0.8125rem; color: var(--color-text-link); } + + .resolve-body { padding: 1.5rem; display: grid; gap: 1rem; } + + .claims-compare { display: flex; gap: 0.75rem; flex-wrap: wrap; } + .claim-card { + flex: 1; min-width: 180px; + padding: 1rem; + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + display: grid; gap: 0.5rem; + } + .claim-card--primary { border-left: 3px solid var(--color-brand-primary); } + .claim-card--conflict { border-left: 3px solid var(--color-status-error); } + .claim-card__label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-secondary); } + .claim-card__issuer { font-weight: var(--font-weight-semibold); color: var(--color-text-heading); } + .claim-card__trust { font-size: 0.75rem; color: var(--color-text-secondary); } + + .status-badge { + display: inline-block; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + width: fit-content; + } + .status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + .status-badge--not_affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .status-badge--fixed { background: var(--color-status-info-bg, var(--color-severity-low-bg)); color: var(--color-status-info-text); } + .status-badge--under_investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + + .suggestion-box { + padding: 0.75rem 1rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-lg); + border-left: 3px solid var(--color-status-info); + } + .suggestion-box strong { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; } + .suggestion-box p { margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--color-text-primary); } + + .resolve-form { display: grid; gap: 0.75rem; } + .form-group { display: flex; flex-direction: column; gap: 0.375rem; } + .form-group label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.025em; } + .resolve-textarea { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + resize: vertical; + } + + .resolve-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--color-border-primary); + display: flex; justify-content: flex-end; gap: 0.75rem; + } + `], +}) +export class VexConflictsPageComponent { + private readonly vexHubApi = inject(VEX_HUB_API); + private readonly route = inject(ActivatedRoute); + + readonly loading = signal(false); + readonly resolving = signal(false); + readonly error = signal(null); + readonly conflicts = signal([]); + readonly searched = signal(false); + readonly resolvingConflict = signal(null); + + cveInput = ''; + resolutionType: 'prefer' | 'supersede' | 'defer' = 'prefer'; + resolutionNotes = ''; + + constructor() { + const cveParam = this.route.snapshot.queryParamMap.get('cveId'); + if (cveParam) { + this.cveInput = cveParam; + this.searchConflicts(); + } + } + + async searchConflicts(): Promise { + const cve = this.cveInput.trim(); + if (!cve) { + this.error.set('Please enter a CVE ID'); + return; + } + + this.loading.set(true); + this.error.set(null); + this.searched.set(true); + + try { + const conflicts = await firstValueFrom(this.vexHubApi.getVexLensConflicts(cve)); + this.conflicts.set(conflicts); + } catch (err) { + this.conflicts.set([]); + this.error.set(err instanceof Error ? err.message : 'Failed to load conflicts'); + } finally { + this.loading.set(false); + } + } + + openResolve(conflict: VexLensConflict): void { + this.resolvingConflict.set(conflict); + this.resolutionType = 'prefer'; + this.resolutionNotes = ''; + } + + closeResolve(): void { + this.resolvingConflict.set(null); + } + + async submitResolution(): Promise { + const conflict = this.resolvingConflict(); + if (!conflict) return; + + this.resolving.set(true); + try { + await firstValueFrom( + this.vexHubApi.resolveConflict({ + cveId: conflict.cveId, + selectedStatementId: conflict.primaryClaim.statementId, + resolutionType: this.resolutionType, + notes: this.resolutionNotes, + }) + ); + this.closeResolve(); + await this.searchConflicts(); + } catch (err) { + this.error.set(err instanceof Error ? err.message : 'Failed to resolve conflict'); + } finally { + this.resolving.set(false); + } + } + + formatStatus(status: VexStatementStatus): string { + const labels: Record = { + affected: 'Affected', + not_affected: 'Not Affected', + fixed: 'Fixed', + under_investigation: 'Investigating', + }; + return labels[status] || status; + } + + formatResolutionStatus(status: string): string { + const labels: Record = { + unresolved: 'Unresolved', + pending_review: 'Pending Review', + resolved: 'Resolved', + }; + return labels[status] || status; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts index a3ac03f78..479fb290b 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts @@ -33,51 +33,28 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-
- -
-

VEX Consensus Viewer

-

- Analyze multi-issuer voting and resolve conflicts -

-
- - -
-
-
- - -
-
- - -
- + +
+ + +
@if (loading()) { @@ -298,156 +275,98 @@ import {
`, styles: [` - :host { display: block; min-height: 100%; } + :host { display: block; } .consensus-container { - max-width: 1000px; - margin: 0 auto; - padding: 1.5rem; - } - - .consensus-header { - margin-bottom: 1.5rem; - } - - .consensus-header__nav { - margin-bottom: 0.75rem; - } - - .btn-back { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.75rem; - background: transparent; - border: none; - color: var(--color-status-info-border); - font-size: 0.875rem; - cursor: pointer; - border-radius: var(--radius-sm); - transition: all 0.15s ease; - } - - .btn-back:hover { - background: var(--color-surface-tertiary); - color: var(--color-status-info-border); - } - - .btn-back svg { - width: 16px; - height: 16px; - } - - .consensus-header h1 { - margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-bold); - color: var(--color-text-heading); - } - - .consensus-header__subtitle { - margin: 0.25rem 0 0; - color: var(--color-text-muted); - font-size: 0.875rem; - } - - /* Search Panel */ - .search-panel { - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-xl); - padding: 1.25rem; - margin-bottom: 1.5rem; - } - - .search-row { - display: flex; + display: grid; gap: 1rem; - align-items: flex-end; + } + + /* Filter Bar (canonical horizontal pattern) */ + .filter-bar { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + align-items: center; flex-wrap: wrap; } - - .search-field { + .filter-bar__search { + position: relative; flex: 1; min-width: 200px; - display: flex; - flex-direction: column; - gap: 0.375rem; } - - .search-field label { - font-size: 0.75rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.025em; + .filter-bar__search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-secondary); } - - .search-field input { - padding: 0.625rem 0.875rem; - background: var(--color-surface-tertiary); + .filter-bar__input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - color: var(--color-text-heading); font-size: 0.875rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + transition: border-color 150ms ease, box-shadow 150ms ease; } - - .search-field input:focus { + .filter-bar__input:focus { outline: none; - border-color: var(--color-status-info); + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + .filter-bar__input--product { + padding-left: 0.75rem; + min-width: 160px; + flex: 0 1 auto; } + /* Buttons (canonical) */ .btn { display: inline-flex; align-items: center; + justify-content: center; gap: 0.5rem; - padding: 0.625rem 1.25rem; + padding: 0.375rem 0.75rem; border-radius: var(--radius-md); - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; - transition: all 0.15s ease; - border: none; + text-decoration: none; + transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); white-space: nowrap; } - - .btn svg { - width: 16px; - height: 16px; - } - - .btn--primary { - background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-info-text) 100%); - color: var(--color-btn-primary-text); - } - + .btn:hover { opacity: 0.9; transform: translateY(-1px); } + .btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn--ghost { background: transparent; border: 1px solid var(--color-border-primary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-primary); } - - .btn--ghost:hover { - background: var(--color-surface-tertiary); - } - + .btn--ghost:hover { background: var(--color-surface-tertiary); } .btn--text { background: transparent; - color: var(--color-status-info-border); + border: none; + color: var(--color-text-link); + font-weight: var(--font-weight-medium); } - .btn-link { background: none; border: none; - color: var(--color-status-info-border); + color: var(--color-text-link); font-size: 0.75rem; cursor: pointer; padding: 0; } - - .btn-link:hover { - text-decoration: underline; - } + .btn-link:hover { text-decoration: underline; } /* Loading */ .loading-state { @@ -478,7 +397,6 @@ import { border: 1px solid var(--color-border-primary); border-radius: var(--radius-xl); padding: 1.5rem; - margin-bottom: 1.5rem; } .summary-header { @@ -622,14 +540,13 @@ import { border: 1px solid var(--color-border-primary); border-radius: var(--radius-xl); padding: 1.5rem; - margin-bottom: 1.5rem; } .voting-section h2 { margin: 0 0 1.25rem; font-size: 1rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* Vote Distribution */ @@ -671,7 +588,7 @@ import { .vote-group__status { font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .vote-group__count { @@ -702,7 +619,7 @@ import { justify-content: space-between; align-items: center; padding: 1rem; - background: rgba(0, 0, 0, 0.2); + background: var(--color-surface-secondary); } .issuer-info { @@ -799,7 +716,7 @@ import { .metric-value { font-size: 0.8125rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-weight: var(--font-weight-semibold); } @@ -819,7 +736,7 @@ import { display: block; margin-top: 0.25rem; font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .vote-meta { @@ -839,14 +756,13 @@ import { border: 1px solid var(--color-border-primary); border-radius: var(--radius-xl); padding: 1.5rem; - margin-bottom: 1.5rem; } .hints-section h2 { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .hints-list { @@ -865,7 +781,7 @@ import { padding: 0.75rem; background: var(--color-surface-tertiary); border-radius: var(--radius-lg); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; } @@ -880,10 +796,9 @@ import { /* Conflicts Section */ .conflicts-section { background: var(--color-surface-elevated); - border: 1px solid var(--color-status-error-text); + border: 1px solid var(--color-status-error-border); border-radius: var(--radius-xl); padding: 1.5rem; - margin-bottom: 1.5rem; } .section-header { @@ -897,7 +812,7 @@ import { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .conflict-card { @@ -916,7 +831,7 @@ import { justify-content: space-between; align-items: center; padding: 0.75rem 1rem; - background: rgba(0, 0, 0, 0.2); + background: var(--color-surface-secondary); } .conflict-severity { @@ -970,7 +885,7 @@ import { .claim-issuer { font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .claim-status { @@ -1003,7 +918,7 @@ import { .conflict-suggestion p { margin: 0; font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* Error */ @@ -1012,22 +927,24 @@ import { align-items: center; gap: 0.75rem; padding: 1rem 1.25rem; - background: var(--color-status-error-text); - border: 1px solid var(--color-status-error-text); + background: var(--color-status-error-bg); + border: 1px solid var(--color-status-error-border); border-radius: var(--radius-lg); - color: var(--color-status-error-border); + color: var(--color-status-error-text); } .error-icon { width: 24px; height: 24px; - background: var(--color-status-error-text); + background: var(--color-status-error); + color: #fff; border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center; font-weight: var(--font-weight-bold); font-size: 0.875rem; + flex-shrink: 0; } `] }) @@ -1160,7 +1077,7 @@ export class VexConsensusComponent implements OnInit { viewStatement(statementId: string): void { this.statementSelected.emit(statementId); - this.router.navigate(['..', 'search', 'detail', statementId], { relativeTo: this.route }); + this.router.navigate(['/ops/policy/vex/search/detail', statementId]); } formatStatus(status: VexStatementStatus): string { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-page.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-page.component.ts new file mode 100644 index 000000000..5f2f0012a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-page.component.ts @@ -0,0 +1,206 @@ +/** + * VEX Create Page - renders VexCreateWorkflowComponent inline (not as modal) + * by overriding the overlay positioning. On close/create, navigates back. + */ + +import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core'; +import { Router } from '@angular/router'; + +import { VexCreateWorkflowComponent } from './vex-create-workflow.component'; +import { VexStatement } from '../../core/api/vex-hub.models'; + +@Component({ + selector: 'app-vex-create-page', + standalone: true, + imports: [VexCreateWorkflowComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ `, + styles: [` + /* Override modal overlay to render inline within the tab panel */ + .vex-create-page .workflow-overlay { + position: static !important; + background: none !important; + backdrop-filter: none !important; + padding: 0 !important; + z-index: auto !important; + display: block !important; + } + .vex-create-page .workflow-container { + max-width: 100% !important; + max-height: none !important; + border-radius: var(--radius-lg) !important; + box-shadow: none !important; + border: 1px solid var(--color-border-primary) !important; + background: var(--color-surface-primary) !important; + } + /* Fix buttons to canonical style */ + .vex-create-page .btn--primary { + background: var(--color-btn-primary-bg) !important; + color: var(--color-btn-primary-text) !important; + border: 1px solid var(--color-btn-primary-bg) !important; + border-radius: var(--radius-md) !important; + } + .vex-create-page .btn--primary:not(:disabled):hover { + opacity: 0.9 !important; + transform: translateY(-1px) !important; + box-shadow: none !important; + } + .vex-create-page .btn--secondary, + .vex-create-page .btn--ghost { + background: var(--color-surface-secondary) !important; + border: 1px solid var(--color-border-primary) !important; + color: var(--color-text-primary) !important; + border-radius: var(--radius-md) !important; + } + .vex-create-page .btn--text { + background: transparent !important; + border: none !important; + color: var(--color-text-link) !important; + } + /* Fix form inputs to canonical style */ + .vex-create-page input[type="text"], + .vex-create-page input[type="url"], + .vex-create-page textarea, + .vex-create-page select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + transition: border-color 150ms ease, box-shadow 150ms ease; + } + .vex-create-page input:focus, + .vex-create-page textarea:focus, + .vex-create-page select:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + .vex-create-page label { + display: block; + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.375rem; + } + /* Fix progress bar background */ + .vex-create-page .workflow-progress { + background: var(--color-surface-secondary) !important; + } + /* Fix step indicator active state */ + .vex-create-page .progress-step--active .step-indicator { + background: var(--color-btn-primary-bg) !important; + border-color: var(--color-btn-primary-bg) !important; + } + /* Fix error banner */ + .vex-create-page .workflow-error { + background: var(--color-status-error-bg) !important; + color: var(--color-status-error-text) !important; + } + /* Fix step indicator: completed = success, inactive = surface */ + .vex-create-page .step-indicator { + background: var(--color-surface-tertiary) !important; + border-color: var(--color-border-primary) !important; + color: var(--color-text-secondary) !important; + } + .vex-create-page .progress-step--active .step-indicator { + background: var(--color-btn-primary-bg) !important; + border-color: var(--color-btn-primary-bg) !important; + color: var(--color-btn-primary-text) !important; + } + .vex-create-page .progress-step--completed .step-indicator { + background: var(--color-status-success) !important; + border-color: var(--color-status-success) !important; + color: #fff !important; + } + .vex-create-page .progress-step--active .step-label { + color: var(--color-text-heading) !important; + } + .vex-create-page .progress-step--completed .step-label { + color: var(--color-status-success) !important; + } + /* Fix status option cards */ + .vex-create-page .status-option { + background: var(--color-surface-secondary) !important; + border-color: var(--color-border-primary) !important; + } + .vex-create-page .status-option:hover { + border-color: var(--color-text-secondary) !important; + } + .vex-create-page .status-option--selected.status-option--affected { + border-color: var(--color-status-error) !important; + background: var(--color-status-error-bg) !important; + } + .vex-create-page .status-option--selected.status-option--not_affected { + border-color: var(--color-status-success) !important; + background: var(--color-status-success-bg) !important; + } + .vex-create-page .status-option--selected.status-option--fixed { + border-color: var(--color-status-info) !important; + background: var(--color-status-info-bg) !important; + } + .vex-create-page .status-option--selected.status-option--under_investigation { + border-color: var(--color-status-warning) !important; + background: var(--color-status-warning-bg) !important; + } + .vex-create-page .status-icon { + background: var(--color-surface-tertiary) !important; + } + .vex-create-page .status-option--affected .status-icon { color: var(--color-status-error) !important; } + .vex-create-page .status-option--not_affected .status-icon { color: var(--color-status-success) !important; } + .vex-create-page .status-option--fixed .status-icon { color: var(--color-status-info) !important; } + .vex-create-page .status-option--under_investigation .status-icon { color: var(--color-status-warning) !important; } + /* Fix justification type buttons */ + .vex-create-page .justification-type-btn { + background: var(--color-surface-secondary) !important; + border-color: var(--color-border-primary) !important; + } + .vex-create-page .justification-type-btn--selected { + border-color: var(--color-brand-primary) !important; + background: var(--color-brand-soft, var(--color-surface-tertiary)) !important; + } + /* Fix review header backgrounds */ + .vex-create-page .review-header--affected { background: var(--color-status-error-bg) !important; } + .vex-create-page .review-header--not_affected { background: var(--color-status-success-bg) !important; } + .vex-create-page .review-header--fixed { background: var(--color-status-info-bg) !important; } + .vex-create-page .review-header--under_investigation { background: var(--color-status-warning-bg) !important; } + /* Fix submit notice */ + .vex-create-page .submit-notice { + background: var(--color-brand-soft, var(--color-surface-secondary)) !important; + border-color: var(--color-border-emphasis) !important; + color: var(--color-text-primary) !important; + } + /* Fix workflow header */ + .vex-create-page .workflow-header { + background: var(--color-surface-secondary) !important; + } + /* Fix footer */ + .vex-create-page .workflow-footer { + background: var(--color-surface-secondary) !important; + } + `], +}) +export class VexCreatePageComponent { + private readonly router = inject(Router); + + onClose(): void { + this.router.navigate(['/ops/policy/vex']); + } + + onCreated(stmt: VexStatement): void { + this.router.navigate(['/ops/policy/vex/search/detail', stmt.id]); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-workflow.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-workflow.component.ts index 10912f098..4cd166fff 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-workflow.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-create-workflow.component.ts @@ -80,30 +80,52 @@ interface EvidenceItem {

Identify Vulnerability

- Enter the CVE identifier and affected product reference. + Select the CVE and product affected.

-
+
- Format: CVE-YYYY-NNNNN + @if (cveSuggestions().length > 0 && showCveSuggestions()) { +
    + @for (cve of cveSuggestions(); track cve) { +
  • {{ cve }}
  • + } +
+ }
- + + +
+ +
+ - Image reference or PURL + Full image reference including tag
@@ -229,26 +251,14 @@ interface EvidenceItem {
-
- -
@if (form.status === 'fixed') { @@ -519,7 +529,7 @@ interface EvidenceItem { .btn-close:hover { background: var(--color-surface-tertiary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn-close svg { @@ -533,7 +543,7 @@ interface EvidenceItem { justify-content: space-between; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--color-border-primary); - background: rgba(30, 41, 59, 0.5); + background: var(--color-surface-secondary); } .progress-step { @@ -566,9 +576,9 @@ interface EvidenceItem { } .progress-step--active .step-indicator { - background: linear-gradient(135deg, var(--color-status-info), var(--color-status-info-text)); - border-color: var(--color-status-info); - color: white; + background: var(--color-btn-primary-bg); + border-color: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .progress-step--completed .step-indicator { @@ -618,6 +628,39 @@ interface EvidenceItem { margin-bottom: 1.25rem; } + .form-group--autocomplete { + position: relative; + } + + .autocomplete-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + margin: 0.25rem 0 0; + padding: 0.25rem 0; + list-style: none; + background: var(--color-surface-elevated, var(--color-surface-primary)); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + max-height: 180px; + overflow-y: auto; + } + + .autocomplete-item { + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + font-family: ui-monospace, monospace; + color: var(--color-text-primary); + cursor: pointer; + } + + .autocomplete-item:hover { + background: var(--color-surface-tertiary, var(--color-surface-secondary)); + } + .form-hint { display: block; margin-top: 0.375rem; @@ -680,12 +723,12 @@ interface EvidenceItem { .status-option--selected.status-option--fixed { border-color: var(--color-status-info); - background: rgba(59, 130, 246, 0.1); + background: var(--color-status-info-bg); } .status-option--selected.status-option--under_investigation { border-color: var(--color-status-warning); - background: rgba(245, 158, 11, 0.1); + background: var(--color-status-warning-bg); } .status-icon { @@ -714,18 +757,18 @@ interface EvidenceItem { } .status-option--fixed .status-icon { - background: rgba(59, 130, 246, 0.2); + background: var(--color-status-info-bg); color: var(--color-status-info-border); } .status-option--under_investigation .status-icon { - background: rgba(245, 158, 11, 0.2); + background: var(--color-status-warning-bg); color: var(--color-status-warning-border); } .status-info strong { display: block; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.9375rem; margin-bottom: 0.25rem; } @@ -759,12 +802,12 @@ interface EvidenceItem { .justification-type-btn--selected { border-color: var(--color-status-excepted); - background: rgba(139, 92, 246, 0.1); + background: var(--color-brand-primary-20, var(--color-surface-tertiary)); } .justification-type-btn strong { display: block; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); font-size: 0.875rem; margin-bottom: 0.25rem; } @@ -807,10 +850,10 @@ interface EvidenceItem { font-weight: var(--font-weight-bold); } - .evidence-icon--sbom { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info-border); } + .evidence-icon--sbom { background: var(--color-status-info-bg); color: var(--color-status-info-border); } .evidence-icon--attestation { background: var(--color-brand-primary-20); color: var(--color-status-excepted-border); } - .evidence-icon--reachability { background: rgba(251, 191, 36, 0.2); color: var(--color-status-warning-border); } - .evidence-icon--advisory { background: rgba(244, 114, 182, 0.2); color: var(--color-status-excepted-border); } + .evidence-icon--reachability { background: var(--color-status-warning-bg); color: var(--color-status-warning-border); } + .evidence-icon--advisory { background: var(--color-brand-primary-20, var(--color-surface-tertiary)); color: var(--color-status-excepted-border); } .evidence-icon--other { background: var(--color-surface-tertiary); color: var(--color-text-muted); } .evidence-info { @@ -824,7 +867,7 @@ interface EvidenceItem { .evidence-title { font-size: 0.875rem; font-weight: var(--font-weight-medium); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .evidence-url { @@ -849,8 +892,8 @@ interface EvidenceItem { } .btn-remove:hover { - background: var(--color-status-error-text); - color: var(--color-status-error-border); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); } .btn-remove svg { @@ -868,7 +911,7 @@ interface EvidenceItem { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } /* Review */ @@ -887,8 +930,8 @@ interface EvidenceItem { .review-header--affected { background: var(--color-status-error-bg); } .review-header--not_affected { background: var(--color-status-success-bg); } - .review-header--fixed { background: rgba(59, 130, 246, 0.15); } - .review-header--under_investigation { background: rgba(245, 158, 11, 0.15); } + .review-header--fixed { background: var(--color-status-info-bg); } + .review-header--under_investigation { background: var(--color-status-warning-bg); } .review-status { font-size: 0.875rem; @@ -938,7 +981,7 @@ interface EvidenceItem { .review-value { font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } code.review-value { @@ -953,7 +996,7 @@ interface EvidenceItem { .review-text { margin: 0; font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); line-height: 1.6; } @@ -977,8 +1020,8 @@ interface EvidenceItem { gap: 0.75rem; margin-top: 1.5rem; padding: 1rem; - background: rgba(59, 130, 246, 0.1); - border: 1px solid rgba(59, 130, 246, 0.3); + background: var(--color-status-info-bg); + border: 1px solid var(--color-status-info-border); border-radius: var(--radius-xl); font-size: 0.8125rem; color: var(--color-status-info-border); @@ -1024,8 +1067,9 @@ interface EvidenceItem { } .btn--primary { - background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-info-text) 100%); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-bg); } .btn--primary:disabled { @@ -1034,14 +1078,14 @@ interface EvidenceItem { } .btn--primary:not(:disabled):hover { + opacity: 0.9; transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); } .btn--secondary { background: var(--color-surface-tertiary); border: 1px solid var(--color-border-primary); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn--secondary:disabled { @@ -1059,7 +1103,7 @@ interface EvidenceItem { } .btn--ghost:hover { - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .btn--text { @@ -1093,8 +1137,8 @@ interface EvidenceItem { align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; - background: var(--color-status-error-text); - color: var(--color-status-error-border); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); font-size: 0.875rem; } @@ -1122,7 +1166,13 @@ export class VexCreateWorkflowComponent { // Outputs readonly closed = output(); readonly statementCreated = output(); - readonly aiDraftRequested = output<{ cveId: string; productRef: string; status: VexStatementStatus }>(); + + // Repositories loaded from existing VEX statements + readonly knownRepos = signal([]); + readonly cveSuggestions = signal([]); + readonly showCveSuggestions = signal(false); + + selectedRepo = '*'; // State readonly currentStep = signal('vulnerability'); @@ -1169,13 +1219,24 @@ export class VexCreateWorkflowComponent { ); ngOnInit(): void { - // Initialize form with inputs if (this.initialCveId()) { this.form.cveId = this.initialCveId(); } if (this.initialProductRef()) { this.form.productRef = this.initialProductRef(); } + this.loadKnownRepos(); + } + + private loadKnownRepos(): void { + this.vexHubApi.searchStatements({ limit: 200 }).subscribe({ + next: (result) => { + const products = (result.items ?? []).map(s => s.productRef).filter(Boolean); + const repos = [...new Set(products.map(p => p.split(':')[0]))]; + this.knownRepos.set(repos); + }, + error: () => this.knownRepos.set([]), + }); } onBackdropClick(event: MouseEvent): void { @@ -1218,6 +1279,44 @@ export class VexCreateWorkflowComponent { return stepIdx < currentIdx; } + onCveType(value: string): void { + this.form.cveId = value; + if (value.length >= 3) { + this.showCveSuggestions.set(true); + this.vexHubApi.searchStatements({ cveId: value, limit: 20 }).subscribe({ + next: (result) => { + const cves = [...new Set((result.items ?? []).map(s => s.cveId))]; + this.cveSuggestions.set(cves); + }, + error: () => this.cveSuggestions.set([]), + }); + } else { + this.cveSuggestions.set([]); + this.showCveSuggestions.set(false); + } + } + + selectCve(cve: string): void { + this.form.cveId = cve; + this.showCveSuggestions.set(false); + this.cveSuggestions.set([]); + } + + onCveBlur(): void { + // Delay to allow mousedown on suggestion to fire first + setTimeout(() => this.showCveSuggestions.set(false), 200); + } + + onRepoChange(repo: string): void { + this.selectedRepo = repo; + if (repo !== '*') { + // Pre-fill the image reference with the repo prefix + if (!this.form.productRef.startsWith(repo)) { + this.form.productRef = repo + ':'; + } + } + } + canProceed(): boolean { switch (this.currentStep()) { case 'vulnerability': @@ -1226,11 +1325,11 @@ export class VexCreateWorkflowComponent { return !!this.form.status; case 'justification': if (this.form.status === 'not_affected') { - return !!this.form.justificationType && !!this.form.justification; + return !!this.form.justificationType; } return true; case 'evidence': - return true; // Evidence is optional + return true; case 'review': return true; default: @@ -1257,14 +1356,6 @@ export class VexCreateWorkflowComponent { this.form.evidence = this.form.evidence.filter((e) => e.id !== id); } - requestAiDraft(): void { - this.aiDraftRequested.emit({ - cveId: this.form.cveId, - productRef: this.form.productRef, - status: this.form.status, - }); - } - async submit(): Promise { this.submitting.set(true); this.error.set(null); diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts index be27112b1..111d47243 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts @@ -389,7 +389,7 @@ import { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-heading); } .section-header { @@ -439,7 +439,7 @@ import { } .source-card__label { color: var(--color-text-muted); } - .source-card__value { color: rgba(212, 201, 168, 0.3); font-weight: var(--font-weight-semibold); } + .source-card__value { color: var(--color-text-heading); font-weight: var(--font-weight-semibold); } /* Activity List */ .activity-list { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-stats.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-stats.component.ts index 4235b61e2..037e4b49f 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-stats.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-stats.component.ts @@ -1,6 +1,6 @@ /** * VEX Hub Statistics component. - * Implements VEX-AI-004: Statements by status, source breakdown, trends. + * Operational summary: what needs attention, source health, coverage gaps. */ import { CommonModule } from '@angular/common'; @@ -30,177 +30,122 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-
- -
-

VEX Hub Statistics

-

- Overview of VEX statement distribution, sources, and activity -

-
- @if (loading()) {

Loading statistics...

} @else if (stats()) { - -
-
-
- - - -
-
- {{ stats()!.totalStatements | number }} - Total Statements -
-
+ +
+ + {{ stats()!.byStatus['affected'] | number }} + Affected + Need remediation or exception + + + {{ stats()!.byStatus['under_investigation'] | number }} + Investigating + Awaiting triage decision + + + {{ stats()!.byStatus['not_affected'] | number }} + Not Affected + Cleared via justification + + + {{ stats()!.byStatus['fixed'] | number }} + Fixed + Remediated +
- -
-

Status Distribution

-
+ +
+
+

Coverage Ratio

+ {{ stats()!.totalStatements | number }} total statements +
+
@for (item of statusItems(); track item.status) { -
-
-
-
-
- {{ item.count | number }} - {{ formatStatus(item.status) }} - {{ getStatusPercentage(item.count).toFixed(1) }}% + @if (item.count > 0) { +
+ } + } +
+
+ @for (item of statusItems(); track item.status) { +
+ + {{ formatStatus(item.status) }} + {{ getStatusPercentage(item.count).toFixed(1) }}%
}
- -
-
- - Affected - Red -
-
- - Not Affected - Green -
-
- - Fixed - Blue -
-
- - Under Investigation - Yellow -
-
- +
-

Source Breakdown

-
+

Source Health

+
@for (item of sourceItems(); track item.source) {
-
- - {{ getSourceIcon(item.source) }} - - {{ formatSourceType(item.source) }} -
-
+ {{ formatSourceType(item.source) }} +
-
- {{ item.count | number }} - {{ getSourcePercentage(item.count).toFixed(1) }}% -
+ {{ item.count | number }}
}
- -
-

Recent Activity

- @if (stats()!.recentActivity.length) { + + @if (stats()!.recentActivity.length) { +
+
+

Recent Changes

+ Full audit trail +
@for (activity of stats()!.recentActivity; track activity.statementId) { -
-
- @switch (activity.action) { - @case ('created') { - - - - } - @case ('updated') { - - - - } - @case ('superseded') { - - - - } - } -
-
- {{ activity.cveId }} - {{ formatActivityAction(activity.action) }} -
+
+ + {{ formatActivityAction(activity.action) }} + + + {{ activity.cveId }} + {{ activity.timestamp | date:'short' }}
}
- } @else { -
-

No recent activity

-
- } -
+
+ } - + @if (stats()!.trends?.length) { -