Sprint 2+3+5: Registry search, workflow chain, unified security data
Sprint 2 — Registry image search (S2-T01/T02/T03):
Harbor plugin: SearchRepositoriesAsync + ListArtifactsAsync calling
Harbor /api/v2.0/search and /api/v2.0/projects/*/repositories/*/artifacts
Platform endpoint: GET /api/v1/registries/images/search proxies to
Harbor fixture, returns aggregated RegistryImage[] response
Frontend: release-management.client.ts now calls /api/v1/registries/*
instead of the nonexistent /api/registry/* path
Gateway route: /api/v1/registries → platform (ReverseProxy)
Sprint 3 — Workflow chain links (S3-T01/T02/T03/T05):
S3-T01: Integration detail health tab shows "Scan your first image"
CTA after successful registry connection test
S3-T02: Scan submit page already had "View findings" link (verified)
S3-T03: Triage findings detail shows "Check policy gates" banner
after recording a VEX decision
S3-T05: Promotions list + detail show "Review blocking finding"
link when promotion is blocked by gate failure
Sprint 5 — Unified security data (S5-T01):
Security Posture now queries VULNERABILITY_API for triage stats
Risk Posture card shows real finding count from triage (was hardcoded 0)
Risk label computed from triage severity breakdown (GUARDED→HIGH)
Blocking Items shows critical+high counts from triage
"View in Vulnerabilities workspace" drilldown link added
Angular build: 0 errors. .NET builds: 0 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,7 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
|
||||
{ "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" },
|
||||
|
||||
@@ -160,11 +160,84 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches Harbor repositories matching the given query string.
|
||||
/// Uses Harbor v2.0 global search or project-scoped repository listing.
|
||||
/// </summary>
|
||||
public async Task<List<RepositoryInfo>> SearchRepositoriesAsync(IntegrationConfig config, string query, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return [];
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Use Harbor global search endpoint
|
||||
var response = await client.GetAsync($"/api/v2.0/search?q={Uri.EscapeDataString(query)}", ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var searchResult = JsonSerializer.Deserialize<HarborSearchResponse>(content, JsonOptions);
|
||||
|
||||
return (searchResult?.Repository ?? [])
|
||||
.Select(r => new RepositoryInfo
|
||||
{
|
||||
Name = r.RepositoryName ?? string.Empty,
|
||||
Project = r.ProjectName ?? string.Empty,
|
||||
Tags = []
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists artifacts for a specific repository within a project.
|
||||
/// </summary>
|
||||
public async Task<List<ArtifactInfo>> ListArtifactsAsync(IntegrationConfig config, string project, string repository, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(repository))
|
||||
return [];
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
var encodedRepo = Uri.EscapeDataString(repository);
|
||||
var response = await client.GetAsync($"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories/{encodedRepo}/artifacts", ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var artifacts = JsonSerializer.Deserialize<List<HarborArtifactDto>>(content, JsonOptions);
|
||||
|
||||
return (artifacts ?? [])
|
||||
.Select(a => new ArtifactInfo
|
||||
{
|
||||
Digest = a.Digest ?? string.Empty,
|
||||
Tags = (a.Tags ?? []).Select(t => t.Name ?? string.Empty).Where(n => n.Length > 0).ToList(),
|
||||
PushedAt = a.PushTime
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Harbor API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class HarborHealthResponse
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
@@ -176,4 +249,47 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class HarborSearchResponse
|
||||
{
|
||||
public List<HarborSearchRepository>? Repository { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborSearchRepository
|
||||
{
|
||||
public string? RepositoryName { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborArtifactDto
|
||||
{
|
||||
public string? Digest { get; set; }
|
||||
public List<HarborTagDto>? Tags { get; set; }
|
||||
public DateTimeOffset? PushTime { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborTagDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a Harbor repository returned from search.
|
||||
/// </summary>
|
||||
public sealed class RepositoryInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Project { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a Harbor artifact (image manifest).
|
||||
/// </summary>
|
||||
public sealed class ArtifactInfo
|
||||
{
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public DateTimeOffset? PushedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregation endpoint for registry image search.
|
||||
/// Proxies search to connected registry integrations (Harbor fixture by default)
|
||||
/// and returns a unified response format for the frontend.
|
||||
/// </summary>
|
||||
public static class RegistrySearchEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapRegistrySearchEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/registries")
|
||||
.WithTags("Registry Search")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsRead)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/images/search", SearchImagesAsync)
|
||||
.WithName("Registries.SearchImages")
|
||||
.WithSummary("Search container images across connected registries");
|
||||
|
||||
group.MapGet("/images/digests", GetImageDigestsAsync)
|
||||
.WithName("Registries.GetImageDigests")
|
||||
.WithSummary("Get artifact digests for a specific repository");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchImagesAsync(
|
||||
HttpContext ctx,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
[FromQuery] string? q,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Trim().Length < 2)
|
||||
{
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = null
|
||||
});
|
||||
}
|
||||
|
||||
var query = q.Trim();
|
||||
var logger = loggerFactory.CreateLogger("RegistrySearch");
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("HarborFixture");
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v2.0/search?q={Uri.EscapeDataString(query)}",
|
||||
ctx.RequestAborted);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Harbor search returned {StatusCode} for query '{Query}'",
|
||||
response.StatusCode, query);
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted);
|
||||
var searchResult = JsonSerializer.Deserialize<HarborSearchResult>(content, JsonOptions);
|
||||
|
||||
var items = (searchResult?.Repository ?? [])
|
||||
.Select(r =>
|
||||
{
|
||||
var fullName = r.RepositoryName ?? string.Empty;
|
||||
var shortName = fullName.Contains('/')
|
||||
? fullName.Substring(fullName.LastIndexOf('/') + 1)
|
||||
: fullName;
|
||||
|
||||
return new RegistryImageItem
|
||||
{
|
||||
Name = shortName,
|
||||
Repository = fullName,
|
||||
Tags = [],
|
||||
Digests = [],
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = items.Count,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Harbor registry unreachable during image search for query '{Query}'", query);
|
||||
|
||||
return Results.Ok(new RegistrySearchResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
RegistryId = "harbor-fixture"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetImageDigestsAsync(
|
||||
HttpContext ctx,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
[FromQuery] string? repository,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(repository))
|
||||
{
|
||||
return Results.BadRequest(new { error = "repository_required" });
|
||||
}
|
||||
|
||||
var repo = repository.Trim();
|
||||
var logger = loggerFactory.CreateLogger("RegistrySearch");
|
||||
// Parse project/repository from the full repository name
|
||||
var slashIndex = repo.IndexOf('/');
|
||||
if (slashIndex < 0)
|
||||
{
|
||||
// No project prefix - return empty
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repo,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
|
||||
var project = repo.Substring(0, slashIndex);
|
||||
var repoName = repo.Substring(slashIndex + 1);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("HarborFixture");
|
||||
var encodedRepo = Uri.EscapeDataString(repoName);
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories/{encodedRepo}/artifacts",
|
||||
ctx.RequestAborted);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted);
|
||||
var artifacts = JsonSerializer.Deserialize<List<HarborArtifactDto>>(content, JsonOptions) ?? [];
|
||||
|
||||
var tags = artifacts
|
||||
.SelectMany(a => (a.Tags ?? []).Select(t => t.Name ?? string.Empty))
|
||||
.Where(t => t.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var digests = artifacts
|
||||
.SelectMany(a =>
|
||||
{
|
||||
var artifactTags = (a.Tags ?? []).Select(t => t.Name ?? "untagged").ToList();
|
||||
if (artifactTags.Count == 0) artifactTags.Add("untagged");
|
||||
|
||||
return artifactTags.Select(tag => new DigestEntry
|
||||
{
|
||||
Tag = tag,
|
||||
Digest = a.Digest ?? string.Empty,
|
||||
PushedAt = a.PushTime?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty
|
||||
});
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = tags,
|
||||
Digests = digests
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Harbor registry unreachable during digest lookup for '{Repository}'", repo);
|
||||
|
||||
return Results.Ok(new RegistryImageItem
|
||||
{
|
||||
Name = repoName,
|
||||
Repository = repo,
|
||||
Tags = [],
|
||||
Digests = []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response DTOs ──────────────────────────────────────────────
|
||||
|
||||
private sealed class RegistrySearchResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<RegistryImageItem> Items { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
[JsonPropertyName("registryId")]
|
||||
public string? RegistryId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RegistryImageItem
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string Repository { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("digests")]
|
||||
public List<DigestEntry> Digests { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class DigestEntry
|
||||
{
|
||||
[JsonPropertyName("tag")]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pushedAt")]
|
||||
public string PushedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Harbor API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class HarborSearchResult
|
||||
{
|
||||
public List<HarborSearchRepository>? Repository { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborSearchRepository
|
||||
{
|
||||
public string? RepositoryName { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborArtifactDto
|
||||
{
|
||||
public string? Digest { get; set; }
|
||||
public List<HarborTagDto>? Tags { get; set; }
|
||||
public DateTimeOffset? PushTime { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborTagDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,17 @@ builder.Services.AddHttpClient("AuthorityInternal", client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("HarborFixture", client =>
|
||||
{
|
||||
var harborUrl = builder.Configuration["STELLAOPS_HARBOR_URL"]
|
||||
?? builder.Configuration["Platform:HarborFixtureUrl"]
|
||||
?? "http://harbor-fixture.stella-ops.local";
|
||||
client.BaseAddress = new Uri(harborUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
@@ -345,6 +356,7 @@ app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
app.MapSeedEndpoints();
|
||||
app.MapMigrationAdminEndpoints();
|
||||
app.MapRegistrySearchEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
|
||||
{ "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" },
|
||||
|
||||
@@ -25,6 +25,24 @@ import type {
|
||||
|
||||
export const RELEASE_MANAGEMENT_API = new InjectionToken<ReleaseManagementApi>('RELEASE_MANAGEMENT_API');
|
||||
|
||||
interface RegistrySearchResponse {
|
||||
items: Array<{
|
||||
name: string;
|
||||
repository: string;
|
||||
tags: string[];
|
||||
digests: Array<{ tag: string; digest: string; pushedAt: string }>;
|
||||
}>;
|
||||
totalCount: number;
|
||||
registryId: string | null;
|
||||
}
|
||||
|
||||
interface RegistryDigestResponse {
|
||||
name: string;
|
||||
repository: string;
|
||||
tags: string[];
|
||||
digests: Array<{ tag: string; digest: string; pushedAt: string }>;
|
||||
}
|
||||
|
||||
interface PlatformListResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -378,22 +396,50 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return this.http.get<RegistryImage[]>('/api/registry/images/search', { params: { q: query } }).pipe(
|
||||
catchError(() => of([])),
|
||||
return this.http.get<RegistrySearchResponse>('/api/v1/registries/images/search', { params: { q: query } }).pipe(
|
||||
map((response) =>
|
||||
(response.items ?? []).map((item) => ({
|
||||
name: item.name,
|
||||
repository: item.repository,
|
||||
tags: item.tags ?? [],
|
||||
digests: (item.digests ?? []).map((d) => ({
|
||||
tag: d.tag,
|
||||
digest: d.digest,
|
||||
pushedAt: d.pushedAt,
|
||||
})),
|
||||
lastPushed: item.digests?.[0]?.pushedAt ?? '',
|
||||
})),
|
||||
),
|
||||
catchError((err) => {
|
||||
console.warn('[ReleaseManagement] Registry image search failed:', err?.message ?? err);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getImageDigests(repository: string): Observable<RegistryImage> {
|
||||
return this.http.get<RegistryImage>('/api/registry/images/digests', { params: { repository } }).pipe(
|
||||
catchError(() =>
|
||||
of({
|
||||
return this.http.get<RegistryDigestResponse>('/api/v1/registries/images/digests', { params: { repository } }).pipe(
|
||||
map((response) => ({
|
||||
name: response.name,
|
||||
repository: response.repository,
|
||||
tags: response.tags ?? [],
|
||||
digests: (response.digests ?? []).map((d) => ({
|
||||
tag: d.tag,
|
||||
digest: d.digest,
|
||||
pushedAt: d.pushedAt,
|
||||
})),
|
||||
lastPushed: response.digests?.[0]?.pushedAt ?? '',
|
||||
})),
|
||||
catchError((err) => {
|
||||
console.warn('[ReleaseManagement] Registry digest lookup failed:', err?.message ?? err);
|
||||
return of({
|
||||
name: repository.split('/').at(-1) ?? repository,
|
||||
repository,
|
||||
tags: [],
|
||||
digests: [],
|
||||
lastPushed: '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HealthStatus,
|
||||
Integration,
|
||||
IntegrationHealthResponse,
|
||||
IntegrationType,
|
||||
TestConnectionResponse,
|
||||
IntegrationStatus,
|
||||
getHealthStatusColor,
|
||||
@@ -183,6 +184,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
</div>
|
||||
<p>{{ lastTestResult.message || 'Connection successful.' }}</p>
|
||||
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} (duration {{ lastTestResult.duration }})</small>
|
||||
@if (lastTestResult.success && isRegistryType()) {
|
||||
<a class="workflow-cta" routerLink="/security/scan">
|
||||
Registry connected! Scan your first image ->
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (lastHealthResult) {
|
||||
@@ -423,6 +429,18 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
}
|
||||
.loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
|
||||
|
||||
.workflow-cta {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.delete-error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -587,6 +605,10 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
return getProviderLabel(provider);
|
||||
}
|
||||
|
||||
isRegistryType(): boolean {
|
||||
return this.integration?.type === IntegrationType.Registry;
|
||||
}
|
||||
|
||||
integrationHubRoute(): string[] {
|
||||
return this.integrationCommands();
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ type DetailTab =
|
||||
<div><strong>Requested:</strong> {{ formatDate(promotion()!.requestedAt) }}</div>
|
||||
</div>
|
||||
|
||||
@if (!promotion()!.gatesPassed) {
|
||||
<a routerLink="/triage/artifacts" class="blocked-finding-link">
|
||||
Review blocking finding ->
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (promotion()!.status === 'pending') {
|
||||
<div class="decision-box" *stellaOperatorOnly>
|
||||
<label for="decisionComment">Decision comment</label>
|
||||
@@ -540,6 +546,18 @@ type DetailTab =
|
||||
color: var(--color-brand-primary, #4f46e5);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blocked-finding-link {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
color: #991b1b;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blocked-finding-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -197,6 +197,9 @@ interface PromotionRow {
|
||||
<span class="signal signal--{{ promotion.riskSignal.level }}">
|
||||
{{ promotion.riskSignal.text }}
|
||||
</span>
|
||||
@if (promotion.riskSignal.level === 'blocked') {
|
||||
<a class="blocked-finding-link" routerLink="/triage/artifacts">Review blocking finding -></a>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="signal signal--{{ promotion.dataHealth.level }}">
|
||||
@@ -411,6 +414,19 @@ interface PromotionRow {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.blocked-finding-link {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: #991b1b;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blocked-finding-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.requested-cell {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, take } from 'rxjs/operators';
|
||||
import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api';
|
||||
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
|
||||
import type { VulnerabilityStats } from '../../core/api/vulnerability.models';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
|
||||
@@ -95,11 +97,12 @@ interface PlatformListResponse<T> {
|
||||
<h2>Risk Posture</h2>
|
||||
<p class="value">{{ riskPostureLabel() }}</p>
|
||||
<small>{{ findingsCount() }} findings in scope</small>
|
||||
<a routerLink="/triage/artifacts" queryParamsHandling="merge" class="kpi-link">View in Vulnerabilities workspace</a>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Blocking Items</h2>
|
||||
<p class="value">{{ blockerCount() }}</p>
|
||||
<small>Policy action = block</small>
|
||||
<small>{{ triageCriticalCount() }} critical, {{ triageHighCount() }} high severity</small>
|
||||
</article>
|
||||
<article>
|
||||
<h2>VEX Coverage</h2>
|
||||
@@ -271,6 +274,8 @@ interface PlatformListResponse<T> {
|
||||
}
|
||||
.kpis .value{margin:.2rem 0 0;font-size:1.15rem;font-weight:var(--font-weight-semibold)}
|
||||
.kpis small{font-size:.68rem;color:var(--color-text-secondary)}
|
||||
.kpi-link{display:block;margin-top:.25rem;font-size:.66rem;color:var(--color-brand-primary);text-decoration:none}
|
||||
.kpi-link:hover{text-decoration:underline}
|
||||
|
||||
.grid{
|
||||
display:grid;
|
||||
@@ -297,6 +302,7 @@ interface PlatformListResponse<T> {
|
||||
export class SecurityRiskOverviewComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly advisorySourcesApi = inject(AdvisorySourcesApi);
|
||||
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly loading = signal(false);
|
||||
@@ -306,10 +312,27 @@ export class SecurityRiskOverviewComponent {
|
||||
readonly sbomRows = signal<SecuritySbomExplorerResponse['table']>([]);
|
||||
readonly feedHealth = signal<IntegrationHealthRow[]>([]);
|
||||
readonly vexSourceHealth = signal<IntegrationHealthRow[]>([]);
|
||||
readonly triageStats = signal<VulnerabilityStats | null>(null);
|
||||
|
||||
readonly findingsCount = computed(() => this.findings().length);
|
||||
/** Use triage stats total when security findings API returns empty. */
|
||||
readonly findingsCount = computed(() => {
|
||||
const securityFindings = this.findings().length;
|
||||
if (securityFindings > 0) return securityFindings;
|
||||
return this.triageStats()?.total ?? 0;
|
||||
});
|
||||
readonly reachableCount = computed(() => this.findings().filter((item) => item.reachable).length);
|
||||
readonly blockerCount = computed(() => this.topBlockers().length);
|
||||
|
||||
/** Triage severity breakdown for blocker KPI. */
|
||||
readonly triageCriticalCount = computed(() => this.triageStats()?.bySeverity?.critical ?? 0);
|
||||
readonly triageHighCount = computed(() => this.triageStats()?.bySeverity?.high ?? 0);
|
||||
|
||||
/** Blockers: disposition-based blockers + critical open findings from triage. */
|
||||
readonly blockerCount = computed(() => {
|
||||
const dispositionBlockers = this.topBlockers().length;
|
||||
if (dispositionBlockers > 0) return dispositionBlockers;
|
||||
// Fall back to critical open from triage when no disposition data
|
||||
return this.triageStats()?.criticalOpen ?? 0;
|
||||
});
|
||||
readonly topBlockers = computed(() =>
|
||||
this.dispositions()
|
||||
.filter((item) => item.policyAction === 'block' || item.effectiveDisposition === 'action_required')
|
||||
@@ -342,10 +365,20 @@ export class SecurityRiskOverviewComponent {
|
||||
});
|
||||
|
||||
readonly riskPostureLabel = computed(() => {
|
||||
const critical = this.findings().filter((item) => item.severity === 'critical').length;
|
||||
const high = this.findings().filter((item) => item.severity === 'high').length;
|
||||
const securityFindings = this.findings();
|
||||
const critical = securityFindings.filter((item) => item.severity === 'critical').length;
|
||||
const high = securityFindings.filter((item) => item.severity === 'high').length;
|
||||
if (critical > 0) return 'HIGH';
|
||||
if (high > 0) return 'ELEVATED';
|
||||
// Fall back to triage stats when security findings API returns empty
|
||||
if (securityFindings.length === 0) {
|
||||
const stats = this.triageStats();
|
||||
if (stats) {
|
||||
if ((stats.bySeverity?.critical ?? 0) > 0) return 'HIGH';
|
||||
if ((stats.bySeverity?.high ?? 0) > 0) return 'ELEVATED';
|
||||
if (stats.total > 0) return 'GUARDED';
|
||||
}
|
||||
}
|
||||
return 'GUARDED';
|
||||
});
|
||||
|
||||
@@ -452,16 +485,20 @@ export class SecurityRiskOverviewComponent {
|
||||
catchError(() => of([] as IntegrationHealthRow[]))
|
||||
);
|
||||
const vexHealth$ = of([] as IntegrationHealthRow[]);
|
||||
const triageStats$ = this.vulnApi.getStats().pipe(
|
||||
catchError(() => of(null as VulnerabilityStats | null))
|
||||
);
|
||||
|
||||
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ })
|
||||
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$, triageStats: triageStats$ })
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: ({ findings, disposition, sbom, feedHealth, vexHealth }) => {
|
||||
next: ({ findings, disposition, sbom, feedHealth, vexHealth, triageStats }) => {
|
||||
this.findings.set(findings);
|
||||
this.dispositions.set(disposition);
|
||||
this.sbomRows.set(sbom);
|
||||
this.feedHealth.set(feedHealth);
|
||||
this.vexSourceHealth.set(vexHealth);
|
||||
this.triageStats.set(triageStats);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Subject, takeUntil, forkJoin } from 'rxjs';
|
||||
|
||||
// Components
|
||||
@@ -60,6 +61,7 @@ export interface FindingDetail {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
TriageLaneToggleComponent,
|
||||
GatedBucketsComponent,
|
||||
GatingReasonFilterComponent,
|
||||
@@ -194,6 +196,17 @@ export interface FindingDetail {
|
||||
(decisionSubmit)="onDecisionSubmit($event)"
|
||||
(decisionRevoked)="onDecisionRevoked($event)"
|
||||
/>
|
||||
|
||||
<!-- Workflow chain: decision recorded success banner -->
|
||||
@if (decisionSuccess()) {
|
||||
<div class="decision-success-banner" role="status">
|
||||
<span>Decision recorded.</span>
|
||||
<a routerLink="/ops/policy/simulation" class="policy-gate-link">
|
||||
Check policy gates for this release ->
|
||||
</a>
|
||||
<button type="button" class="dismiss-btn" (click)="decisionSuccess.set(false)" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -457,6 +470,37 @@ export interface FindingDetail {
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
|
||||
/* Workflow chain: decision success banner */
|
||||
.decision-success-banner {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--color-status-success-text) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--color-status-success-text) 8%, transparent);
|
||||
color: var(--color-status-success-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-gate-link {
|
||||
margin-left: auto;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.finding-card {
|
||||
@@ -493,6 +537,7 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy {
|
||||
readonly selectedFinding = signal<FindingDetail | null>(null);
|
||||
readonly selectedCallPath = signal<CallPath | null>(null);
|
||||
readonly isDrawerOpen = signal(false);
|
||||
readonly decisionSuccess = signal(false);
|
||||
readonly evidenceHash = signal('');
|
||||
|
||||
// T004: Filter by lane
|
||||
@@ -644,6 +689,10 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
// T029: Record decision
|
||||
this.ttfsService.recordDecision(finding.id, decision.status);
|
||||
|
||||
// Workflow chain: show success banner with policy gate link
|
||||
this.decisionSuccess.set(true);
|
||||
this.isDrawerOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user