Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,235 @@
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Persistence.Postgres.Repositories;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Advisory-source freshness endpoints used by UI v2 shell.
/// </summary>
internal static class AdvisorySourceEndpointExtensions
{
private const string AdvisoryReadPolicy = "Concelier.Advisories.Read";
public static void MapAdvisorySourceEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/advisory-sources")
.WithTags("Advisory Sources");
group.MapGet(string.Empty, async (
HttpContext httpContext,
[FromQuery] bool includeDisabled,
[FromServices] IAdvisorySourceReadRepository readRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
var items = records.Select(MapListItem).ToList();
return HttpResults.Ok(new AdvisorySourceListResponse
{
Items = items,
TotalCount = items.Count,
DataAsOf = timeProvider.GetUtcNow()
});
})
.WithName("ListAdvisorySources")
.WithSummary("List advisory sources with freshness state")
.Produces<AdvisorySourceListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
group.MapGet("/summary", async (
HttpContext httpContext,
[FromServices] IAdvisorySourceReadRepository readRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
var records = await readRepository.ListAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false);
var response = new AdvisorySourceSummaryResponse
{
TotalSources = records.Count,
HealthySources = records.Count(r => string.Equals(r.FreshnessStatus, "healthy", StringComparison.OrdinalIgnoreCase)),
WarningSources = records.Count(r => string.Equals(r.FreshnessStatus, "warning", StringComparison.OrdinalIgnoreCase)),
StaleSources = records.Count(r => string.Equals(r.FreshnessStatus, "stale", StringComparison.OrdinalIgnoreCase)),
UnavailableSources = records.Count(r => string.Equals(r.FreshnessStatus, "unavailable", StringComparison.OrdinalIgnoreCase)),
DisabledSources = records.Count(r => !r.Enabled),
ConflictingSources = 0,
DataAsOf = timeProvider.GetUtcNow()
};
return HttpResults.Ok(response);
})
.WithName("GetAdvisorySourceSummary")
.WithSummary("Get advisory source summary cards")
.Produces<AdvisorySourceSummaryResponse>(StatusCodes.Status200OK)
.RequireAuthorization(AdvisoryReadPolicy);
group.MapGet("/{id}/freshness", async (
HttpContext httpContext,
string id,
[FromServices] IAdvisorySourceReadRepository readRepository,
[FromServices] ISourceRepository sourceRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out _))
{
return HttpResults.BadRequest(new { error = "tenant_required", header = StellaOps.Concelier.WebService.Program.TenantHeaderName });
}
if (string.IsNullOrWhiteSpace(id))
{
return HttpResults.BadRequest(new { error = "source_id_required" });
}
id = id.Trim();
AdvisorySourceFreshnessRecord? record = null;
if (Guid.TryParse(id, out var sourceId))
{
record = await readRepository.GetBySourceIdAsync(sourceId, cancellationToken).ConfigureAwait(false);
}
else
{
var source = await sourceRepository.GetByKeyAsync(id, cancellationToken).ConfigureAwait(false);
if (source is not null)
{
record = await readRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
if (record is null)
{
return HttpResults.NotFound(new { error = "advisory_source_not_found", id });
}
return HttpResults.Ok(new AdvisorySourceFreshnessResponse
{
Source = MapListItem(record),
LastSyncAt = record.LastSyncAt,
LastSuccessAt = record.LastSuccessAt,
LastError = record.LastError,
SyncCount = record.SyncCount,
ErrorCount = record.ErrorCount,
DataAsOf = timeProvider.GetUtcNow()
});
})
.WithName("GetAdvisorySourceFreshness")
.WithSummary("Get freshness details for one advisory source")
.Produces<AdvisorySourceFreshnessResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(AdvisoryReadPolicy);
}
private static bool TryGetTenant(HttpContext httpContext, out string tenant)
{
tenant = string.Empty;
var claimTenant = httpContext.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenant = claimTenant.Trim();
return true;
}
var headerTenant = httpContext.Request.Headers[StellaOps.Concelier.WebService.Program.TenantHeaderName].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim();
return true;
}
return false;
}
private static AdvisorySourceListItem MapListItem(AdvisorySourceFreshnessRecord record)
{
return new AdvisorySourceListItem
{
SourceId = record.SourceId,
SourceKey = record.SourceKey,
SourceName = record.SourceName,
SourceFamily = record.SourceFamily,
SourceUrl = record.SourceUrl,
Priority = record.Priority,
Enabled = record.Enabled,
LastSyncAt = record.LastSyncAt,
LastSuccessAt = record.LastSuccessAt,
FreshnessAgeSeconds = record.FreshnessAgeSeconds,
FreshnessSlaSeconds = record.FreshnessSlaSeconds,
FreshnessStatus = record.FreshnessStatus,
SignatureStatus = record.SignatureStatus,
LastError = record.LastError,
SyncCount = record.SyncCount,
ErrorCount = record.ErrorCount,
TotalAdvisories = record.TotalAdvisories,
SignedAdvisories = record.SignedAdvisories,
UnsignedAdvisories = record.UnsignedAdvisories,
SignatureFailureCount = record.SignatureFailureCount
};
}
}
public sealed record AdvisorySourceListResponse
{
public IReadOnlyList<AdvisorySourceListItem> Items { get; init; } = [];
public int TotalCount { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceListItem
{
public Guid SourceId { get; init; }
public string SourceKey { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public string SourceFamily { get; init; } = string.Empty;
public string? SourceUrl { get; init; }
public int Priority { get; init; }
public bool Enabled { get; init; }
public DateTimeOffset? LastSyncAt { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public long FreshnessAgeSeconds { get; init; }
public int FreshnessSlaSeconds { get; init; }
public string FreshnessStatus { get; init; } = "unknown";
public string SignatureStatus { get; init; } = "unsigned";
public string? LastError { get; init; }
public long SyncCount { get; init; }
public int ErrorCount { get; init; }
public long TotalAdvisories { get; init; }
public long SignedAdvisories { get; init; }
public long UnsignedAdvisories { get; init; }
public long SignatureFailureCount { get; init; }
}
public sealed record AdvisorySourceSummaryResponse
{
public int TotalSources { get; init; }
public int HealthySources { get; init; }
public int WarningSources { get; init; }
public int StaleSources { get; init; }
public int UnavailableSources { get; init; }
public int DisabledSources { get; init; }
public int ConflictingSources { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceFreshnessResponse
{
public AdvisorySourceListItem Source { get; init; } = new();
public DateTimeOffset? LastSyncAt { get; init; }
public DateTimeOffset? LastSuccessAt { get; init; }
public string? LastError { get; init; }
public long SyncCount { get; init; }
public int ErrorCount { get; init; }
public DateTimeOffset DataAsOf { get; init; }
}

View File

@@ -909,6 +909,7 @@ app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
// Canonical advisory endpoints (Sprint 8200.0012.0003)
app.MapCanonicalAdvisoryEndpoints();
app.MapAdvisorySourceEndpoints();
app.MapInterestScoreEndpoints();
// Federation endpoints for site-to-site bundle sync

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0242-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-A | DONE | Applied 2026-01-13; TimeProvider defaults, ASCII cleanup, federation tests. |
| BE8-07-API | DONE | Advisory-source freshness endpoint contract extended with advisory stats fields consumed by UI security diagnostics. |