Fix release health multi-scope evidence contracts
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Sprint 20260307-012 - FE Mission Control Release Health Environment Resolution
|
||||
|
||||
## Topic & Scope
|
||||
- Repair `/mission-control/release-health` so it resolves and displays the active environment instead of rendering an empty environment header shell.
|
||||
- Reuse the already-synced global query context (`environment`, `environments`, `env`) as a fallback when the route has no `:environmentId` segment.
|
||||
- Add focused Web tests for query-driven environment resolution and the explicit no-environment empty state, then verify the live route with Playwright.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: focused Web unit tests, live Playwright verification on `https://stella-ops.local`, and sprint execution log updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the earlier Topology context persistence iterations (`SPRINT_20260307_010` and `SPRINT_20260307_011`) because `/mission-control/release-health` now receives stable synced query context from the global platform context store.
|
||||
- Safe parallelism: stay inside `src/Web/StellaOps.Web` plus sprint updates; do not touch unrelated settings/sidebar/user-menu work already in progress from other agents.
|
||||
- QA lead: route was previously listed as FALLBACK in `docs/qa/baseline-test-results-2026-02-26.md`, so this pass revalidates that route at current runtime behavior instead of treating the old baseline as the final truth.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
- `src/Web/StellaOps.Web/src/app/routes/mission-control.routes.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-RH-001 - Reproduce the live release-health context resolution gap
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Replay `/mission-control/release-health` with live Playwright after the Topology context persistence fix landed.
|
||||
- Confirm whether the page resolves an environment or still renders the generic header/empty summaries.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright captures the current `release-health` page behavior.
|
||||
- [x] The route defect is isolated to environment resolution rather than route existence.
|
||||
|
||||
### FE-RH-002 - Resolve environment context from route or synced query state
|
||||
Status: DONE
|
||||
Dependency: FE-RH-001
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Update `EnvironmentPosturePageComponent` so it falls back to query-context environment identifiers when the route lacks `:environmentId`.
|
||||
- Show an explicit guidance message when no environment can be resolved instead of rendering a misleading empty summary.
|
||||
- Add focused unit tests for query fallback and the no-environment state.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `/mission-control/release-health` resolves `dev` from synced query context and loads posture data.
|
||||
- [x] The component shows an explicit empty-state/error message when no environment is available.
|
||||
- [x] Focused unit tests cover both the query fallback and missing-environment behavior.
|
||||
|
||||
### FE-RH-003 - Replay the live mission-control release-health route
|
||||
Status: DONE
|
||||
Dependency: FE-RH-002
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Replay the live route with Playwright after the Web patch is built into the running stack.
|
||||
- Confirm the page shows a resolved environment label/region and that the quick links remain user-reachable.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright shows the resolved environment label instead of the generic header shell.
|
||||
- [x] Quick links from `release-health` remain reachable without degraded state.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created after live Playwright on `/mission-control/release-health` showed the route no longer fell back, but still rendered a generic environment header with empty summaries because the component expected a route param that the mission-control route never provided. | QA |
|
||||
| 2026-03-07 | Added query-context environment fallback plus explicit no-environment guidance in `EnvironmentPosturePageComponent`; focused Angular specs for query fallback and missing-context behavior passed via `npx ng test --watch=false --include src/app/core/testing/environment-posture-page.component.spec.ts --include src/app/core/testing/global-context-http.interceptor.spec.ts`. | Developer |
|
||||
| 2026-03-07 | Replayed `/mission-control/release-health` with live Playwright after rebuilding the Web bundle. The route resolved `Development · us-east`, Pack22 requests returned `200`, and the action links to Release Runs, Findings, and Decision Capsules stayed reachable without degraded state. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: fix the component at the environment-resolution layer rather than adding a route parameter to mission-control, because the route is already fed by the global query context sync and should honor that state.
|
||||
- Decision: keep the shared multi-scope/evidence contract work in Sprint `20260307-013` because the component fix alone did not address narrowed region scope or the missing evidence adapter.
|
||||
- Risk: other routes may still mount shared components with mismatched parameter expectations, so the broader mission-control sweep should continue after this fix.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-07: continue the mission-control/topology Playwright sweep for the next live defects outside this component-specific environment-resolution fix.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sprint 20260307-013 - Platform Release Health Multi-Scope Evidence Adapter
|
||||
|
||||
## Topic & Scope
|
||||
- Repair the Pack22 release-health contract so multi-region context is preserved across Platform read models instead of being collapsed to the first selected region.
|
||||
- Add the missing `/api/v2/evidence/packs` adapter required by shared topology and mission-control environment surfaces.
|
||||
- Align the Web release-health/topology environment surfaces with multi-region environment scope so headers and evidence summaries no longer degrade into `unknown-region` or silent `404 -> empty` behavior.
|
||||
- Working directory: `src/Platform/StellaOps.Platform.WebService`.
|
||||
- Expected evidence: focused Platform endpoint tests, focused Web unit tests, live Playwright verification on `https://stella-ops.local`, and updated docs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on `SPRINT_20260307_012_FE_mission_control_release_health_environment_resolution` for the initial route/query-context repair in the shared release-health component.
|
||||
- Safe parallelism: Platform contract work may update the consuming Web release-health/topology files and the global context interceptor, but must not touch unrelated UI/navigation/settings work already in progress from other agents.
|
||||
- This iteration is scoped to release-health/topology/evidence contract correctness only; broader evidence UX and cross-module correlation filters remain separate follow-up work.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `src/Platform/AGENTS.md`
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### PLAT-RH-001 - Reproduce multi-scope release-health contract failures
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Replay `/mission-control/release-health` with live Playwright after the route fallback fix and capture the real backend request set.
|
||||
- Confirm whether the route still narrows context incorrectly or calls missing evidence contracts.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright captures the actual request/response set for release-health.
|
||||
- [x] Root cause is reduced to concrete contract defects instead of generic empty-state rendering.
|
||||
|
||||
### PLAT-RH-002 - Preserve multi-select scope through Platform read models
|
||||
Status: DONE
|
||||
Dependency: PLAT-RH-001
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Update the global Web context propagation and Platform release read models so multi-region/multi-environment scope remains a real filter set instead of collapsing to the first selected value.
|
||||
- Add targeted backend tests that prove comma-delimited scope filters are honored deterministically.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Multi-region scope no longer collapses to `region=<first-selected>` semantics for release-health traffic.
|
||||
- [x] Release read-model endpoints honor comma-delimited region/environment filters.
|
||||
- [x] Targeted backend tests prove deterministic multi-scope behavior.
|
||||
|
||||
### PLAT-RH-003 - Add the Pack22 evidence packs adapter for release-health/topology
|
||||
Status: DONE
|
||||
Dependency: PLAT-RH-001
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Implement `/api/v2/evidence/packs` in Platform as the Pack22 read-model adapter required by shared topology and mission-control environment views.
|
||||
- Return deterministic list payloads derived from release evidence posture instead of allowing the UI to hit a nonexistent route.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `/api/v2/evidence/packs` returns `200` on the live stack.
|
||||
- [x] Shared topology/release-health consumers can load decision capsule summaries without silent `404` fallback.
|
||||
- [x] Targeted backend tests cover deterministic list behavior and policy wiring.
|
||||
|
||||
### PLAT-RH-004 - Rebind shared environment surfaces to multi-scope summaries
|
||||
Status: DONE
|
||||
Dependency: PLAT-RH-002
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Update the shared environment posture/detail UI so multi-region environment scope renders an accurate label instead of `unknown-region`.
|
||||
- Keep the route query fallback from Sprint 012 while making the shared presentation reflect the resolved scope and live evidence contract.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `/mission-control/release-health` shows a resolved environment label and multi-region summary.
|
||||
- [x] Shared topology environment surfaces stop showing `unknown-region` for valid multi-scope selections.
|
||||
- [x] Focused Web tests cover multi-region scope presentation and context propagation.
|
||||
|
||||
### PLAT-RH-005 - Replay the live release-health flow end to end
|
||||
Status: DONE
|
||||
Dependency: PLAT-RH-003
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Rebuild only the touched Platform/Web artifacts, redeploy them, and replay the release-health route plus its action links with live Playwright.
|
||||
- Confirm the page loads real data contracts and no longer masks missing evidence or narrowed region scope.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live Playwright shows a resolved environment/region summary without `unknown-region`.
|
||||
- [x] Live Playwright captures `200` responses for the release-health evidence/read-model requests.
|
||||
- [x] Release-health action links remain reachable after the contract fixes.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created after live Playwright on `/mission-control/release-health` showed two deeper contract defects: global context narrowed multi-region scope to `region=apac`, and the shared component called missing `/api/v2/evidence/packs` routes while rendering false empty summaries. | QA |
|
||||
| 2026-03-07 | Updated the global context HTTP interceptor and Platform release read models to preserve comma-delimited region/environment scope sets end to end; focused Angular specs passed and focused Platform endpoint tests passed through the Platform test executable (`Total: 8, Errors: 0, Failed: 0, Skipped: 0`). | Developer |
|
||||
| 2026-03-07 | Added the Platform `/api/v2/evidence/packs` adapter, rebuilt the scoped Platform container and Web bundle, and replayed `/mission-control/release-health` plus shared topology environment actions with live Playwright. Post-auth route checks were clean, Pack22 requests returned `200`, and the shared action links stayed reachable without degraded state. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: treat the release-health failure as a shared Pack22 contract problem, not a one-off FE route bug, because the same evidence/scope defects impact shared topology environment surfaces.
|
||||
- Decision: keep the iteration scoped to Platform read models plus the directly affected Web consumers, rather than broadening into general evidence UX work.
|
||||
- Decision: implement `/api/v2/evidence/packs` as a deterministic Platform read-model adapter derived from release run evidence posture so Pack22 surfaces consume a canonical Console backend contract instead of probing nonexistent evidence routes.
|
||||
- Risk: the signed-out shell still emits unauthenticated `/api/v2/context/*` and `/api/v1/doctor/scheduler/trends/categories/*` bootstrap requests before auth is established; that is a separate live defect for the next QA/developer iteration.
|
||||
- Risk: other pages may still assume single-region scope in ad hoc component code; continue Playwright sweeps after this iteration lands.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-07: start the next live-auth/bootstrap iteration on the signed-out shell `401` requests discovered during the topology sweep.
|
||||
@@ -87,7 +87,7 @@ graph TD
|
||||
Observability -.-> Notify
|
||||
```
|
||||
|
||||
Platform Service (StellaOps.Platform.WebService) aggregates cross-service status for the Console UI (health, quotas, onboarding, preferences, global search) and does not mutate raw evidence.
|
||||
Platform Service (StellaOps.Platform.WebService) aggregates cross-service status for the Console UI (health, quotas, onboarding, preferences, global search) and does not mutate raw evidence. Pack22 environment posture surfaces also consume Platform v2 read-model adapters, including `/api/v2/evidence/packs`, with comma-delimited `region` and `environment` scope sets preserved end to end so multi-select Console context is not collapsed by the backend.
|
||||
|
||||
Key boundaries:
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record EvidencePackProjection(
|
||||
string CapsuleId,
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string? Environment,
|
||||
string? Region,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CapsuleRoute);
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class EvidenceReadModelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEvidenceReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var evidence = app.MapGroup("/api/v2/evidence")
|
||||
.WithTags("Evidence V2")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead)
|
||||
.RequireTenant();
|
||||
|
||||
evidence.MapGet("/packs", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] EvidencePackListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListEvidencePacksAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<EvidencePackProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListEvidencePacksV2")
|
||||
.WithSummary("List Pack-22 evidence pack projections")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed record EvidencePackListQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -321,6 +321,7 @@ app.MapReleaseControlEndpoints();
|
||||
app.MapReleaseReadModelEndpoints();
|
||||
app.MapTopologyReadModelEndpoints();
|
||||
app.MapSecurityReadModelEndpoints();
|
||||
app.MapEvidenceReadModelEndpoints();
|
||||
app.MapIntegrationReadModelEndpoints();
|
||||
app.MapLegacyAliasEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
|
||||
@@ -37,8 +37,8 @@ public sealed class ReleaseReadModelService
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var normalizedRegion = NormalizeFilter(region);
|
||||
var normalizedEnvironment = NormalizeFilter(environment);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedType = NormalizeFilter(releaseType);
|
||||
var normalizedStatus = NormalizeFilter(status);
|
||||
|
||||
@@ -66,8 +66,8 @@ public sealed class ReleaseReadModelService
|
||||
var projected = bundles
|
||||
.Select(bundle => BuildReleaseProjection(bundle, runsByBundle.GetValueOrDefault(bundle.Id, Array.Empty<ReleaseControlBundleMaterializationRun>())))
|
||||
.Where(release =>
|
||||
(string.IsNullOrEmpty(normalizedRegion) || string.Equals(release.TargetRegion, normalizedRegion, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedEnvironment) || string.Equals(release.TargetEnvironment, normalizedEnvironment, StringComparison.Ordinal))
|
||||
MatchesFilters(release.TargetRegion, regionFilter)
|
||||
&& MatchesFilters(release.TargetEnvironment, environmentFilter)
|
||||
&& (string.IsNullOrEmpty(normalizedType) || string.Equals(release.ReleaseType, normalizedType, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedStatus) || string.Equals(release.Status, normalizedStatus, StringComparison.Ordinal)))
|
||||
.OrderByDescending(release => release.UpdatedAt)
|
||||
@@ -132,8 +132,8 @@ public sealed class ReleaseReadModelService
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var normalizedRegion = NormalizeFilter(region);
|
||||
var normalizedEnvironment = NormalizeFilter(environment);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var bundles = await store.ListBundlesAsync(
|
||||
context.TenantId,
|
||||
@@ -184,8 +184,8 @@ public sealed class ReleaseReadModelService
|
||||
|
||||
var filtered = activity
|
||||
.Where(item =>
|
||||
(string.IsNullOrEmpty(normalizedRegion) || string.Equals(item.TargetRegion, normalizedRegion, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedEnvironment) || string.Equals(item.TargetEnvironment, normalizedEnvironment, StringComparison.Ordinal)))
|
||||
MatchesFilters(item.TargetRegion, regionFilter)
|
||||
&& MatchesFilters(item.TargetEnvironment, environmentFilter))
|
||||
.OrderByDescending(item => item.OccurredAt)
|
||||
.ThenBy(item => item.ActivityId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
@@ -210,8 +210,8 @@ public sealed class ReleaseReadModelService
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var normalizedStatus = NormalizeFilter(status);
|
||||
var normalizedRegion = NormalizeFilter(region);
|
||||
var normalizedEnvironment = NormalizeFilter(environment);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var bundles = await store.ListBundlesAsync(
|
||||
context.TenantId,
|
||||
@@ -241,8 +241,8 @@ public sealed class ReleaseReadModelService
|
||||
var filtered = approvals
|
||||
.Where(item =>
|
||||
string.Equals(item.Status, effectiveStatus, StringComparison.Ordinal)
|
||||
&& (string.IsNullOrEmpty(normalizedRegion) || string.Equals(item.TargetRegion, normalizedRegion, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedEnvironment) || string.Equals(item.TargetEnvironment, normalizedEnvironment, StringComparison.Ordinal)))
|
||||
&& MatchesFilters(item.TargetRegion, regionFilter)
|
||||
&& MatchesFilters(item.TargetEnvironment, environmentFilter))
|
||||
.OrderByDescending(item => item.RequestedAt)
|
||||
.ThenBy(item => item.ApprovalId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
@@ -270,8 +270,8 @@ public sealed class ReleaseReadModelService
|
||||
{
|
||||
var normalizedStatus = NormalizeFilter(status);
|
||||
var normalizedLane = NormalizeFilter(lane);
|
||||
var normalizedEnvironment = NormalizeFilter(environment);
|
||||
var normalizedRegion = NormalizeFilter(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var normalizedOutcome = NormalizeFilter(outcome);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
@@ -281,8 +281,8 @@ public sealed class ReleaseReadModelService
|
||||
.Where(run =>
|
||||
(string.IsNullOrEmpty(normalizedStatus) || string.Equals(run.Status, normalizedStatus, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedLane) || string.Equals(run.Lane, normalizedLane, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedEnvironment) || string.Equals(run.TargetEnvironment, normalizedEnvironment, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrEmpty(normalizedRegion) || string.Equals(run.TargetRegion, normalizedRegion, StringComparison.Ordinal))
|
||||
&& MatchesFilters(run.TargetEnvironment, environmentFilter)
|
||||
&& MatchesFilters(run.TargetRegion, regionFilter)
|
||||
&& (string.IsNullOrEmpty(normalizedOutcome) || string.Equals(run.Outcome, normalizedOutcome, StringComparison.Ordinal))
|
||||
&& (needsApproval is null || run.NeedsApproval == needsApproval.Value)
|
||||
&& (blockedByDataIntegrity is null || run.BlockedByDataIntegrity == blockedByDataIntegrity.Value))
|
||||
@@ -298,6 +298,53 @@ public sealed class ReleaseReadModelService
|
||||
return new ReleasePageResult<ReleaseRunProjection>(paged, projections.Length, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
public async Task<ReleasePageResult<EvidencePackProjection>> ListEvidencePacksAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
var projections = (await LoadRunSeedsAsync(context, cancellationToken).ConfigureAwait(false))
|
||||
.Select(seed =>
|
||||
{
|
||||
var run = BuildRunProjection(seed);
|
||||
if (!MatchesFilters(run.TargetRegion, regionFilter) || !MatchesFilters(run.TargetEnvironment, environmentFilter))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var evidence = BuildRunEvidence(seed);
|
||||
return new EvidencePackProjection(
|
||||
CapsuleId: evidence.DecisionCapsuleId,
|
||||
RunId: run.RunId,
|
||||
ReleaseId: run.ReleaseId,
|
||||
Environment: run.TargetEnvironment,
|
||||
Region: run.TargetRegion,
|
||||
Status: ResolveEvidencePackStatus(run, evidence),
|
||||
UpdatedAt: run.UpdatedAt,
|
||||
CapsuleRoute: evidence.CapsuleRoute);
|
||||
})
|
||||
.Where(static item => item is not null)
|
||||
.Select(static item => item!)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ThenBy(item => item.CapsuleId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var paged = projections
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return new ReleasePageResult<EvidencePackProjection>(paged, projections.Length, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
public async Task<ReleaseRunDetailProjection?> GetRunDetailAsync(
|
||||
PlatformRequestContext context,
|
||||
Guid runId,
|
||||
@@ -1469,6 +1516,59 @@ public sealed class ReleaseReadModelService
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseFilterSet(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static item => NormalizeFilter(item))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(string? value, HashSet<string> filter)
|
||||
{
|
||||
if (filter.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = NormalizeFilter(value);
|
||||
return normalized is not null && filter.Contains(normalized);
|
||||
}
|
||||
|
||||
private static string ResolveEvidencePackStatus(
|
||||
ReleaseRunProjection run,
|
||||
ReleaseRunEvidenceProjection evidence)
|
||||
{
|
||||
if (evidence.ReplayMismatch)
|
||||
{
|
||||
return "stale";
|
||||
}
|
||||
|
||||
if (run.Status is "queued" or "pending")
|
||||
{
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (string.Equals(evidence.SignatureStatus, "unsigned", StringComparison.Ordinal))
|
||||
{
|
||||
return "unsigned";
|
||||
}
|
||||
|
||||
if (string.Equals(evidence.ChainCompleteness, "partial", StringComparison.Ordinal))
|
||||
{
|
||||
return "stale";
|
||||
}
|
||||
|
||||
return "current";
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class EvidenceReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public EvidenceReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidencePacksEndpoint_ReturnsDeterministicFilteredProjections()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var search = await SeedReleaseAsync(client, "search-evidence", "Search Evidence", "us-prod", "critical-fix");
|
||||
var catalog = await SeedReleaseAsync(client, "catalog-evidence", "Catalog Evidence", "eu-prod", "policy-review");
|
||||
|
||||
var first = await client.GetFromJsonAsync<PlatformListResponse<EvidencePackProjection>>(
|
||||
"/api/v2/evidence/packs?region=us-east,eu-west&environment=us-prod,eu-prod&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var second = await client.GetFromJsonAsync<PlatformListResponse<EvidencePackProjection>>(
|
||||
"/api/v2/evidence/packs?region=us-east,eu-west&environment=us-prod,eu-prod&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotEmpty(first!.Items);
|
||||
Assert.Equal(
|
||||
first.Items.Select(item => item.CapsuleId).ToArray(),
|
||||
second!.Items.Select(item => item.CapsuleId).ToArray());
|
||||
|
||||
Assert.Contains(first.Items, item => item.ReleaseId == search.Bundle.Id.ToString("D"));
|
||||
Assert.Contains(first.Items, item => item.ReleaseId == catalog.Bundle.Id.ToString("D"));
|
||||
Assert.All(first.Items, item =>
|
||||
{
|
||||
Assert.Contains(item.Region, new[] { "us-east", "eu-west" });
|
||||
Assert.Contains(item.Environment, new[] { "us-prod", "eu-prod" });
|
||||
Assert.False(string.IsNullOrWhiteSpace(item.CapsuleRoute));
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidencePacksEndpoint_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/evidence/packs", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidencePacksEndpoint_RequiresReleaseControlReadPolicy()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, "/api/v2/evidence/packs", StringComparison.Ordinal)
|
||||
&& candidate.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET", StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(PlatformPolicies.ReleaseControlRead, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: slug,
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "evidence-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -133,6 +133,27 @@ public sealed class ReleaseReadModelEndpointsTests : IClassFixture<PlatformWebAp
|
||||
Assert.All(euApprovals.Items, item => Assert.Equal("eu-west", item.TargetRegion));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_AcceptCommaDelimitedRegionAndEnvironmentFilters()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "search-hotfix", "Search Hotfix", "us-prod", "critical-fix");
|
||||
await SeedReleaseAsync(client, "catalog-release", "Catalog Release", "eu-prod", "policy-review");
|
||||
|
||||
var activity = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?region=us-east,eu-west&environment=us-prod,eu-prod&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(activity);
|
||||
Assert.NotEmpty(activity!.Items);
|
||||
Assert.Contains(activity.Items, item => item.TargetRegion == "us-east");
|
||||
Assert.Contains(activity.Items, item => item.TargetRegion == "eu-west");
|
||||
Assert.All(activity.Items, item => Assert.Contains(item.TargetEnvironment, new[] { "us-prod", "eu-prod" }));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
|
||||
@@ -25,13 +25,15 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
|
||||
params = params.set('regions', regions.join(','));
|
||||
params = params.set('region', regions[0]);
|
||||
const regionFilter = regions.join(',');
|
||||
params = params.set('regions', regionFilter);
|
||||
params = params.set('region', regionFilter);
|
||||
}
|
||||
|
||||
if (environments.length > 0 && !params.has('environments') && !params.has('environment')) {
|
||||
params = params.set('environments', environments.join(','));
|
||||
params = params.set('environment', environments[0]);
|
||||
const environmentFilter = environments.join(',');
|
||||
params = params.set('environments', environmentFilter);
|
||||
params = params.set('environment', environmentFilter);
|
||||
}
|
||||
|
||||
if (timeWindow && !params.has('timeWindow')) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { EnvironmentPosturePageComponent } from '../../features/topology/environment-posture-page.component';
|
||||
|
||||
describe('EnvironmentPosturePageComponent', () => {
|
||||
const paramMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
paramMap$.next(convertToParamMap({}));
|
||||
queryParamMap$.next(convertToParamMap({}));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EnvironmentPosturePageComponent],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMap$.asObservable(),
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
initialize: () => undefined,
|
||||
regionSummary: () => '4 regions',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('falls back to synced query environment context when the route has no environmentId param', () => {
|
||||
queryParamMap$.next(convertToParamMap({ environments: 'dev' }));
|
||||
|
||||
const fixture = TestBed.createComponent(EnvironmentPosturePageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
const inventoryReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/topology/environments' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const runsReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/releases/activity' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const findingsReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/security/findings' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
const capsulesReq = httpMock.expectOne((req) =>
|
||||
req.url === '/api/v2/evidence/packs' && req.params.get('environment') === 'dev',
|
||||
);
|
||||
|
||||
inventoryReq.flush({
|
||||
items: [
|
||||
{
|
||||
environmentId: 'dev',
|
||||
displayName: 'Development',
|
||||
regionId: 'us-east',
|
||||
status: 'healthy',
|
||||
},
|
||||
{
|
||||
environmentId: 'dev',
|
||||
displayName: 'Development',
|
||||
regionId: 'eu-west',
|
||||
status: 'healthy',
|
||||
},
|
||||
],
|
||||
});
|
||||
runsReq.flush({ items: [] });
|
||||
findingsReq.flush({ items: [] });
|
||||
capsulesReq.flush({ items: [] });
|
||||
|
||||
expect(component.environmentId()).toBe('dev');
|
||||
expect(component.environmentLabel()).toBe('Development');
|
||||
expect(component.regionLabel()).toBe('4 regions');
|
||||
expect(component.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an explicit guidance message when no environment context is available', () => {
|
||||
const fixture = TestBed.createComponent(EnvironmentPosturePageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.environmentId()).toBe('');
|
||||
expect(component.error()).toBe('Select an environment from Mission Control or Topology to view release health.');
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.runRows()).toEqual([]);
|
||||
expect(component.findingRows()).toEqual([]);
|
||||
expect(component.capsuleRows()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HTTP_INTERCEPTORS, HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { GlobalContextHttpInterceptor } from '../context/global-context-http.interceptor';
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
|
||||
describe('GlobalContextHttpInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: GlobalContextHttpInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: PlatformContextStore,
|
||||
useValue: {
|
||||
tenantId: () => 'demo-prod',
|
||||
selectedRegions: () => ['apac', 'eu-west', 'us-east', 'us-west'],
|
||||
selectedEnvironments: () => ['dev', 'stage'],
|
||||
timeWindow: () => '24h',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('propagates comma-delimited region and environment scope instead of collapsing to the first selection', () => {
|
||||
http.get('/api/v2/releases/activity').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/releases/activity?tenant=demo-prod&tenantId=demo-prod®ions=apac,eu-west,us-east,us-west®ion=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h');
|
||||
request.flush({ items: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { combineLatest, forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, take } from 'rxjs/operators';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { summarizeEnvironmentScope } from './environment-scope-summary';
|
||||
|
||||
interface PlatformListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
@@ -12,6 +15,7 @@ interface EnvironmentInventoryRow {
|
||||
environmentId: string;
|
||||
displayName: string;
|
||||
regionId: string;
|
||||
environmentType?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -98,8 +102,12 @@ interface EvidenceCapsuleRow {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EnvironmentPosturePageComponent {
|
||||
private static readonly MissingEnvironmentMessage =
|
||||
'Select an environment from Mission Control or Topology to view release health.';
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -147,12 +155,30 @@ export class EnvironmentPosturePageComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const id = params.get('environmentId') ?? '';
|
||||
this.environmentId.set(id);
|
||||
if (id) {
|
||||
this.reload(id);
|
||||
this.context.initialize();
|
||||
|
||||
combineLatest([this.route.paramMap, this.route.queryParamMap]).subscribe(([params, queryParams]) => {
|
||||
const id = this.resolveEnvironmentId(
|
||||
params.get('environmentId'),
|
||||
queryParams.get('environment'),
|
||||
queryParams.get('environments'),
|
||||
queryParams.get('env'),
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
this.environmentId.set('');
|
||||
this.environmentLabel.set('Environment');
|
||||
this.regionLabel.set('region');
|
||||
this.runRows.set([]);
|
||||
this.findingRows.set([]);
|
||||
this.capsuleRows.set([]);
|
||||
this.loading.set(false);
|
||||
this.error.set(EnvironmentPosturePageComponent.MissingEnvironmentMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.environmentId.set(id);
|
||||
this.reload(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,12 +186,12 @@ export class EnvironmentPosturePageComponent {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const envParams = new HttpParams().set('limit', '1').set('offset', '0').set('environment', environmentId);
|
||||
const envParams = new HttpParams().set('limit', '200').set('offset', '0').set('environment', environmentId);
|
||||
const inventory$ = this.http
|
||||
.get<PlatformListResponse<EnvironmentInventoryRow>>('/api/v2/topology/environments', { params: envParams })
|
||||
.pipe(
|
||||
map((response) => response.items?.[0] ?? null),
|
||||
catchError(() => of(null)),
|
||||
map((response) => response.items ?? []),
|
||||
catchError(() => of([] as EnvironmentInventoryRow[])),
|
||||
);
|
||||
|
||||
const runs$ = this.http
|
||||
@@ -193,8 +219,14 @@ export class EnvironmentPosturePageComponent {
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: ({ inventory, runs, findings, capsules }) => {
|
||||
this.environmentLabel.set(inventory?.displayName ?? environmentId);
|
||||
this.regionLabel.set(inventory?.regionId ?? 'unknown-region');
|
||||
const scopeSummary = summarizeEnvironmentScope(
|
||||
inventory,
|
||||
environmentId,
|
||||
this.resolveScopeRegionLabel(),
|
||||
);
|
||||
|
||||
this.environmentLabel.set(scopeSummary.environmentLabel);
|
||||
this.regionLabel.set(scopeSummary.regionLabel);
|
||||
this.runRows.set(runs);
|
||||
this.findingRows.set(findings);
|
||||
this.capsuleRows.set(capsules);
|
||||
@@ -209,6 +241,40 @@ export class EnvironmentPosturePageComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveEnvironmentId(
|
||||
routeEnvironmentId: string | null,
|
||||
queryEnvironmentId: string | null,
|
||||
queryEnvironments: string | null,
|
||||
legacyEnvironmentId: string | null,
|
||||
): string {
|
||||
const candidate =
|
||||
routeEnvironmentId
|
||||
?? queryEnvironmentId
|
||||
?? this.parseFirstEnvironment(queryEnvironments)
|
||||
?? legacyEnvironmentId
|
||||
?? '';
|
||||
|
||||
return candidate.trim();
|
||||
}
|
||||
|
||||
private parseFirstEnvironment(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
|
||||
return first ?? null;
|
||||
}
|
||||
|
||||
private resolveScopeRegionLabel(): string {
|
||||
const summary = this.context.regionSummary();
|
||||
return summary.trim().length > 0 ? summary : 'current scope';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface EnvironmentScopeSummarySource {
|
||||
displayName?: string | null;
|
||||
regionId?: string | null;
|
||||
environmentType?: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentScopeSummary {
|
||||
environmentLabel: string;
|
||||
regionLabel: string;
|
||||
environmentTypeLabel: string;
|
||||
hasMatch: boolean;
|
||||
}
|
||||
|
||||
export function summarizeEnvironmentScope(
|
||||
rows: readonly EnvironmentScopeSummarySource[],
|
||||
fallbackEnvironmentLabel: string,
|
||||
fallbackRegionLabel: string,
|
||||
fallbackEnvironmentTypeLabel = 'unknown-type',
|
||||
): EnvironmentScopeSummary {
|
||||
const displayNames = uniqueNonEmpty(rows.map((row) => row.displayName));
|
||||
const regions = uniqueNonEmpty(rows.map((row) => row.regionId));
|
||||
const environmentTypes = uniqueNonEmpty(rows.map((row) => row.environmentType));
|
||||
|
||||
return {
|
||||
environmentLabel: displayNames.length === 1 ? displayNames[0] : fallbackEnvironmentLabel,
|
||||
regionLabel: regions.length === 1 ? regions[0] : fallbackRegionLabel,
|
||||
environmentTypeLabel: environmentTypes.length === 1 ? environmentTypes[0] : fallbackEnvironmentTypeLabel,
|
||||
hasMatch: rows.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: ReadonlyArray<string | null | undefined>): string[] {
|
||||
return [...new Set(values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value && value.length > 0)))];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { catchError, forkJoin, of, take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TopologyDataService } from './topology-data.service';
|
||||
import { summarizeEnvironmentScope } from './environment-scope-summary';
|
||||
import {
|
||||
EvidenceCapsuleRow,
|
||||
PlatformListResponse,
|
||||
@@ -539,10 +540,15 @@ export class TopologyEnvironmentDetailPageComponent {
|
||||
),
|
||||
}).subscribe({
|
||||
next: ({ environmentRows, targets, agents, runs, findings, capsules }) => {
|
||||
const environment = environmentRows[0];
|
||||
this.environmentLabel.set(environment?.displayName ?? environmentId);
|
||||
this.regionLabel.set(environment?.regionId ?? 'unknown-region');
|
||||
this.environmentTypeLabel.set(environment?.environmentType ?? 'unknown-type');
|
||||
const scopeSummary = summarizeEnvironmentScope(
|
||||
environmentRows,
|
||||
environmentId,
|
||||
this.context.regionSummary(),
|
||||
);
|
||||
|
||||
this.environmentLabel.set(scopeSummary.environmentLabel);
|
||||
this.regionLabel.set(scopeSummary.regionLabel);
|
||||
this.environmentTypeLabel.set(scopeSummary.environmentTypeLabel);
|
||||
|
||||
this.targetRows.set(targets);
|
||||
this.agentRows.set(agents);
|
||||
|
||||
Reference in New Issue
Block a user