Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user