Redesign security UX: unified Image Security page, VEX hub overhaul, nav simplification

Security nav restructured from 8 items to 4: Image Security, Triage Queue,
Risk Overview, Advisory Sources. New Image Security page at /security/images
with scope selectors (repo/image/release/environment) and 6 tabs (Summary,
Findings, SBOM, Reachability, VEX, Evidence).

VEX Hub: removed dashboard tab, moved create to button, fixed filters to use
stella-filter-multi, fixed all navigation to absolute paths, fixed 72+ hardcoded
rgba colors, created proper page components for conflicts and create workflow.

Policy shell: added tabs for Packs, Governance, VEX & Exceptions, Simulation,
Audit — all sub-pages now accessible from the Release Policies page.

Integrations: moved symbol sources/marketplace and scanner config to
/setup/integrations.

Backend: mirror config changes now persist via IFeedMirrorConfigStore and
propagate to central Scheduler via SchedulerClient. MirrorExportScheduler
supports IMirrorSchedulerSignal for immediate wakeup on config change.

Mirror detail page: fixed all wrong CSS tokens (text colors used as
backgrounds, inverted borders) to canonical Stella Ops design system.

Exception dashboard: removed duplicate English/Bulgarian title headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 01:36:41 +03:00
parent 684f69c2ae
commit a330dd3673
42 changed files with 3636 additions and 2543 deletions

View File

@@ -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<IResult> 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)

View File

@@ -661,7 +661,23 @@ builder.Services.AddHttpClient("MirrorConsumer");
// Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b)
builder.Services.Configure<MirrorDistributionOptions>(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName));
builder.Services.AddHostedService<MirrorExportScheduler>();
// Feed mirror config store (replaces stateless MirrorSeedData for PATCH persistence)
builder.Services.AddSingleton<InMemoryFeedMirrorConfigStore>();
builder.Services.AddSingleton<IFeedMirrorConfigStore>(sp => sp.GetRequiredService<InMemoryFeedMirrorConfigStore>());
// 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<SchedulerClient>();
// MirrorExportScheduler: singleton + signal interface + hosted service
builder.Services.AddSingleton<MirrorExportScheduler>();
builder.Services.AddSingleton<IMirrorSchedulerSignal>(sp => sp.GetRequiredService<MirrorExportScheduler>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<MirrorExportScheduler>());
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();

View File

@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using static StellaOps.Concelier.WebService.Extensions.FeedMirrorManagementEndpoints;
namespace StellaOps.Concelier.WebService.Services;
/// <summary>
/// Thread-safe in-memory store for feed mirror configuration.
/// Seeded from <see cref="MirrorSeedData"/> on construction.
/// Replaces the stateless seed-data pattern so that PATCH updates are persisted in-process.
/// Future: replace with DB-backed store.
/// </summary>
internal sealed class InMemoryFeedMirrorConfigStore : IFeedMirrorConfigStore
{
private readonly ConcurrentDictionary<string, FeedMirrorDto> _mirrors;
public InMemoryFeedMirrorConfigStore()
{
_mirrors = new ConcurrentDictionary<string, FeedMirrorDto>(
MirrorSeedData.Mirrors.ToDictionary(m => m.MirrorId, m => m),
StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<FeedMirrorDto> 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;
}
}
/// <summary>
/// Abstraction for feed mirror config persistence.
/// </summary>
internal interface IFeedMirrorConfigStore
{
IReadOnlyList<FeedMirrorDto> GetAll();
FeedMirrorDto? Get(string mirrorId);
FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update);
}

View File

@@ -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;
/// <summary>
/// HTTP client for the central Scheduler WebService.
/// Manages mirror sync schedules using deterministic IDs: sys-{tenantId}-mirror-{mirrorId}.
/// </summary>
public sealed class SchedulerClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SchedulerClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public SchedulerClient(IHttpClientFactory httpClientFactory, ILogger<SchedulerClient> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// Creates or updates the scheduler schedule for a feed mirror.
/// Uses deterministic schedule ID: sys-{tenantId}-mirror-{mirrorId}.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Builds the deterministic schedule ID for a mirror.
/// </summary>
public static string BuildScheduleId(string tenantId, string mirrorId)
=> $"sys-{tenantId}-mirror-{mirrorId}";
/// <summary>
/// Converts an interval in minutes to a cron expression.
/// </summary>
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
};
}

View File

@@ -13,19 +13,32 @@ using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Signal interface to wake the <see cref="MirrorExportScheduler"/> from its sleep cycle
/// when mirror configuration changes at runtime (e.g. sync interval update).
/// </summary>
public interface IMirrorSchedulerSignal
{
/// <summary>
/// Interrupts the current sleep cycle so the scheduler re-reads its options immediately.
/// </summary>
void SignalReconfigured();
}
/// <summary>
/// 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 <see cref="MirrorDistributionOptions.AutoRefreshEnabled"/>.
/// </summary>
public sealed class MirrorExportScheduler : BackgroundService
public sealed class MirrorExportScheduler : BackgroundService, IMirrorSchedulerSignal
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<MirrorDistributionOptions> _optionsMonitor;
private readonly ILogger<MirrorExportScheduler> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, DomainGenerationStatus> _domainStatus = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource _wakeupCts = new();
/// <summary>
/// Initializes a new instance of <see cref="MirrorExportScheduler"/>.
@@ -48,6 +61,15 @@ public sealed class MirrorExportScheduler : BackgroundService
public IReadOnlyDictionary<string, DomainGenerationStatus> GetDomainStatuses()
=> _domainStatus.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc cref="IMirrorSchedulerSignal.SignalReconfigured"/>
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.");
}
/// <inheritdoc />
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.");