First-time user experience fixes and platform contract repairs
FTUX fixes (Sprint 316-001): - Remove all hardcoded fake data from dashboard — fresh installs show honest setup guide instead of fake crisis data (5 fake criticals gone) - Curate advisory source defaults: 32 sources disabled by default (ecosystem, geo-restricted, exploit, hardware, mirror). ~43 core sources remain enabled. StellaOps Mirror no longer enabled at priority 1. - Filter Mirror-category sources from Create Domain wizard to prevent circular mirror-from-mirror chains - Add 404 catch-all route — unknown URLs show "Page Not Found" instead of silently rendering the dashboard - Fix arrow characters in release target path dropdown (? → →) - Add login credentials to quickstart documentation - Update Feature Matrix: 14 release orchestration features marked as shipped (was marked planned) Platform contract repairs (from prior session): - Add /api/v1/jobengine/quotas/summary endpoint on Platform - Fix gateway route prefix matching for /policy/shadow/* and /policy/simulations/* (regex routes instead of exact match) - Fix VexHub PostgresVexSourceRepository missing interface method - Fix advisory-vex-sources sweep text expectation - Fix mirror operator journey auth (session storage token extraction) Verified: 110/111 canonical routes passing (1 unrelated stale approval ref) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -611,7 +611,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "NpmClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node")
|
||||
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition PyPi = new()
|
||||
@@ -626,7 +627,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "PyPiClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 52,
|
||||
Tags = ImmutableArray.Create("pypi", "ecosystem", "python")
|
||||
Tags = ImmutableArray.Create("pypi", "ecosystem", "python"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Go = new()
|
||||
@@ -641,7 +643,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "GoClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 54,
|
||||
Tags = ImmutableArray.Create("go", "ecosystem", "golang")
|
||||
Tags = ImmutableArray.Create("go", "ecosystem", "golang"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition RubyGems = new()
|
||||
@@ -656,7 +659,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "RubyGemsClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 56,
|
||||
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby")
|
||||
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Nuget = new()
|
||||
@@ -672,7 +676,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
DefaultPriority = 58,
|
||||
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp")
|
||||
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Maven = new()
|
||||
@@ -687,7 +692,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "MavenClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 60,
|
||||
Tags = ImmutableArray.Create("maven", "ecosystem", "java")
|
||||
Tags = ImmutableArray.Create("maven", "ecosystem", "java"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Crates = new()
|
||||
@@ -702,7 +708,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "CratesClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 62,
|
||||
Tags = ImmutableArray.Create("crates", "ecosystem", "rust")
|
||||
Tags = ImmutableArray.Create("crates", "ecosystem", "rust"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Packagist = new()
|
||||
@@ -717,7 +724,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "PackagistClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 64,
|
||||
Tags = ImmutableArray.Create("packagist", "ecosystem", "php")
|
||||
Tags = ImmutableArray.Create("packagist", "ecosystem", "php"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Hex = new()
|
||||
@@ -732,7 +740,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "HexClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 66,
|
||||
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang")
|
||||
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== CSAF/VEX Sources =====
|
||||
@@ -927,7 +936,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
DocumentationUrl = "https://www.exploit-db.com/",
|
||||
DefaultPriority = 110,
|
||||
Tags = ImmutableArray.Create("exploit", "poc", "offensive")
|
||||
Tags = ImmutableArray.Create("exploit", "poc", "offensive"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition PocGithub = new()
|
||||
@@ -943,7 +953,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
DefaultPriority = 112,
|
||||
Tags = ImmutableArray.Create("exploit", "poc", "github")
|
||||
Tags = ImmutableArray.Create("exploit", "poc", "github"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Metasploit = new()
|
||||
@@ -958,7 +969,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "MetasploitClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 114,
|
||||
Tags = ImmutableArray.Create("exploit", "metasploit", "rapid7")
|
||||
Tags = ImmutableArray.Create("exploit", "metasploit", "rapid7"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== Cloud Provider Advisories =====
|
||||
@@ -1054,7 +1066,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "IntelClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 130,
|
||||
Tags = ImmutableArray.Create("intel", "hardware", "firmware", "cpu")
|
||||
Tags = ImmutableArray.Create("intel", "hardware", "firmware", "cpu"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Amd = new()
|
||||
@@ -1069,7 +1082,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "AmdClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 132,
|
||||
Tags = ImmutableArray.Create("amd", "hardware", "firmware", "cpu")
|
||||
Tags = ImmutableArray.Create("amd", "hardware", "firmware", "cpu"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Arm = new()
|
||||
@@ -1084,7 +1098,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "ArmClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 134,
|
||||
Tags = ImmutableArray.Create("arm", "hardware", "firmware", "cpu")
|
||||
Tags = ImmutableArray.Create("arm", "hardware", "firmware", "cpu"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Siemens = new()
|
||||
@@ -1099,7 +1114,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "SiemensClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 136,
|
||||
Tags = ImmutableArray.Create("siemens", "ics", "scada", "hardware")
|
||||
Tags = ImmutableArray.Create("siemens", "ics", "scada", "hardware"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== Package Manager Native Advisories =====
|
||||
@@ -1116,7 +1132,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "RustSecClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 63,
|
||||
Tags = ImmutableArray.Create("rustsec", "package-manager", "rust", "cargo")
|
||||
Tags = ImmutableArray.Create("rustsec", "package-manager", "rust", "cargo"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition PyPa = new()
|
||||
@@ -1131,7 +1148,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "PyPaClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 53,
|
||||
Tags = ImmutableArray.Create("pypa", "package-manager", "python", "pip")
|
||||
Tags = ImmutableArray.Create("pypa", "package-manager", "python", "pip"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition GoVuln = new()
|
||||
@@ -1146,7 +1164,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "GoVulnClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 55,
|
||||
Tags = ImmutableArray.Create("govuln", "package-manager", "go", "golang")
|
||||
Tags = ImmutableArray.Create("govuln", "package-manager", "go", "golang"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition BundlerAudit = new()
|
||||
@@ -1161,7 +1180,8 @@ public static class SourceDefinitions
|
||||
HttpClientName = "BundlerAuditClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 57,
|
||||
Tags = ImmutableArray.Create("bundler", "package-manager", "ruby", "rubysec")
|
||||
Tags = ImmutableArray.Create("bundler", "package-manager", "ruby", "rubysec"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== Additional CERTs =====
|
||||
@@ -1179,7 +1199,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("UA"),
|
||||
DefaultPriority = 95,
|
||||
Tags = ImmutableArray.Create("cert", "ukraine")
|
||||
Tags = ImmutableArray.Create("cert", "ukraine"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertPl = new()
|
||||
@@ -1195,7 +1216,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("PL", "EU"),
|
||||
DefaultPriority = 96,
|
||||
Tags = ImmutableArray.Create("cert", "poland", "eu")
|
||||
Tags = ImmutableArray.Create("cert", "poland", "eu"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition AusCert = new()
|
||||
@@ -1211,7 +1233,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("AU", "APAC"),
|
||||
DefaultPriority = 97,
|
||||
Tags = ImmutableArray.Create("cert", "australia", "apac")
|
||||
Tags = ImmutableArray.Create("cert", "australia", "apac"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition KrCert = new()
|
||||
@@ -1227,7 +1250,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("KR", "APAC"),
|
||||
DefaultPriority = 98,
|
||||
Tags = ImmutableArray.Create("cert", "korea", "apac")
|
||||
Tags = ImmutableArray.Create("cert", "korea", "apac"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertIn = new()
|
||||
@@ -1243,7 +1267,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("IN", "APAC"),
|
||||
DefaultPriority = 99,
|
||||
Tags = ImmutableArray.Create("cert", "india", "apac")
|
||||
Tags = ImmutableArray.Create("cert", "india", "apac"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== Russian/CIS Sources =====
|
||||
@@ -1261,7 +1286,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("RU", "CIS"),
|
||||
DefaultPriority = 100,
|
||||
Tags = ImmutableArray.Create("fstec", "bdu", "russia", "cis")
|
||||
Tags = ImmutableArray.Create("fstec", "bdu", "russia", "cis"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Nkcki = new()
|
||||
@@ -1277,7 +1303,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("RU", "CIS"),
|
||||
DefaultPriority = 101,
|
||||
Tags = ImmutableArray.Create("nkcki", "russia", "cis", "cert")
|
||||
Tags = ImmutableArray.Create("nkcki", "russia", "cis", "cert"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition KasperskyIcs = new()
|
||||
@@ -1293,7 +1320,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("RU", "CIS", "GLOBAL"),
|
||||
DefaultPriority = 102,
|
||||
Tags = ImmutableArray.Create("kaspersky", "ics", "russia", "cis", "scada")
|
||||
Tags = ImmutableArray.Create("kaspersky", "ics", "russia", "cis", "scada"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition AstraLinux = new()
|
||||
@@ -1309,7 +1337,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("RU", "CIS"),
|
||||
DefaultPriority = 48,
|
||||
Tags = ImmutableArray.Create("astra", "distro", "linux", "fstec", "russia")
|
||||
Tags = ImmutableArray.Create("astra", "distro", "linux", "fstec", "russia"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== Threat Intelligence =====
|
||||
@@ -1327,7 +1356,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
DocumentationUrl = "https://attack.mitre.org/",
|
||||
DefaultPriority = 140,
|
||||
Tags = ImmutableArray.Create("mitre", "attack", "threat-intel", "tactics")
|
||||
Tags = ImmutableArray.Create("mitre", "attack", "threat-intel", "tactics"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition MitreD3fend = new()
|
||||
@@ -1343,7 +1373,8 @@ public static class SourceDefinitions
|
||||
RequiresAuthentication = false,
|
||||
DocumentationUrl = "https://d3fend.mitre.org/",
|
||||
DefaultPriority = 142,
|
||||
Tags = ImmutableArray.Create("mitre", "d3fend", "threat-intel", "defensive")
|
||||
Tags = ImmutableArray.Create("mitre", "d3fend", "threat-intel", "defensive"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== StellaOps Mirror =====
|
||||
@@ -1362,7 +1393,8 @@ public static class SourceDefinitions
|
||||
StatusPageUrl = "https://status.stella-ops.org/",
|
||||
DocumentationUrl = "https://docs.stella-ops.org/mirror/",
|
||||
DefaultPriority = 1, // Highest priority when using mirror mode
|
||||
Tags = ImmutableArray.Create("stella", "mirror", "aggregated")
|
||||
Tags = ImmutableArray.Create("stella", "mirror", "aggregated"),
|
||||
EnabledByDefault = false
|
||||
};
|
||||
|
||||
// ===== All Sources Collection =====
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class QuotaCompatibilityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapQuotaCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// --- Authority quota endpoints (routed from /api/v1/authority/quotas) ---
|
||||
var quotaGroup = app.MapGroup("/api/v1/authority/quotas")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
quotaGroup.MapGet("/dashboard", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
entitlement = BuildEntitlement(now),
|
||||
consumption = BuildConsumptionMetrics(now),
|
||||
tenantCount = 3,
|
||||
activeAlerts = 1,
|
||||
recentViolations = 4
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetDashboard");
|
||||
|
||||
quotaGroup.MapGet("/", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildEntitlement(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetEntitlement");
|
||||
|
||||
quotaGroup.MapGet("/consumption", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildConsumptionMetrics(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetConsumption");
|
||||
|
||||
quotaGroup.MapGet("/history", (
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] string? categories,
|
||||
[FromQuery] string aggregation,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveAggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation;
|
||||
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
|
||||
var periodEnd = ParseDateOrDefault(endDate, now);
|
||||
|
||||
var requestedCategories = string.IsNullOrWhiteSpace(categories)
|
||||
? new[] { "license", "jobs", "api", "storage", "scans" }
|
||||
: categories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
period = new
|
||||
{
|
||||
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
|
||||
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
points = BuildHistoryPoints(periodStart, periodEnd, effectiveAggregation, requestedCategories),
|
||||
aggregation = effectiveAggregation
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetHistory");
|
||||
|
||||
quotaGroup.MapGet("/tenants", (
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? sortBy,
|
||||
[FromQuery] string sortDir,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveLimit = limit > 0 ? limit : 50;
|
||||
var all = BuildTenantQuotaUsages(now);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
all = all.Where(t =>
|
||||
t.TenantName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
t.TenantId.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
}
|
||||
|
||||
var items = all.Skip(offset > 0 ? offset : 0).Take(effectiveLimit).ToArray();
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = all.Length
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetTenantQuotas");
|
||||
|
||||
quotaGroup.MapGet("/tenants/{tenantId}", (
|
||||
string tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildTenantBreakdown(tenantId, now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetTenantBreakdown");
|
||||
|
||||
quotaGroup.MapGet("/forecast", (
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
return Results.Ok(BuildForecasts(category));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetForecast");
|
||||
|
||||
quotaGroup.MapGet("/alerts", () =>
|
||||
{
|
||||
return Results.Ok(BuildAlertConfig());
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetAlertConfig");
|
||||
|
||||
quotaGroup.MapPost("/alerts", (QuotaAlertConfigRequest config) =>
|
||||
{
|
||||
// Accept and echo back the config (mock persistence)
|
||||
return Results.Ok(config);
|
||||
})
|
||||
.WithName("QuotaCompatibility.SaveAlertConfig");
|
||||
|
||||
quotaGroup.MapPost("/reports", (
|
||||
QuotaReportRequestBody request,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
reportId = $"quota-report-{now:yyyyMMddHHmmss}",
|
||||
status = "completed",
|
||||
downloadUrl = (string?)null,
|
||||
createdAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
completedAt = now.AddSeconds(2).ToString("O", CultureInfo.InvariantCulture),
|
||||
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.RequestReport");
|
||||
|
||||
quotaGroup.MapGet("/reports/{reportId}", (
|
||||
string reportId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
reportId,
|
||||
status = "completed",
|
||||
downloadUrl = (string?)null,
|
||||
createdAt = now.AddMinutes(-5).ToString("O", CultureInfo.InvariantCulture),
|
||||
completedAt = now.AddMinutes(-4).ToString("O", CultureInfo.InvariantCulture),
|
||||
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetReportStatus");
|
||||
|
||||
// --- Gateway rate-limit endpoints (routed from /api/v1/gateway/rate-limits) ---
|
||||
var rateLimitGroup = app.MapGroup("/api/v1/gateway/rate-limits")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
rateLimitGroup.MapGet("/", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(BuildRateLimitStatuses(now));
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetRateLimitStatus");
|
||||
|
||||
rateLimitGroup.MapGet("/violations", (
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int limit,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var effectiveLimit = limit > 0 ? limit : 50;
|
||||
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
|
||||
var periodEnd = ParseDateOrDefault(endDate, now);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items = BuildRateLimitViolations(now).Take(effectiveLimit).ToArray(),
|
||||
total = 3,
|
||||
period = new
|
||||
{
|
||||
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
|
||||
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
|
||||
}
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetRateLimitViolations");
|
||||
|
||||
// --- JobEngine quota endpoints (routed from /api/v1/jobengine/quotas) ---
|
||||
var jobQuotaGroup = app.MapGroup("/api/v1/jobengine/quotas")
|
||||
.WithTags("Quota Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
jobQuotaGroup.MapGet("/", () =>
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
concurrentJobs = new { current = 3, limit = 10 },
|
||||
dailyJobLimit = new { current = 47, limit = 500 },
|
||||
queueDepth = 2,
|
||||
averageJobDuration = 34.5
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetJobQuotaStatus");
|
||||
|
||||
jobQuotaGroup.MapGet("/summary", (TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 },
|
||||
dailyJobLimit = new { current = 47, limit = 500, percentage = 9.4 },
|
||||
queueDepth = 2,
|
||||
averageJobDuration = 34.5,
|
||||
activeWorkers = 4,
|
||||
totalWorkers = 6,
|
||||
lastUpdated = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("QuotaCompatibility.GetJobQuotaSummary");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---- Data builders ----
|
||||
|
||||
private static object BuildEntitlement(DateTimeOffset now) => new
|
||||
{
|
||||
planId = "enterprise-v2",
|
||||
planName = "Enterprise",
|
||||
features = new[]
|
||||
{
|
||||
"unlimited_tenants", "advanced_scanning", "policy_engine",
|
||||
"air_gap_mode", "federation", "custom_crypto"
|
||||
},
|
||||
limits = new
|
||||
{
|
||||
artifacts = 50000,
|
||||
users = 500,
|
||||
scansPerDay = 1000,
|
||||
storageMb = 102400,
|
||||
concurrentJobs = 10,
|
||||
apiRequestsPerMinute = 600
|
||||
},
|
||||
validFrom = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
validTo = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
renewalDate = now.AddMonths(10).ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
private static object[] BuildConsumptionMetrics(DateTimeOffset now)
|
||||
{
|
||||
var ts = now.ToString("O", CultureInfo.InvariantCulture);
|
||||
return
|
||||
[
|
||||
new
|
||||
{
|
||||
category = "license",
|
||||
current = 142,
|
||||
limit = 500,
|
||||
percentage = 28.4,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 3.2,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "jobs",
|
||||
current = 47,
|
||||
limit = 500,
|
||||
percentage = 9.4,
|
||||
status = "healthy",
|
||||
trend = "stable",
|
||||
trendPercentage = 0.8,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "api",
|
||||
current = 312,
|
||||
limit = 600,
|
||||
percentage = 52.0,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 5.1,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "storage",
|
||||
current = 24576,
|
||||
limit = 102400,
|
||||
percentage = 24.0,
|
||||
status = "healthy",
|
||||
trend = "up",
|
||||
trendPercentage = 1.8,
|
||||
lastUpdated = ts
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "scans",
|
||||
current = 187,
|
||||
limit = 1000,
|
||||
percentage = 18.7,
|
||||
status = "healthy",
|
||||
trend = "stable",
|
||||
trendPercentage = 0.4,
|
||||
lastUpdated = ts
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static object[] BuildHistoryPoints(
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset end,
|
||||
string aggregation,
|
||||
string[] categories)
|
||||
{
|
||||
var interval = aggregation switch
|
||||
{
|
||||
"hourly" => TimeSpan.FromHours(1),
|
||||
"weekly" => TimeSpan.FromDays(7),
|
||||
_ => TimeSpan.FromDays(1)
|
||||
};
|
||||
|
||||
var points = new List<object>();
|
||||
var cursor = start;
|
||||
var step = 0;
|
||||
|
||||
while (cursor <= end && step < 200) // cap at 200 points
|
||||
{
|
||||
foreach (var cat in categories)
|
||||
{
|
||||
var (baseVal, limit) = cat switch
|
||||
{
|
||||
"license" => (130 + step * 2, 500),
|
||||
"jobs" => (40 + (step % 10), 500),
|
||||
"api" => (280 + step * 3, 600),
|
||||
"storage" => (23000 + step * 100, 102400),
|
||||
"scans" => (150 + step * 5, 1000),
|
||||
_ => (50, 500)
|
||||
};
|
||||
|
||||
var value = Math.Min(baseVal, limit);
|
||||
points.Add(new
|
||||
{
|
||||
timestamp = cursor.ToString("O", CultureInfo.InvariantCulture),
|
||||
category = cat,
|
||||
value,
|
||||
percentage = Math.Round(value * 100.0 / limit, 1)
|
||||
});
|
||||
}
|
||||
|
||||
cursor = cursor.Add(interval);
|
||||
step++;
|
||||
}
|
||||
|
||||
return points.ToArray();
|
||||
}
|
||||
|
||||
private static TenantQuotaUsageDto[] BuildTenantQuotaUsages(DateTimeOffset now)
|
||||
{
|
||||
var ts = now.ToString("O", CultureInfo.InvariantCulture);
|
||||
return
|
||||
[
|
||||
new TenantQuotaUsageDto(
|
||||
"demo-prod",
|
||||
"Demo Production",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 82, 200, 41.0, "healthy", "up", 2.1, ts),
|
||||
new QuotaMetricDto("jobs", 23, 200, 11.5, "healthy", "stable", 0.5, ts),
|
||||
new QuotaMetricDto("api", 180, 300, 60.0, "healthy", "up", 4.2, ts),
|
||||
new QuotaMetricDto("storage", 14000, 51200, 27.3, "healthy", "up", 1.5, ts)),
|
||||
"up", 2.8, now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture)),
|
||||
new TenantQuotaUsageDto(
|
||||
"staging-01",
|
||||
"Staging Environment",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 38, 200, 19.0, "healthy", "stable", 0.3, ts),
|
||||
new QuotaMetricDto("jobs", 15, 200, 7.5, "healthy", "stable", 0.2, ts),
|
||||
new QuotaMetricDto("api", 95, 300, 31.7, "healthy", "stable", 1.1, ts),
|
||||
new QuotaMetricDto("storage", 7200, 51200, 14.1, "healthy", "stable", 0.4, ts)),
|
||||
"stable", 0.6, now.AddMinutes(-35).ToString("O", CultureInfo.InvariantCulture)),
|
||||
new TenantQuotaUsageDto(
|
||||
"dev-sandbox",
|
||||
"Developer Sandbox",
|
||||
"Enterprise",
|
||||
new TenantQuotasDto(
|
||||
new QuotaMetricDto("license", 22, 100, 22.0, "healthy", "down", -1.2, ts),
|
||||
new QuotaMetricDto("jobs", 9, 100, 9.0, "healthy", "stable", 0.1, ts),
|
||||
new QuotaMetricDto("api", 37, 150, 24.7, "healthy", "down", -0.8, ts),
|
||||
new QuotaMetricDto("storage", 3400, 25600, 13.3, "healthy", "stable", 0.2, ts)),
|
||||
"down", -0.7, now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture))
|
||||
];
|
||||
}
|
||||
|
||||
private static object BuildTenantBreakdown(string tenantId, DateTimeOffset now) => new
|
||||
{
|
||||
tenantId,
|
||||
tenantName = tenantId switch
|
||||
{
|
||||
"demo-prod" => "Demo Production",
|
||||
"staging-01" => "Staging Environment",
|
||||
"dev-sandbox" => "Developer Sandbox",
|
||||
_ => tenantId
|
||||
},
|
||||
planName = "Enterprise",
|
||||
licensePeriod = new
|
||||
{
|
||||
start = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
end = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
quotaDetails = new
|
||||
{
|
||||
artifacts = new { current = 1247, limit = 10000, percentage = 12.5 },
|
||||
users = new { current = 28, limit = 200, percentage = 14.0 },
|
||||
scansPerDay = new { current = 64, limit = 400, percentage = 16.0 },
|
||||
storageMb = new { current = 14000, limit = 51200, percentage = 27.3 },
|
||||
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 }
|
||||
},
|
||||
usageByResourceType = new object[]
|
||||
{
|
||||
new { type = "Container Images", percentage = 42.5 },
|
||||
new { type = "SBOM Documents", percentage = 28.3 },
|
||||
new { type = "VEX Advisories", percentage = 15.7 },
|
||||
new { type = "Policy Artifacts", percentage = 8.2 },
|
||||
new { type = "Evidence Records", percentage = 5.3 }
|
||||
},
|
||||
forecast = new
|
||||
{
|
||||
category = "storage",
|
||||
exhaustionDays = 180,
|
||||
confidence = 0.82,
|
||||
trendSlope = 1.8,
|
||||
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
|
||||
severity = "info"
|
||||
}
|
||||
};
|
||||
|
||||
private static object[] BuildForecasts(string? category)
|
||||
{
|
||||
var all = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
category = "license",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.91,
|
||||
trendSlope = 3.2,
|
||||
recommendation = "License usage is well within limits. No action needed.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "jobs",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.88,
|
||||
trendSlope = 0.8,
|
||||
recommendation = "Job usage is stable. Current capacity is adequate.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "api",
|
||||
exhaustionDays = (int?)120,
|
||||
confidence = 0.76,
|
||||
trendSlope = 5.1,
|
||||
recommendation = "API usage is trending upward. Monitor closely if trend continues.",
|
||||
severity = "warning"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "storage",
|
||||
exhaustionDays = (int?)180,
|
||||
confidence = 0.82,
|
||||
trendSlope = 1.8,
|
||||
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
|
||||
severity = "info"
|
||||
},
|
||||
new
|
||||
{
|
||||
category = "scans",
|
||||
exhaustionDays = (int?)null,
|
||||
confidence = 0.94,
|
||||
trendSlope = 0.4,
|
||||
recommendation = "Scan quota usage is low and stable.",
|
||||
severity = "info"
|
||||
}
|
||||
};
|
||||
|
||||
// The anonymous objects don't expose "category" for filtering easily,
|
||||
// so we return all if no filter, otherwise filter by matching category string in serialized form.
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return all;
|
||||
}
|
||||
|
||||
// Return matching category only
|
||||
return category switch
|
||||
{
|
||||
"license" => [all[0]],
|
||||
"jobs" => [all[1]],
|
||||
"api" => [all[2]],
|
||||
"storage" => [all[3]],
|
||||
"scans" => [all[4]],
|
||||
_ => all
|
||||
};
|
||||
}
|
||||
|
||||
private static object BuildAlertConfig() => new
|
||||
{
|
||||
thresholds = new object[]
|
||||
{
|
||||
new { category = "license", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
|
||||
new { category = "jobs", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
|
||||
new { category = "api", enabled = true, warningThreshold = 70, criticalThreshold = 85 },
|
||||
new { category = "storage", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
|
||||
new { category = "scans", enabled = false, warningThreshold = 80, criticalThreshold = 95 }
|
||||
},
|
||||
channels = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "email",
|
||||
enabled = true,
|
||||
target = "ops-team@stella-ops.local",
|
||||
events = new[] { "warning", "critical", "recovery" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "slack",
|
||||
enabled = false,
|
||||
target = "#ops-alerts",
|
||||
events = new[] { "critical" }
|
||||
}
|
||||
},
|
||||
quietHours = new { start = "22:00", end = "06:00" },
|
||||
escalationMinutes = 30
|
||||
};
|
||||
|
||||
private static object[] BuildRateLimitStatuses(DateTimeOffset now)
|
||||
{
|
||||
return
|
||||
[
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/scanner/scan",
|
||||
method = "POST",
|
||||
limit = 60,
|
||||
remaining = 42,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 10,
|
||||
burstRemaining = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/findings",
|
||||
method = "GET",
|
||||
limit = 120,
|
||||
remaining = 98,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 20,
|
||||
burstRemaining = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/evidence",
|
||||
method = "POST",
|
||||
limit = 30,
|
||||
remaining = 24,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 5,
|
||||
burstRemaining = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
endpoint = "/api/v1/policy/evaluate",
|
||||
method = "POST",
|
||||
limit = 90,
|
||||
remaining = 71,
|
||||
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
|
||||
burstLimit = 15,
|
||||
burstRemaining = 12
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static object[] BuildRateLimitViolations(DateTimeOffset now) =>
|
||||
[
|
||||
new
|
||||
{
|
||||
id = "rlv-001",
|
||||
timestamp = now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "demo-prod",
|
||||
tenantName = "Demo Production",
|
||||
endpoint = "/api/v1/scanner/scan",
|
||||
method = "POST",
|
||||
limitType = "burst",
|
||||
currentRate = 14,
|
||||
rateLimit = 10,
|
||||
retryAfter = 8,
|
||||
recommendation = "Reduce scan concurrency or spread requests over a longer window."
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "rlv-002",
|
||||
timestamp = now.AddHours(-3).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "staging-01",
|
||||
tenantName = "Staging Environment",
|
||||
endpoint = "/api/v1/findings",
|
||||
method = "GET",
|
||||
limitType = "sustained",
|
||||
currentRate = 135,
|
||||
rateLimit = 120,
|
||||
retryAfter = 45,
|
||||
recommendation = "Implement client-side caching for findings queries to reduce request volume."
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "rlv-003",
|
||||
timestamp = now.AddDays(-1).ToString("O", CultureInfo.InvariantCulture),
|
||||
tenantId = "demo-prod",
|
||||
tenantName = "Demo Production",
|
||||
endpoint = "/api/v1/evidence",
|
||||
method = "POST",
|
||||
limitType = "burst",
|
||||
currentRate = 8,
|
||||
rateLimit = 5,
|
||||
retryAfter = 12,
|
||||
recommendation = "Batch evidence submissions to reduce burst pressure."
|
||||
}
|
||||
];
|
||||
|
||||
private static DateTimeOffset ParseDateOrDefault(string? dateStr, DateTimeOffset fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dateStr))
|
||||
return fallback;
|
||||
|
||||
return DateTimeOffset.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
// ---- Request / response DTOs (private, scoped to this compatibility layer) ----
|
||||
|
||||
private sealed record QuotaAlertConfigRequest(
|
||||
object[]? Thresholds,
|
||||
object[]? Channels,
|
||||
QuotaQuietHoursDto? QuietHours,
|
||||
int EscalationMinutes);
|
||||
|
||||
private sealed record QuotaQuietHoursDto(string Start, string End);
|
||||
|
||||
private sealed record QuotaReportRequestBody(
|
||||
string StartDate,
|
||||
string EndDate,
|
||||
string[]? TenantIds,
|
||||
string[]? Categories,
|
||||
string Format,
|
||||
bool IncludeForecasts,
|
||||
bool IncludeRecommendations);
|
||||
|
||||
private sealed record QuotaMetricDto(
|
||||
string Category,
|
||||
int Current,
|
||||
int Limit,
|
||||
double Percentage,
|
||||
string Status,
|
||||
string Trend,
|
||||
double TrendPercentage,
|
||||
string LastUpdated);
|
||||
|
||||
private sealed record TenantQuotasDto(
|
||||
QuotaMetricDto License,
|
||||
QuotaMetricDto Jobs,
|
||||
QuotaMetricDto Api,
|
||||
QuotaMetricDto Storage);
|
||||
|
||||
private sealed record TenantQuotaUsageDto(
|
||||
string TenantId,
|
||||
string TenantName,
|
||||
string PlanName,
|
||||
TenantQuotasDto Quotas,
|
||||
string Trend,
|
||||
double TrendPercentage,
|
||||
string LastActivity);
|
||||
}
|
||||
@@ -95,7 +95,7 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
|
||||
@@ -137,11 +137,13 @@
|
||||
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
|
||||
{ "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" },
|
||||
{ "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/orchestrator$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
|
||||
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
|
||||
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
|
||||
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
|
||||
|
||||
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
|
||||
|
||||
@@ -121,6 +121,35 @@ public sealed class PostgresVexSourceRepository : IVexSourceRepository
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateFailureTrackingAsync(
|
||||
string sourceId,
|
||||
int consecutiveFailures,
|
||||
DateTimeOffset? nextEligiblePollAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Sources.FirstOrDefaultAsync(s => s.SourceId == sourceId, cancellationToken);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Store failure count in LastErrorMessage when failures > 0
|
||||
if (consecutiveFailures > 0)
|
||||
{
|
||||
entity.LastErrorMessage = $"consecutive_failures={consecutiveFailures}; {entity.LastErrorMessage}";
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.LastErrorMessage = null;
|
||||
}
|
||||
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
259
src/Web/StellaOps.Web/scripts/live-first-time-user-audit.mjs
Normal file
259
src/Web/StellaOps.Web/scripts/live-first-time-user-audit.mjs
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* First-Time User Audit — Walks through Stella Ops as a brand-new user.
|
||||
* Captures: page load status, headings, visible actions, console errors, API errors,
|
||||
* empty states, and screenshots for every major surface.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const auditDir = path.join(outputDir, 'first-time-user-audit');
|
||||
const resultPath = path.join(outputDir, 'first-time-user-audit.json');
|
||||
|
||||
const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
|
||||
const journeyRoutes = [
|
||||
// Phase 2: First boot
|
||||
{ phase: 'setup', route: '/', label: 'Landing page (redirect)', scope: false },
|
||||
{ phase: 'setup', route: '/mission-control/board', label: 'Dashboard - Mission Board', scope: true },
|
||||
{ phase: 'setup', route: '/mission-control/alerts', label: 'Dashboard - Alerts', scope: true },
|
||||
{ phase: 'setup', route: '/mission-control/activity', label: 'Dashboard - Activity', scope: true },
|
||||
|
||||
// Phase 3A: Releases
|
||||
{ phase: 'releases', route: '/releases/deployments', label: 'Releases - Deployments', scope: true },
|
||||
{ phase: 'releases', route: '/releases/versions', label: 'Releases - Versions', scope: true },
|
||||
{ phase: 'releases', route: '/releases/approvals', label: 'Releases - Approvals', scope: true },
|
||||
{ phase: 'releases', route: '/releases/environments', label: 'Releases - Environments', scope: true },
|
||||
|
||||
// Phase 3C: Security
|
||||
{ phase: 'security', route: '/security/posture', label: 'Security - Posture', scope: true },
|
||||
{ phase: 'security', route: '/security/triage', label: 'Security - Triage', scope: true },
|
||||
{ phase: 'security', route: '/security/disposition', label: 'Security - Disposition', scope: true },
|
||||
{ phase: 'security', route: '/security/supply-chain-data', label: 'Security - Supply Chain', scope: true },
|
||||
{ phase: 'security', route: '/security/reachability', label: 'Security - Reachability', scope: true },
|
||||
|
||||
// Phase 3D: Evidence
|
||||
{ phase: 'evidence', route: '/evidence/overview', label: 'Evidence - Overview', scope: true },
|
||||
{ phase: 'evidence', route: '/evidence/capsules', label: 'Evidence - Capsules', scope: true },
|
||||
{ phase: 'evidence', route: '/evidence/verify-replay', label: 'Evidence - Verify/Replay', scope: true },
|
||||
{ phase: 'evidence', route: '/evidence/exports', label: 'Evidence - Exports', scope: true },
|
||||
|
||||
// Phase 3E: Policy
|
||||
{ phase: 'policy', route: '/ops/policy/overview', label: 'Policy - Overview', scope: true },
|
||||
{ phase: 'policy', route: '/ops/policy/simulation', label: 'Policy - Simulation', scope: true },
|
||||
{ phase: 'policy', route: '/ops/policy/baselines', label: 'Policy - Baselines', scope: true },
|
||||
{ phase: 'policy', route: '/ops/policy/gates', label: 'Policy - Gates', scope: true },
|
||||
{ phase: 'policy', route: '/ops/policy/waivers', label: 'Policy - Waivers', scope: true },
|
||||
|
||||
// Phase 3F: Operations
|
||||
{ phase: 'operations', route: '/ops/operations', label: 'Operations - Hub', scope: true },
|
||||
{ phase: 'operations', route: '/ops/operations/jobengine', label: 'Operations - JobEngine', scope: true },
|
||||
{ phase: 'operations', route: '/ops/operations/system-health', label: 'Operations - System Health', scope: true },
|
||||
{ phase: 'operations', route: '/ops/operations/doctor', label: 'Operations - Doctor', scope: true },
|
||||
{ phase: 'operations', route: '/ops/operations/signals', label: 'Operations - Signals', scope: true },
|
||||
{ phase: 'operations', route: '/ops/operations/dead-letter', label: 'Operations - Dead Letter', scope: true },
|
||||
|
||||
// Phase 4: Integrations
|
||||
{ phase: 'integrations', route: '/setup/integrations', label: 'Integrations - Hub', scope: false },
|
||||
{ phase: 'integrations', route: '/setup/integrations/advisory-vex-sources', label: 'Integrations - Advisory/VEX', scope: false },
|
||||
{ phase: 'integrations', route: '/setup/integrations/registries', label: 'Integrations - Registries', scope: false },
|
||||
{ phase: 'integrations', route: '/setup/integrations/scm', label: 'Integrations - SCM', scope: false },
|
||||
{ phase: 'integrations', route: '/setup/integrations/secrets', label: 'Integrations - Secrets', scope: false },
|
||||
|
||||
// Phase 5: Admin
|
||||
{ phase: 'admin', route: '/setup/topology', label: 'Setup - Topology', scope: false },
|
||||
{ phase: 'admin', route: '/setup/identity-access', label: 'Setup - Identity & Access', scope: false },
|
||||
{ phase: 'admin', route: '/setup/tenant-branding', label: 'Setup - Tenant Branding', scope: false },
|
||||
{ phase: 'admin', route: '/setup/notifications', label: 'Setup - Notifications', scope: false },
|
||||
{ phase: 'admin', route: '/setup/usage', label: 'Setup - Usage', scope: false },
|
||||
{ phase: 'admin', route: '/setup/trust-signing', label: 'Setup - Trust & Signing', scope: false },
|
||||
{ phase: 'admin', route: '/ops/platform-setup', label: 'Platform Setup - Home', scope: true },
|
||||
|
||||
// Phase 6: Edge cases
|
||||
{ phase: 'edge', route: '/nonexistent-route-12345', label: 'Edge - 404 page', scope: false },
|
||||
];
|
||||
|
||||
function buildUrl(route, addScope) {
|
||||
const url = new URL(route, BASE_URL);
|
||||
if (addScope) {
|
||||
url.searchParams.set('tenant', 'demo-prod');
|
||||
url.searchParams.set('regions', 'us-east');
|
||||
url.searchParams.set('environments', 'stage');
|
||||
url.searchParams.set('timeWindow', '7d');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
async function auditPage(page, entry) {
|
||||
const runtime = { consoleErrors: [], responseErrors: [], requestFailures: [] };
|
||||
|
||||
const consoleHandler = (msg) => {
|
||||
if (msg.type() === 'error') runtime.consoleErrors.push(cleanText(msg.text()));
|
||||
};
|
||||
const responseHandler = (resp) => {
|
||||
if (!/\.(css|js|map|png|jpg|svg|woff2?)(\?|$)/i.test(resp.url()) && resp.status() >= 400) {
|
||||
runtime.responseErrors.push({ status: resp.status(), url: resp.url().split('?')[0] });
|
||||
}
|
||||
};
|
||||
const failHandler = (req) => {
|
||||
if (!/\.(css|js|map|png|jpg|svg|woff2?)(\?|$)/i.test(req.url())) {
|
||||
runtime.requestFailures.push({ url: req.url().split('?')[0], error: req.failure()?.errorText });
|
||||
}
|
||||
};
|
||||
|
||||
page.on('console', consoleHandler);
|
||||
page.on('response', responseHandler);
|
||||
page.on('requestfailed', failHandler);
|
||||
|
||||
try {
|
||||
await page.goto(buildUrl(entry.route, entry.scope), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title().catch(() => '');
|
||||
|
||||
// Headings
|
||||
const headings = await page.locator('h1, main h2').evaluateAll((els) =>
|
||||
els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 10)
|
||||
).catch(() => []);
|
||||
|
||||
// Visible buttons and links (primary actions)
|
||||
const actions = await page.locator('button:visible, a[routerLink]:visible, a[href]:visible')
|
||||
.evaluateAll((els) => els.slice(0, 20).map((el) => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
||||
href: el.getAttribute('href') || el.getAttribute('routerlink') || '',
|
||||
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
|
||||
})).filter((a) => a.text && a.text.length > 1)
|
||||
).catch(() => []);
|
||||
|
||||
// Empty state detection
|
||||
const emptyIndicators = await page.locator('body').evaluate((body) => {
|
||||
const text = (body.textContent || '').toLowerCase();
|
||||
const indicators = [];
|
||||
if (text.includes('no data')) indicators.push('no_data');
|
||||
if (text.includes('no results')) indicators.push('no_results');
|
||||
if (text.includes('get started')) indicators.push('get_started');
|
||||
if (text.includes('loading')) indicators.push('loading_visible');
|
||||
if (text.includes('empty')) indicators.push('empty');
|
||||
if (text.includes('not found')) indicators.push('not_found');
|
||||
if (text.includes('error') || text.includes('went wrong')) indicators.push('error_text');
|
||||
if (text.includes('connect') && text.includes('configure')) indicators.push('setup_prompt');
|
||||
return indicators;
|
||||
}).catch(() => []);
|
||||
|
||||
// Body text snippet (first 500 chars of main content)
|
||||
const bodySnippet = await page.locator('main, [role="main"], .content, app-root').first()
|
||||
.evaluate((el) => (el?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 500))
|
||||
.catch(() => '');
|
||||
|
||||
// Screenshot
|
||||
const screenshotKey = entry.label.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
|
||||
const screenshotPath = path.join(auditDir, `${screenshotKey}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {});
|
||||
|
||||
return {
|
||||
phase: entry.phase,
|
||||
label: entry.label,
|
||||
route: entry.route,
|
||||
finalUrl: finalUrl.replace(BASE_URL, '').split('?')[0],
|
||||
title,
|
||||
headings,
|
||||
actionCount: actions.length,
|
||||
topActions: actions.slice(0, 8),
|
||||
emptyIndicators,
|
||||
bodySnippet: bodySnippet.slice(0, 300),
|
||||
consoleErrors: runtime.consoleErrors,
|
||||
responseErrors: [...new Map(runtime.responseErrors.map((e) => [e.status + e.url, e])).values()],
|
||||
requestFailures: runtime.requestFailures.slice(0, 5),
|
||||
screenshot: screenshotPath,
|
||||
};
|
||||
} finally {
|
||||
page.off('console', consoleHandler);
|
||||
page.off('response', responseHandler);
|
||||
page.off('requestfailed', failHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(auditDir, { recursive: true });
|
||||
|
||||
const statePath = path.join(outputDir, 'first-time-user-audit-auth.state.json');
|
||||
const reportPath = path.join(outputDir, 'first-time-user-audit-auth.report.json');
|
||||
|
||||
const auth = await authenticateFrontdoor({ statePath, reportPath });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await createAuthenticatedContext(browser, auth, { statePath });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(20000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
|
||||
const results = [];
|
||||
const issues = [];
|
||||
|
||||
for (const entry of journeyRoutes) {
|
||||
process.stdout.write(`[${entry.phase}] ${entry.label}...`);
|
||||
try {
|
||||
const result = await auditPage(page, entry);
|
||||
results.push(result);
|
||||
|
||||
// Detect issues
|
||||
if (result.consoleErrors.length > 0) {
|
||||
issues.push({ severity: 'medium', category: 'error', route: entry.route, label: entry.label, detail: `${result.consoleErrors.length} console error(s)` });
|
||||
}
|
||||
if (result.responseErrors.length > 0) {
|
||||
issues.push({ severity: 'high', category: 'api', route: entry.route, label: entry.label, detail: result.responseErrors.map((e) => `${e.status} ${e.url}`).join('; ') });
|
||||
}
|
||||
if (result.emptyIndicators.includes('error_text')) {
|
||||
issues.push({ severity: 'medium', category: 'ux', route: entry.route, label: entry.label, detail: 'Error text visible on page' });
|
||||
}
|
||||
if (result.headings.length === 0) {
|
||||
issues.push({ severity: 'low', category: 'ux', route: entry.route, label: entry.label, detail: 'No headings found (h1/h2)' });
|
||||
}
|
||||
|
||||
const status = result.responseErrors.length > 0 ? ' ISSUES' : ' OK';
|
||||
process.stdout.write(`${status}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(` ERROR: ${err.message?.slice(0, 80)}\n`);
|
||||
issues.push({ severity: 'critical', category: 'crash', route: entry.route, label: entry.label, detail: err.message?.slice(0, 200) });
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const report = {
|
||||
auditedAt: new Date().toISOString(),
|
||||
baseUrl: BASE_URL,
|
||||
totalPages: results.length,
|
||||
totalIssues: issues.length,
|
||||
issuesByPhase: {},
|
||||
results,
|
||||
issues,
|
||||
};
|
||||
|
||||
for (const issue of issues) {
|
||||
const phase = results.find((r) => r.route === issue.route)?.phase || 'unknown';
|
||||
report.issuesByPhase[phase] = (report.issuesByPhase[phase] || 0) + 1;
|
||||
}
|
||||
|
||||
await writeFile(resultPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\nAudit complete: ${results.length} pages, ${issues.length} issues`);
|
||||
console.log(`Results: ${resultPath}`);
|
||||
console.log(`Screenshots: ${auditDir}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[first-time-user-audit]', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -163,7 +163,7 @@ const strictRouteExpectations = {
|
||||
},
|
||||
'/setup/integrations/advisory-vex-sources': {
|
||||
title: /Advisory & VEX Sources/i,
|
||||
texts: ['Integrations', 'FeedMirror Integrations'],
|
||||
texts: ['Integrations', 'Advisory & VEX Source Catalog'],
|
||||
},
|
||||
'/setup/integrations/secrets': {
|
||||
title: /Secrets/i,
|
||||
@@ -191,6 +191,7 @@ const allowedFinalPaths = {
|
||||
'/ops/policy/audit': ['/ops/policy/audit/policy'],
|
||||
'/ops/platform-setup/trust-signing': ['/setup/trust-signing'],
|
||||
'/setup/topology': ['/setup/topology/overview'],
|
||||
'/security/posture': ['/security'],
|
||||
};
|
||||
|
||||
function buildRouteUrl(routePath) {
|
||||
|
||||
650
src/Web/StellaOps.Web/scripts/live-mirror-operator-journey.mjs
Normal file
650
src/Web/StellaOps.Web/scripts/live-mirror-operator-journey.mjs
Normal file
@@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const resultPath = path.join(outputDir, 'live-mirror-operator-journey.json');
|
||||
const authStatePath = path.join(outputDir, 'live-mirror-operator-journey.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-mirror-operator-journey.auth.json');
|
||||
const screenshotDir = path.join(outputDir, 'mirror-operator-journey');
|
||||
|
||||
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const tenantId = 'demo-prod';
|
||||
const scopeQuery = `tenant=${tenantId}®ions=us-east&environments=stage&timeWindow=7d`;
|
||||
const sourceApiBasePath = '/api/v1/advisory-sources';
|
||||
const mirrorApiBasePath = '/api/v1/advisory-sources/mirror';
|
||||
const setupCatalogRoute = '/setup/integrations/advisory-vex-sources';
|
||||
const opsCatalogRoute = '/ops/integrations/advisory-vex-sources';
|
||||
const setupMirrorRoute = '/setup/integrations/advisory-vex-sources/mirror';
|
||||
const opsMirrorRoute = '/ops/integrations/advisory-vex-sources/mirror';
|
||||
const setupMirrorNewRoute = '/setup/integrations/advisory-vex-sources/mirror/new';
|
||||
const opsMirrorNewRoute = '/ops/integrations/advisory-vex-sources/mirror/new';
|
||||
const setupMirrorClientRoute = '/setup/integrations/advisory-vex-sources/mirror/client-setup';
|
||||
const opsMirrorClientRoute = '/ops/integrations/advisory-vex-sources/mirror/client-setup';
|
||||
const mirrorJourneySuffix = Date.now().toString(36);
|
||||
const mirrorJourneyDomainId = `qa-mirror-${mirrorJourneySuffix}`;
|
||||
const mirrorJourneyDisplayName = `QA Mirror ${mirrorJourneySuffix}`;
|
||||
|
||||
function buildUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
|
||||
}
|
||||
|
||||
function isIgnorableFailure(request) {
|
||||
const url = request.url();
|
||||
const error = request.failure()?.errorText ?? '';
|
||||
return isStaticAsset(url) || /net::ERR_ABORTED|aborted/i.test(error);
|
||||
}
|
||||
|
||||
function attachRuntime(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push(cleanText(message.text()));
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
runtime.pageErrors.push(cleanText(error instanceof Error ? error.message : String(error)));
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (isIgnorableFailure(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? 'request failed',
|
||||
page: page.url(),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (isStaticAsset(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
status: response.status(),
|
||||
method: response.request().method(),
|
||||
url: response.url(),
|
||||
page: page.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(page, ms = 1200) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
|
||||
await page.waitForFunction(() => {
|
||||
const nodes = Array.from(document.querySelectorAll('[role="alert"], .banner, .banner--error'));
|
||||
return !nodes.some((node) => ((node.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase().startsWith('loading ')));
|
||||
}, { timeout: 10_000 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function navigate(page, route, ms = 1400) {
|
||||
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page, ms);
|
||||
}
|
||||
|
||||
async function heading(page) {
|
||||
const selectors = [
|
||||
'.source-catalog h1',
|
||||
'.mirror-dashboard h1',
|
||||
'.wizard h1',
|
||||
'[data-testid="page-title"]',
|
||||
'main h1',
|
||||
'h1',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const locator = page.locator(selector).first();
|
||||
const text = cleanText(await locator.textContent().catch(() => ''));
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function bannerTexts(page) {
|
||||
return page.locator('[role="alert"], .banner, .banner--error, .error-banner, .toast')
|
||||
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean))
|
||||
.catch(() => []);
|
||||
}
|
||||
|
||||
async function bodyText(page) {
|
||||
return cleanText(await page.locator('body').textContent().catch(() => ''));
|
||||
}
|
||||
|
||||
async function capture(page, key, extra = {}) {
|
||||
const screenshotPath = path.join(screenshotDir, `${key}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
|
||||
|
||||
return {
|
||||
key,
|
||||
url: page.url(),
|
||||
heading: await heading(page),
|
||||
banners: await bannerTexts(page),
|
||||
screenshotPath,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function pathPresent(url, expectedPath) {
|
||||
return typeof url === 'string' && url.includes(expectedPath);
|
||||
}
|
||||
|
||||
async function attemptClick(page, locator, label, waitMs = 1400) {
|
||||
const visible = await locator.isVisible().catch(() => false);
|
||||
if (!visible) {
|
||||
return { clicked: false, reason: `${label} not visible` };
|
||||
}
|
||||
|
||||
try {
|
||||
await locator.click({ timeout: 15_000 });
|
||||
await settle(page, waitMs);
|
||||
return { clicked: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
clicked: false,
|
||||
reason: cleanText(error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeApi(page, endpoint, init = {}) {
|
||||
return page.evaluate(async ({ endpointPath, tenant, method, requestBody }) => {
|
||||
try {
|
||||
// Extract access token from Angular session storage
|
||||
let authHeader = {};
|
||||
try {
|
||||
const raw = sessionStorage.getItem('stellaops.auth.session.full');
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
const token = session?.tokens?.accessToken;
|
||||
if (token) {
|
||||
authHeader = { 'Authorization': `Bearer ${token}` };
|
||||
}
|
||||
}
|
||||
} catch { /* token extraction failed; proceed without auth */ }
|
||||
|
||||
const response = await fetch(endpointPath, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Stella-Ops-Tenant': tenant,
|
||||
...authHeader,
|
||||
...(requestBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = JSON.parse(bodyText);
|
||||
} catch {
|
||||
responseBody = bodyText;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: responseBody,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}, {
|
||||
endpointPath: endpoint,
|
||||
tenant: tenantId,
|
||||
method: init.method ?? 'GET',
|
||||
requestBody: init.body ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await mkdir(screenshotDir, { recursive: true });
|
||||
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
|
||||
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
|
||||
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(20_000);
|
||||
page.setDefaultNavigationTimeout(30_000);
|
||||
attachRuntime(page, runtime);
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
await navigate(page, setupCatalogRoute);
|
||||
const mirrorConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
|
||||
const mirrorHealthApi = await invokeApi(page, `${mirrorApiBasePath}/health`);
|
||||
const mirrorDomainsApi = await invokeApi(page, `${mirrorApiBasePath}/domains`);
|
||||
results.push(await capture(page, 'catalog-direct', {
|
||||
hasConfigureMirrorLink: await page.getByRole('link', { name: 'Configure Mirror', exact: true }).isVisible().catch(() => false),
|
||||
hasConnectMirrorLink: await page.getByRole('link', { name: 'Connect to Mirror', exact: true }).isVisible().catch(() => false),
|
||||
hasCreateMirrorDomainButton: await page.getByRole('button', { name: 'Create Mirror Domain', exact: true }).isVisible().catch(() => false),
|
||||
mirrorConfigApi,
|
||||
mirrorHealthApi,
|
||||
mirrorDomainsApi,
|
||||
}));
|
||||
|
||||
await navigate(page, opsCatalogRoute);
|
||||
results.push(await capture(page, 'ops-catalog-direct', {
|
||||
hasConfigureMirrorLink: await page.getByRole('link', { name: 'Configure Mirror', exact: true }).isVisible().catch(() => false),
|
||||
hasConnectMirrorLink: await page.getByRole('link', { name: 'Connect to Mirror', exact: true }).isVisible().catch(() => false),
|
||||
hasCreateMirrorDomainButton: await page.getByRole('button', { name: 'Create Mirror Domain', exact: true }).isVisible().catch(() => false),
|
||||
}));
|
||||
|
||||
await navigate(page, setupCatalogRoute);
|
||||
const catalogConfigureResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('link', { name: 'Configure Mirror', exact: true }),
|
||||
'Catalog Configure Mirror',
|
||||
);
|
||||
results.push(await capture(page, 'catalog-configure-mirror-handoff', { clickResult: catalogConfigureResult }));
|
||||
|
||||
await navigate(page, setupCatalogRoute);
|
||||
const catalogCreateResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Create Mirror Domain', exact: true }),
|
||||
'Catalog Create Mirror Domain',
|
||||
);
|
||||
results.push(await capture(page, 'catalog-create-domain-handoff', { clickResult: catalogCreateResult }));
|
||||
|
||||
await navigate(page, setupMirrorRoute);
|
||||
const mirrorDashboardConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
|
||||
results.push(await capture(page, 'dashboard-direct', {
|
||||
hasCreateDomainButton: await page.getByRole('button', { name: 'Create Domain', exact: true }).isVisible().catch(() => false),
|
||||
hasSetupMirrorButton: await page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first().isVisible().catch(() => false),
|
||||
mirrorConfigApi: mirrorDashboardConfigApi,
|
||||
}));
|
||||
|
||||
await navigate(page, opsMirrorRoute);
|
||||
results.push(await capture(page, 'ops-dashboard-direct', {
|
||||
hasCreateDomainButton: await page.getByRole('button', { name: 'Create Domain', exact: true }).isVisible().catch(() => false),
|
||||
hasSetupMirrorButton: await page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first().isVisible().catch(() => false),
|
||||
}));
|
||||
|
||||
await navigate(page, setupMirrorRoute);
|
||||
const dashboardCreateResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Create Domain', exact: true }),
|
||||
'Dashboard Create Domain',
|
||||
);
|
||||
results.push(await capture(page, 'dashboard-create-domain-handoff', { clickResult: dashboardCreateResult }));
|
||||
|
||||
await navigate(page, setupMirrorRoute);
|
||||
const setupButton = page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first();
|
||||
const dashboardConsumerResult = await attemptClick(
|
||||
page,
|
||||
setupButton,
|
||||
'Dashboard Configure Consumer',
|
||||
);
|
||||
results.push(await capture(page, 'dashboard-configure-consumer-handoff', { clickResult: dashboardConsumerResult }));
|
||||
|
||||
await navigate(page, setupMirrorNewRoute);
|
||||
const sourceCheckboxes = page.locator('.source-row-label input[type="checkbox"]');
|
||||
const sourceCount = await sourceCheckboxes.count().catch(() => 0);
|
||||
if (sourceCount >= 2) {
|
||||
await sourceCheckboxes.nth(0).check({ force: true });
|
||||
await sourceCheckboxes.nth(1).check({ force: true });
|
||||
}
|
||||
const builderNextToConfig = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Next: Configure Domain', exact: true }),
|
||||
'Builder next to config',
|
||||
800,
|
||||
);
|
||||
if (builderNextToConfig.clicked) {
|
||||
await page.locator('#domainId').fill(mirrorJourneyDomainId).catch(() => {});
|
||||
await page.locator('#displayName').fill(mirrorJourneyDisplayName).catch(() => {});
|
||||
const signingKeyInput = page.locator('#signingKeyId');
|
||||
if (await signingKeyInput.isVisible().catch(() => false)) {
|
||||
await signingKeyInput.fill('operator-mirror-signing-key').catch(() => {});
|
||||
}
|
||||
}
|
||||
const builderNextToReview = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Next: Review', exact: true }),
|
||||
'Builder next to review',
|
||||
800,
|
||||
);
|
||||
const generateToggle = page.getByLabel('Generate mirror output immediately after creation');
|
||||
if (await generateToggle.isVisible().catch(() => false)) {
|
||||
await generateToggle.check({ force: true }).catch(() => {});
|
||||
}
|
||||
const builderCreateResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Create Domain', exact: true }),
|
||||
'Builder create domain',
|
||||
2400,
|
||||
);
|
||||
const createdDomainConfigApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/config`);
|
||||
const createdDomainEndpointsApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/endpoints`);
|
||||
const createdDomainStatusApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/status`);
|
||||
const publicMirrorIndexApi = await invokeApi(page, '/concelier/exports/index.json');
|
||||
const publicDomainManifestApi = await invokeApi(page, `/concelier/exports/mirror/${mirrorJourneyDomainId}/manifest.json`);
|
||||
const publicDomainBundleApi = await invokeApi(page, `/concelier/exports/mirror/${mirrorJourneyDomainId}/bundle.json`);
|
||||
results.push(await capture(page, 'builder-create-attempt', {
|
||||
sourceCount,
|
||||
nextToConfig: builderNextToConfig,
|
||||
nextToReview: builderNextToReview,
|
||||
createClick: builderCreateResult,
|
||||
createErrorBanner: cleanText(await page.locator('.banner--error').first().textContent().catch(() => '')),
|
||||
domainConfigApi: createdDomainConfigApi,
|
||||
domainEndpointsApi: createdDomainEndpointsApi,
|
||||
domainStatusApi: createdDomainStatusApi,
|
||||
publicMirrorIndexApi,
|
||||
publicDomainManifestApi,
|
||||
publicDomainBundleApi,
|
||||
}));
|
||||
|
||||
await navigate(page, opsMirrorNewRoute);
|
||||
results.push(await capture(page, 'ops-builder-direct', {
|
||||
sourceCount: await page.locator('.source-row-label input[type="checkbox"]').count().catch(() => 0),
|
||||
}));
|
||||
|
||||
await navigate(page, setupMirrorRoute);
|
||||
const viewEndpointsResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'View Endpoints', exact: true }).first(),
|
||||
'Dashboard view endpoints',
|
||||
1200,
|
||||
);
|
||||
const viewConfigResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'View Config', exact: true }).first(),
|
||||
'Dashboard view config',
|
||||
1200,
|
||||
);
|
||||
results.push(await capture(page, 'dashboard-domain-panels', {
|
||||
viewEndpointsResult,
|
||||
viewConfigResult,
|
||||
endpointsPanelText: cleanText(await page.locator('.card-endpoints').first().textContent().catch(() => '')),
|
||||
configPanelText: cleanText(await page.locator('.card-config').first().textContent().catch(() => '')),
|
||||
}));
|
||||
|
||||
await navigate(page, setupMirrorClientRoute);
|
||||
const clientConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
|
||||
const clientDomainsApi = await invokeApi(page, `${mirrorApiBasePath}/domains`);
|
||||
await page.locator('#mirrorAddress').fill(baseUrl).catch(() => {});
|
||||
const clientTestConnection = await attemptClick(
|
||||
page,
|
||||
page.getByRole('button', { name: 'Test Connection', exact: true }),
|
||||
'Mirror client test connection',
|
||||
2200,
|
||||
);
|
||||
const discoveredDomainOptions = await page.locator('#domainSelect option').count().catch(() => 0);
|
||||
const nextToSignatureVisible = await page.getByRole('button', { name: 'Next: Signature Verification', exact: true }).isVisible().catch(() => false);
|
||||
const nextToSignatureEnabled = nextToSignatureVisible
|
||||
? await page.getByRole('button', { name: 'Next: Signature Verification', exact: true }).isEnabled().catch(() => false)
|
||||
: false;
|
||||
results.push(await capture(page, 'client-setup-direct', {
|
||||
mirrorConfigApi: clientConfigApi,
|
||||
mirrorDomainsApi: clientDomainsApi,
|
||||
testConnection: clientTestConnection,
|
||||
connectedBanner: cleanText(await page.locator('.result-banner--success').first().textContent().catch(() => '')),
|
||||
connectionErrorBanner: cleanText(await page.locator('.result-banner--error').first().textContent().catch(() => '')),
|
||||
discoveredDomainOptions,
|
||||
nextToSignatureVisible,
|
||||
nextToSignatureEnabled,
|
||||
}));
|
||||
|
||||
await navigate(page, opsMirrorClientRoute);
|
||||
results.push(await capture(page, 'ops-client-setup-direct', {
|
||||
hasMirrorAddress: await page.locator('#mirrorAddress').isVisible().catch(() => false),
|
||||
hasTestConnectionButton: await page.getByRole('button', { name: 'Test Connection', exact: true }).isVisible().catch(() => false),
|
||||
}));
|
||||
|
||||
await navigate(page, '/ops/operations/feeds-airgap');
|
||||
const feedsConfigureResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('link', { name: 'Configure Sources', exact: true }),
|
||||
'Feeds & Airgap Configure Sources',
|
||||
1000,
|
||||
);
|
||||
results.push(await capture(page, 'feeds-airgap-configure-sources-handoff', { clickResult: feedsConfigureResult }));
|
||||
|
||||
await navigate(page, '/security');
|
||||
const securityConfigureResult = await attemptClick(
|
||||
page,
|
||||
page.getByRole('link', { name: /Configure sources|Configure advisory feeds|Configure VEX sources/i }).first(),
|
||||
'Security Configure sources',
|
||||
1000,
|
||||
);
|
||||
results.push(await capture(page, 'security-configure-sources-handoff', { clickResult: securityConfigureResult }));
|
||||
} finally {
|
||||
await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
const catalogDirect = results.find((entry) => entry.key === 'catalog-direct');
|
||||
if (catalogDirect?.heading !== 'Advisory & VEX Source Catalog') {
|
||||
failures.push(`Catalog direct route heading mismatch: ${catalogDirect?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!catalogDirect?.hasConfigureMirrorLink || !catalogDirect?.hasCreateMirrorDomainButton) {
|
||||
failures.push('Catalog direct route is missing primary mirror actions.');
|
||||
}
|
||||
if (!catalogDirect?.mirrorConfigApi?.ok) {
|
||||
failures.push(`Mirror config API is not healthy for catalog context: status=${catalogDirect?.mirrorConfigApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!catalogDirect?.mirrorDomainsApi?.ok) {
|
||||
failures.push(`Mirror domains API is not healthy for catalog context: status=${catalogDirect?.mirrorDomainsApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
|
||||
const opsCatalogDirect = results.find((entry) => entry.key === 'ops-catalog-direct');
|
||||
if (opsCatalogDirect?.heading !== 'Advisory & VEX Source Catalog') {
|
||||
failures.push(`Ops catalog direct route heading mismatch: ${opsCatalogDirect?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!opsCatalogDirect?.hasConfigureMirrorLink || !opsCatalogDirect?.hasCreateMirrorDomainButton) {
|
||||
failures.push('Ops catalog direct route is missing primary mirror actions.');
|
||||
}
|
||||
|
||||
const configureHandoff = results.find((entry) => entry.key === 'catalog-configure-mirror-handoff');
|
||||
if (configureHandoff?.clickResult && !configureHandoff.clickResult.clicked) {
|
||||
failures.push(`Catalog Configure Mirror action failed before navigation: ${configureHandoff.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(configureHandoff?.url, '/advisory-vex-sources/mirror')) {
|
||||
failures.push(`Catalog Configure Mirror did not land on mirror dashboard: ${configureHandoff?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const catalogCreate = results.find((entry) => entry.key === 'catalog-create-domain-handoff');
|
||||
if (catalogCreate?.clickResult && !catalogCreate.clickResult.clicked) {
|
||||
failures.push(`Catalog Create Mirror Domain action failed before navigation: ${catalogCreate.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(catalogCreate?.url, '/advisory-vex-sources/mirror/new')) {
|
||||
failures.push(`Catalog Create Mirror Domain did not land on /mirror/new: ${catalogCreate?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const dashboardDirect = results.find((entry) => entry.key === 'dashboard-direct');
|
||||
if (dashboardDirect?.heading !== 'Mirror Dashboard') {
|
||||
failures.push(`Mirror dashboard direct route heading mismatch: ${dashboardDirect?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!dashboardDirect?.mirrorConfigApi?.ok) {
|
||||
failures.push(`Mirror config API is not healthy for dashboard context: status=${dashboardDirect?.mirrorConfigApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
|
||||
const opsDashboard = results.find((entry) => entry.key === 'ops-dashboard-direct');
|
||||
if (opsDashboard?.heading !== 'Mirror Dashboard') {
|
||||
failures.push(`Ops mirror dashboard direct route heading mismatch: ${opsDashboard?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!opsDashboard?.hasCreateDomainButton || !opsDashboard?.hasSetupMirrorButton) {
|
||||
failures.push('Ops mirror dashboard is missing primary actions.');
|
||||
}
|
||||
|
||||
const dashboardCreate = results.find((entry) => entry.key === 'dashboard-create-domain-handoff');
|
||||
if (dashboardCreate?.clickResult && !dashboardCreate.clickResult.clicked) {
|
||||
failures.push(`Dashboard Create Domain action failed before navigation: ${dashboardCreate.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(dashboardCreate?.url, '/advisory-vex-sources/mirror/new')) {
|
||||
failures.push(`Dashboard Create Domain did not land on /mirror/new: ${dashboardCreate?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const dashboardConsumer = results.find((entry) => entry.key === 'dashboard-configure-consumer-handoff');
|
||||
if (dashboardConsumer?.clickResult && !dashboardConsumer.clickResult.clicked) {
|
||||
failures.push(`Dashboard Configure Consumer action failed before navigation: ${dashboardConsumer.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(dashboardConsumer?.url, '/advisory-vex-sources/mirror/client-setup')) {
|
||||
failures.push(`Dashboard Configure Consumer did not land on /mirror/client-setup: ${dashboardConsumer?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const builderAttempt = results.find((entry) => entry.key === 'builder-create-attempt');
|
||||
const builderText = cleanText(builderAttempt?.createErrorBanner || '');
|
||||
if (!builderAttempt?.nextToConfig?.clicked || !builderAttempt?.nextToReview?.clicked || !builderAttempt?.createClick?.clicked) {
|
||||
failures.push(`Mirror domain builder flow was blocked before submit. nextToConfig=${builderAttempt?.nextToConfig?.reason || 'ok'} nextToReview=${builderAttempt?.nextToReview?.reason || 'ok'} create=${builderAttempt?.createClick?.reason || 'ok'}`);
|
||||
}
|
||||
if (builderText || builderAttempt?.heading !== 'Mirror Domain Created') {
|
||||
failures.push(`Mirror domain create journey did not complete successfully. Banner="${builderText || '<none>'}" finalUrl="${builderAttempt?.url || '<empty>'}"`);
|
||||
}
|
||||
if (!builderAttempt?.domainConfigApi?.ok) {
|
||||
failures.push(`Created mirror domain config API failed: status=${builderAttempt?.domainConfigApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!builderAttempt?.domainEndpointsApi?.ok) {
|
||||
failures.push(`Created mirror domain endpoints API failed: status=${builderAttempt?.domainEndpointsApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!builderAttempt?.domainStatusApi?.ok) {
|
||||
failures.push(`Created mirror domain status API failed: status=${builderAttempt?.domainStatusApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!builderAttempt?.publicMirrorIndexApi?.ok) {
|
||||
failures.push(`Public mirror index was not reachable through the frontdoor: status=${builderAttempt?.publicMirrorIndexApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!builderAttempt?.publicDomainManifestApi?.ok) {
|
||||
failures.push(`Public mirror manifest was not reachable for ${mirrorJourneyDomainId}: status=${builderAttempt?.publicDomainManifestApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!builderAttempt?.publicDomainBundleApi?.ok) {
|
||||
failures.push(`Public mirror bundle was not reachable for ${mirrorJourneyDomainId}: status=${builderAttempt?.publicDomainBundleApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
|
||||
const opsBuilderDirect = results.find((entry) => entry.key === 'ops-builder-direct');
|
||||
if (opsBuilderDirect?.heading !== 'Create Mirror Domain') {
|
||||
failures.push(`Ops mirror builder direct route heading mismatch: ${opsBuilderDirect?.heading || '<empty>'}`);
|
||||
}
|
||||
|
||||
const dashboardPanels = results.find((entry) => entry.key === 'dashboard-domain-panels');
|
||||
if (dashboardPanels?.viewEndpointsResult && !dashboardPanels.viewEndpointsResult.clicked) {
|
||||
failures.push(`Mirror dashboard View Endpoints action failed: ${dashboardPanels.viewEndpointsResult.reason}`);
|
||||
}
|
||||
if (dashboardPanels?.viewConfigResult && !dashboardPanels.viewConfigResult.clicked) {
|
||||
failures.push(`Mirror dashboard View Config action failed: ${dashboardPanels.viewConfigResult.reason}`);
|
||||
}
|
||||
if (!dashboardPanels?.endpointsPanelText?.includes('/concelier/exports')) {
|
||||
failures.push('Mirror dashboard endpoints panel did not render public mirror paths.');
|
||||
}
|
||||
if (!dashboardPanels?.configPanelText?.includes('Index limit')) {
|
||||
failures.push('Mirror dashboard config panel did not render resolved domain configuration.');
|
||||
}
|
||||
|
||||
const clientSetup = results.find((entry) => entry.key === 'client-setup-direct');
|
||||
if (clientSetup?.heading !== 'Mirror Client Setup') {
|
||||
failures.push(`Mirror client setup direct route heading mismatch: ${clientSetup?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!clientSetup?.mirrorConfigApi?.ok) {
|
||||
failures.push(`Mirror config API is not healthy for client setup context: status=${clientSetup?.mirrorConfigApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (!clientSetup?.mirrorDomainsApi?.ok) {
|
||||
failures.push(`Mirror domains API is not healthy for client setup context: status=${clientSetup?.mirrorDomainsApi?.status ?? '<unknown>'}`);
|
||||
}
|
||||
if (clientSetup?.testConnection && !clientSetup.testConnection.clicked) {
|
||||
failures.push(`Mirror client Test Connection action failed before request completed: ${clientSetup.testConnection.reason}`);
|
||||
}
|
||||
if (clientSetup?.connectionErrorBanner) {
|
||||
failures.push(`Mirror client connection preflight failed: ${clientSetup.connectionErrorBanner}`);
|
||||
}
|
||||
if (!clientSetup?.nextToSignatureEnabled) {
|
||||
failures.push('Mirror client setup cannot proceed to signature verification after test connection.');
|
||||
}
|
||||
|
||||
const opsClientSetup = results.find((entry) => entry.key === 'ops-client-setup-direct');
|
||||
if (opsClientSetup?.heading !== 'Mirror Client Setup') {
|
||||
failures.push(`Ops mirror client setup direct route heading mismatch: ${opsClientSetup?.heading || '<empty>'}`);
|
||||
}
|
||||
if (!opsClientSetup?.hasMirrorAddress || !opsClientSetup?.hasTestConnectionButton) {
|
||||
failures.push('Ops mirror client setup is missing connection controls.');
|
||||
}
|
||||
|
||||
const feedsHandoff = results.find((entry) => entry.key === 'feeds-airgap-configure-sources-handoff');
|
||||
if (feedsHandoff?.clickResult && !feedsHandoff.clickResult.clicked) {
|
||||
failures.push(`Feeds & Airgap Configure Sources action failed before navigation: ${feedsHandoff.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(feedsHandoff?.url, '/integrations/advisory-vex-sources')) {
|
||||
failures.push(`Feeds & Airgap Configure Sources did not land on advisory source catalog: ${feedsHandoff?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const securityHandoff = results.find((entry) => entry.key === 'security-configure-sources-handoff');
|
||||
if (securityHandoff?.clickResult && !securityHandoff.clickResult.clicked) {
|
||||
failures.push(`Security Configure sources action failed before navigation: ${securityHandoff.clickResult.reason}`);
|
||||
}
|
||||
if (!pathPresent(securityHandoff?.url, '/integrations/advisory-vex-sources')) {
|
||||
failures.push(`Security Configure sources did not land on advisory source catalog: ${securityHandoff?.url || '<empty>'}`);
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
failedCheckCount: failures.length,
|
||||
runtimeIssueCount:
|
||||
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
|
||||
results,
|
||||
failures,
|
||||
runtime,
|
||||
};
|
||||
|
||||
await writeFile(resultPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
failedCheckCount: 1,
|
||||
runtimeIssueCount: 1,
|
||||
failures: [error instanceof Error ? error.message : String(error)],
|
||||
};
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await writeFile(resultPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
process.stderr.write(`${report.failures[0]}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -387,8 +387,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard],
|
||||
data: { breadcrumb: 'Mission Control' },
|
||||
loadChildren: () => import('./routes/mission-control.routes').then((m) => m.MISSION_CONTROL_ROUTES),
|
||||
title: 'Page Not Found',
|
||||
loadComponent: () => import('./features/not-found/not-found.component').then((m) => m.NotFoundComponent),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,12 +42,7 @@ interface NightlyOpsSignal {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface MissionSummary {
|
||||
activePromotions: number;
|
||||
blockedPromotions: number;
|
||||
highestRiskEnv: string;
|
||||
dataIntegrityStatus: 'healthy' | 'degraded' | 'error';
|
||||
}
|
||||
// MissionSummary removed — dashboard now computes from real environment data
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-v3',
|
||||
@@ -65,36 +60,75 @@ interface MissionSummary {
|
||||
|
||||
</header>
|
||||
|
||||
<!-- Mission Summary Strip -->
|
||||
<section class="mission-summary" aria-label="Mission summary">
|
||||
<div class="summary-card" [class.warning]="summary().blockedPromotions > 0">
|
||||
<div class="summary-value">{{ summary().activePromotions }}</div>
|
||||
<div class="summary-label">Active Promotions</div>
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge" class="summary-link">View all</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.critical]="summary().blockedPromotions > 0">
|
||||
<div class="summary-value">{{ summary().blockedPromotions }}</div>
|
||||
<div class="summary-label">Blocked Promotions</div>
|
||||
<a routerLink="/releases/approvals" queryParamsHandling="merge" class="summary-link">Review</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="summary-value env-name">{{ summary().highestRiskEnv }}</div>
|
||||
<div class="summary-label">Highest Risk Environment</div>
|
||||
<a routerLink="/security" queryParamsHandling="merge" class="summary-link">Risk detail</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'"
|
||||
[class.critical]="summary().dataIntegrityStatus === 'error'">
|
||||
<div class="summary-value">
|
||||
<span class="status-dot" [class]="summary().dataIntegrityStatus"></span>
|
||||
{{ summary().dataIntegrityStatus | titlecase }}
|
||||
@if (hasNoEnvironments()) {
|
||||
<!-- Welcome setup guide for fresh installs -->
|
||||
<section class="welcome-guide" aria-label="Getting started">
|
||||
<div class="welcome-header">
|
||||
<h2>Welcome to Stella Ops</h2>
|
||||
<p>Set up your release control plane in a few steps.</p>
|
||||
</div>
|
||||
<div class="summary-label">Data Integrity</div>
|
||||
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="summary-link">Ops detail</a>
|
||||
</div>
|
||||
</section>
|
||||
<div class="setup-steps">
|
||||
<a routerLink="/setup/integrations" class="setup-step">
|
||||
<span class="step-number">1</span>
|
||||
<div class="step-content">
|
||||
<strong>Connect a registry</strong>
|
||||
<span>Link your container registry for image discovery and scanning.</span>
|
||||
</div>
|
||||
</a>
|
||||
<a routerLink="/ops/platform-setup" class="setup-step">
|
||||
<span class="step-number">2</span>
|
||||
<div class="step-content">
|
||||
<strong>Define your topology</strong>
|
||||
<span>Set up regions, environments, and promotion paths.</span>
|
||||
</div>
|
||||
</a>
|
||||
<a routerLink="/security" class="setup-step">
|
||||
<span class="step-number">3</span>
|
||||
<div class="step-content">
|
||||
<strong>Review security posture</strong>
|
||||
<span>Scan images and review vulnerability findings.</span>
|
||||
</div>
|
||||
</a>
|
||||
<a routerLink="/releases/versions/new" class="setup-step">
|
||||
<span class="step-number">4</span>
|
||||
<div class="step-content">
|
||||
<strong>Create a release</strong>
|
||||
<span>Seal your first digest-first release version.</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<!-- Mission Summary Strip — real data from context -->
|
||||
<section class="mission-summary" aria-label="Mission summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-value">{{ filteredEnvironments().length }}</div>
|
||||
<div class="summary-label">Environments</div>
|
||||
<a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="summary-link">View all</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.critical]="blockedCount() > 0">
|
||||
<div class="summary-value">{{ blockedCount() }}</div>
|
||||
<div class="summary-label">Blocked Environments</div>
|
||||
<a routerLink="/releases/approvals" queryParamsHandling="merge" class="summary-link">Review</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.warning]="degradedCount() > 0">
|
||||
<div class="summary-value">{{ degradedCount() }}</div>
|
||||
<div class="summary-label">Degraded Environments</div>
|
||||
<a routerLink="/security" queryParamsHandling="merge" class="summary-link">Risk detail</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="summary-value">
|
||||
<span class="status-dot healthy"></span>
|
||||
{{ healthyCount() }}
|
||||
</div>
|
||||
<div class="summary-label">Healthy Environments</div>
|
||||
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="summary-link">Ops detail</a>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Regional Pipeline Board -->
|
||||
<section class="pipeline-board" aria-label="Regional pipeline board">
|
||||
@@ -324,67 +358,15 @@ interface MissionSummary {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<section class="alerts-section" aria-label="Alerts">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Alerts</h2>
|
||||
</div>
|
||||
<div class="alerts-card">
|
||||
<ul class="alerts-list">
|
||||
<li><a routerLink="/releases/approvals" queryParamsHandling="merge">3 approvals blocked by policy gate evidence freshness</a></li>
|
||||
<li><a routerLink="/security/disposition" queryParamsHandling="merge">2 waivers expiring within 24h</a></li>
|
||||
<li><a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Feed freshness degraded for advisory ingest</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Quick Links -->
|
||||
<section class="quick-links" aria-label="Quick links">
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge" class="domain-nav-item">Release Runs</a>
|
||||
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">Security & Risk</a>
|
||||
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">Operations</a>
|
||||
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">Evidence</a>
|
||||
<a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">Platform Setup</a>
|
||||
<a routerLink="/ops/operations/doctor" queryParamsHandling="merge" class="domain-nav-item">Diagnostics</a>
|
||||
</section>
|
||||
|
||||
<!-- Activity -->
|
||||
<section class="activity-section" aria-label="Recent activity">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Recent Activity</h2>
|
||||
</div>
|
||||
<div class="activity-grid">
|
||||
<article class="activity-card">
|
||||
<h3 class="activity-card-title">Release Runs</h3>
|
||||
<p class="activity-card-desc">Latest standard and hotfix promotions with gate checkpoints.</p>
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge" class="activity-card-link">Open Runs</a>
|
||||
</article>
|
||||
<article class="activity-card">
|
||||
<h3 class="activity-card-title">Evidence</h3>
|
||||
<p class="activity-card-desc">Newest decision capsules and replay verification outcomes.</p>
|
||||
<a routerLink="/evidence/capsules" queryParamsHandling="merge" class="activity-card-link">Open Capsules</a>
|
||||
</article>
|
||||
<article class="activity-card">
|
||||
<h3 class="activity-card-title">Audit</h3>
|
||||
<p class="activity-card-desc">Unified activity trail by actor, resource, and correlation key.</p>
|
||||
<a routerLink="/evidence/audit-log" queryParamsHandling="merge" class="activity-card-link">Open Audit Log</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cross-domain navigation links -->
|
||||
<nav class="domain-nav" aria-label="Domain navigation">
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">▶</span>
|
||||
Release Runs
|
||||
</a>
|
||||
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">■</span>
|
||||
Security & Risk
|
||||
</a>
|
||||
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">◆</span>
|
||||
Platform
|
||||
</a>
|
||||
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">●</span>
|
||||
Evidence (Decision Capsules)
|
||||
</a>
|
||||
<a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">
|
||||
<span class="domain-icon">⚙</span>
|
||||
Platform Setup
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -418,6 +400,92 @@ interface MissionSummary {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Welcome Guide */
|
||||
.welcome-guide {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-header h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.welcome-header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.setup-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-surface-elevated);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.setup-step:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.step-content strong {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.step-content span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Quick Links */
|
||||
.quick-links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mission Summary Strip */
|
||||
.mission-summary {
|
||||
display: grid;
|
||||
@@ -962,86 +1030,22 @@ interface MissionSummary {
|
||||
export class DashboardV3Component {
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
// Placeholder mission summary data
|
||||
readonly summary = signal<MissionSummary>({
|
||||
activePromotions: 3,
|
||||
blockedPromotions: 1,
|
||||
highestRiskEnv: 'prod-eu-west',
|
||||
dataIntegrityStatus: 'healthy',
|
||||
});
|
||||
readonly hasNoEnvironments = computed(() => this.context.environments().length === 0);
|
||||
|
||||
private readonly fallbackEnvironments: EnvironmentCard[] = [
|
||||
{
|
||||
id: 'dev-eu-west',
|
||||
environmentId: 'dev',
|
||||
regionId: 'eu-west',
|
||||
name: 'dev',
|
||||
region: 'EU West',
|
||||
deployStatus: 'healthy',
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 0,
|
||||
highRCount: 2,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: '2h ago',
|
||||
},
|
||||
{
|
||||
id: 'stage-eu-west',
|
||||
environmentId: 'stage',
|
||||
regionId: 'eu-west',
|
||||
name: 'stage',
|
||||
region: 'EU West',
|
||||
deployStatus: 'degraded',
|
||||
sbomFreshness: 'stale',
|
||||
critRCount: 1,
|
||||
highRCount: 5,
|
||||
birCoverage: '2/3',
|
||||
pendingApprovals: 2,
|
||||
lastDeployedAt: '6h ago',
|
||||
},
|
||||
{
|
||||
id: 'prod-eu-west',
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
name: 'prod',
|
||||
region: 'EU West',
|
||||
deployStatus: 'healthy',
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 3,
|
||||
highRCount: 8,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 1,
|
||||
lastDeployedAt: '1d ago',
|
||||
},
|
||||
{
|
||||
id: 'dev-us-east',
|
||||
environmentId: 'dev',
|
||||
regionId: 'us-east',
|
||||
name: 'dev',
|
||||
region: 'US East',
|
||||
deployStatus: 'healthy',
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 0,
|
||||
highRCount: 1,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: '3h ago',
|
||||
},
|
||||
{
|
||||
id: 'prod-us-east',
|
||||
environmentId: 'prod',
|
||||
regionId: 'us-east',
|
||||
name: 'prod',
|
||||
region: 'US East',
|
||||
deployStatus: 'blocked',
|
||||
sbomFreshness: 'missing',
|
||||
critRCount: 5,
|
||||
highRCount: 12,
|
||||
birCoverage: '1/3',
|
||||
pendingApprovals: 3,
|
||||
lastDeployedAt: '3d ago',
|
||||
},
|
||||
];
|
||||
readonly blockedCount = computed(() =>
|
||||
this.filteredEnvironments().filter((env) => env.deployStatus === 'blocked').length
|
||||
);
|
||||
|
||||
readonly degradedCount = computed(() =>
|
||||
this.filteredEnvironments().filter((env) => env.deployStatus === 'degraded').length
|
||||
);
|
||||
|
||||
readonly healthyCount = computed(() =>
|
||||
this.filteredEnvironments().filter((env) => env.deployStatus === 'healthy').length
|
||||
);
|
||||
|
||||
// No fake fallback data — dashboard shows setup guide when no environments exist.
|
||||
private readonly fallbackEnvironments: EnvironmentCard[] = [];
|
||||
|
||||
constructor() {
|
||||
this.context.initialize();
|
||||
@@ -1089,39 +1093,15 @@ export class DashboardV3Component {
|
||||
};
|
||||
});
|
||||
|
||||
// Placeholder reachability stats
|
||||
// Reachability stats — zeroed until real scan data is available
|
||||
readonly reachabilityStats = signal({
|
||||
bCoverage: 72,
|
||||
iCoverage: 88,
|
||||
rCoverage: 61,
|
||||
bCoverage: 0,
|
||||
iCoverage: 0,
|
||||
rCoverage: 0,
|
||||
});
|
||||
|
||||
readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([
|
||||
{
|
||||
id: 'sbom-rescan',
|
||||
label: 'SBOM rescan',
|
||||
status: 'ok',
|
||||
detail: 'Nightly SBOM rescan completed in 14m.',
|
||||
},
|
||||
{
|
||||
id: 'nvd-feed',
|
||||
label: 'NVD feed',
|
||||
status: 'warn',
|
||||
detail: 'Feed freshness is stale (3h 12m).',
|
||||
},
|
||||
{
|
||||
id: 'integration-health',
|
||||
label: 'Integration health',
|
||||
status: 'ok',
|
||||
detail: 'Key connectors are operational.',
|
||||
},
|
||||
{
|
||||
id: 'dlq',
|
||||
label: 'DLQ',
|
||||
status: 'warn',
|
||||
detail: '3 pending items require replay.',
|
||||
},
|
||||
]);
|
||||
// Ops signals — empty until real signal data is available
|
||||
readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([]);
|
||||
|
||||
environmentPostureRoute(env: EnvironmentCard): string[] {
|
||||
return ['/setup/topology/environments', env.environmentId, 'posture'];
|
||||
@@ -1161,44 +1141,18 @@ export class DashboardV3Component {
|
||||
}
|
||||
|
||||
private resolveStatusSeed(
|
||||
environment: PlatformContextEnvironment,
|
||||
index: number,
|
||||
_environment: PlatformContextEnvironment,
|
||||
_index: number,
|
||||
): Omit<EnvironmentCard, 'id' | 'environmentId' | 'regionId' | 'name' | 'region'> {
|
||||
const environmentType = environment.environmentType.toLowerCase();
|
||||
|
||||
if (environmentType === 'development') {
|
||||
return {
|
||||
deployStatus: 'healthy',
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 0,
|
||||
highRCount: 1,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: '3h ago',
|
||||
};
|
||||
}
|
||||
|
||||
if (environmentType === 'staging') {
|
||||
return {
|
||||
deployStatus: 'degraded',
|
||||
sbomFreshness: 'stale',
|
||||
critRCount: 1,
|
||||
highRCount: 4,
|
||||
birCoverage: '2/3',
|
||||
pendingApprovals: 2,
|
||||
lastDeployedAt: '6h ago',
|
||||
};
|
||||
}
|
||||
|
||||
const blockedProduction = environment.regionId === 'us-east' && index % 2 === 0;
|
||||
// Honest defaults — no scan data available until real backends report
|
||||
return {
|
||||
deployStatus: blockedProduction ? 'blocked' : 'healthy',
|
||||
sbomFreshness: blockedProduction ? 'missing' : 'fresh',
|
||||
critRCount: blockedProduction ? 5 : 2,
|
||||
highRCount: blockedProduction ? 12 : 6,
|
||||
birCoverage: blockedProduction ? '1/3' : '3/3',
|
||||
pendingApprovals: blockedProduction ? 3 : 1,
|
||||
lastDeployedAt: blockedProduction ? '3d ago' : '1d ago',
|
||||
deployStatus: 'unknown',
|
||||
sbomFreshness: 'missing',
|
||||
critRCount: 0,
|
||||
highRCount: 0,
|
||||
birCoverage: '0/0',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: 'No deployments',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MirrorDomainResponse,
|
||||
MirrorDomainGenerateResponse,
|
||||
} from './mirror-management.api';
|
||||
import { buildAdvisoryVexCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -1466,7 +1467,7 @@ export class MirrorDomainBuilderComponent implements OnInit {
|
||||
}
|
||||
|
||||
navigateToSources(): void {
|
||||
this.router.navigate(['/ops/integrations/advisory-vex-sources']);
|
||||
this.router.navigate(buildAdvisoryVexCommands(this.router), getAdvisoryVexNavigationExtras());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1480,7 +1481,7 @@ export class MirrorDomainBuilderComponent implements OnInit {
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.catalog.set(response.items ?? []);
|
||||
this.catalog.set((response.items ?? []).filter(s => s.category !== 'Mirror'));
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="not-found">
|
||||
<div class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<div class="actions">
|
||||
<a routerLink="/mission-control/board" class="btn btn-primary">Go to Dashboard</a>
|
||||
<a routerLink="/setup/integrations" class="btn btn-secondary">Setup & Integrations</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-muted, #4b5563);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class NotFoundComponent {}
|
||||
@@ -58,9 +58,9 @@ import {
|
||||
<label>
|
||||
Target path intent *
|
||||
<select [(ngModel)]="form.targetPathIntent">
|
||||
<option value="dev-stage-prod">Dev ? Stage ? Prod</option>
|
||||
<option value="stage-prod">Stage ? Prod</option>
|
||||
<option value="hotfix-prod">Hotfix ? Prod</option>
|
||||
<option value="dev-stage-prod">Dev → Stage → Prod</option>
|
||||
<option value="stage-prod">Stage → Prod</option>
|
||||
<option value="hotfix-prod">Hotfix → Prod</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user