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:
master
2026-03-16 02:05:38 +02:00
parent f4d3ef76db
commit 534aabfa2a
21 changed files with 3195 additions and 304 deletions

View File

@@ -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 =====

View File

@@ -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);
}

View File

@@ -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" },

View File

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

View 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);
});

View File

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

View 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}&regions=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);
});

View File

@@ -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),
},
];

View File

@@ -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">&#9654;</span>
Release Runs
</a>
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9632;</span>
Security &amp; Risk
</a>
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9670;</span>
Platform
</a>
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9679;</span>
Evidence (Decision Capsules)
</a>
<a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9881;</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',
};
}

View File

@@ -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: () => {

View File

@@ -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 {}

View File

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