From b70457712b643d9a5f60fbda6fb5afe920c87e6a Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 05:13:36 +0200 Subject: [PATCH] Fix release health multi-scope evidence contracts --- ...l_release_health_environment_resolution.md | 73 +++++++++ ...ase_health_multi_scope_evidence_adapter.md | 102 ++++++++++++ .../modules/platform/architecture-overview.md | 2 +- .../Contracts/EvidenceReadModels.cs | 13 ++ .../Endpoints/EvidenceReadModelEndpoints.cs | 83 ++++++++++ .../StellaOps.Platform.WebService/Program.cs | 1 + .../Services/ReleaseReadModelService.cs | 132 +++++++++++++-- .../EvidenceReadModelEndpointsTests.cs | 150 ++++++++++++++++++ .../ReleaseReadModelEndpointsTests.cs | 21 +++ .../global-context-http.interceptor.ts | 10 +- ...environment-posture-page.component.spec.ts | 105 ++++++++++++ .../global-context-http.interceptor.spec.ts | 48 ++++++ .../environment-posture-page.component.ts | 88 ++++++++-- .../topology/environment-scope-summary.ts | 36 +++++ ...ology-environment-detail-page.component.ts | 14 +- 15 files changed, 842 insertions(+), 36 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_012_FE_mission_control_release_health_environment_resolution.md create mode 100644 docs/implplan/SPRINT_20260307_013_Platform_release_health_multi_scope_evidence_adapter.md create mode 100644 src/Platform/StellaOps.Platform.WebService/Contracts/EvidenceReadModels.cs create mode 100644 src/Platform/StellaOps.Platform.WebService/Endpoints/EvidenceReadModelEndpoints.cs create mode 100644 src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EvidenceReadModelEndpointsTests.cs create mode 100644 src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/testing/global-context-http.interceptor.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/topology/environment-scope-summary.ts diff --git a/docs/implplan/SPRINT_20260307_012_FE_mission_control_release_health_environment_resolution.md b/docs/implplan/SPRINT_20260307_012_FE_mission_control_release_health_environment_resolution.md new file mode 100644 index 000000000..a6dd26d2b --- /dev/null +++ b/docs/implplan/SPRINT_20260307_012_FE_mission_control_release_health_environment_resolution.md @@ -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. diff --git a/docs/implplan/SPRINT_20260307_013_Platform_release_health_multi_scope_evidence_adapter.md b/docs/implplan/SPRINT_20260307_013_Platform_release_health_multi_scope_evidence_adapter.md new file mode 100644 index 000000000..5419713a0 --- /dev/null +++ b/docs/implplan/SPRINT_20260307_013_Platform_release_health_multi_scope_evidence_adapter.md @@ -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=` 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. diff --git a/docs/modules/platform/architecture-overview.md b/docs/modules/platform/architecture-overview.md index aff1583cf..b6d4a2301 100644 --- a/docs/modules/platform/architecture-overview.md +++ b/docs/modules/platform/architecture-overview.md @@ -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: diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/EvidenceReadModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/EvidenceReadModels.cs new file mode 100644 index 000000000..e6c9c03f3 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/EvidenceReadModels.cs @@ -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); diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/EvidenceReadModelEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/EvidenceReadModelEndpoints.cs new file mode 100644 index 000000000..313d8f4b1 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/EvidenceReadModelEndpoints.cs @@ -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( + 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( + 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); +} diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index e25d57eef..30ec17849 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -321,6 +321,7 @@ app.MapReleaseControlEndpoints(); app.MapReleaseReadModelEndpoints(); app.MapTopologyReadModelEndpoints(); app.MapSecurityReadModelEndpoints(); +app.MapEvidenceReadModelEndpoints(); app.MapIntegrationReadModelEndpoints(); app.MapLegacyAliasEndpoints(); app.MapPackAdapterEndpoints(); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/ReleaseReadModelService.cs b/src/Platform/StellaOps.Platform.WebService/Services/ReleaseReadModelService.cs index ed03432e9..76785a387 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/ReleaseReadModelService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/ReleaseReadModelService.cs @@ -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()))) .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(paged, projections.Length, normalizedLimit, normalizedOffset); } + public async Task> 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(paged, projections.Length, normalizedLimit, normalizedOffset); + } + public async Task GetRunDetailAsync( PlatformRequestContext context, Guid runId, @@ -1469,6 +1516,59 @@ public sealed class ReleaseReadModelService return value.Trim().ToLowerInvariant(); } + private static HashSet ParseFilterSet(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new HashSet(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 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 diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EvidenceReadModelEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EvidenceReadModelEndpointsTests.cs new file mode 100644 index 000000000..bfdd20f12 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EvidenceReadModelEndpointsTests.cs @@ -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 +{ + 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>( + "/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>( + "/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() + .Endpoints + .OfType() + .ToArray(); + + var endpoint = endpoints.Single(candidate => + string.Equals(candidate.RoutePattern.RawText, "/api/v2/evidence/packs", StringComparison.Ordinal) + && candidate.Metadata.GetMetadata()?.HttpMethods.Contains("GET", StringComparer.OrdinalIgnoreCase) == true); + + var policies = endpoint.Metadata + .GetOrderedMetadata() + .Select(metadata => metadata.Policy) + .Where(static policy => !string.IsNullOrWhiteSpace(policy)) + .ToArray(); + + Assert.Contains(PlatformPolicies.ReleaseControlRead, policies); + } + + private static async Task 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( + 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( + 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( + 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); +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseReadModelEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseReadModelEndpointsTests.cs index a153ea4b0..15d6de24f 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseReadModelEndpointsTests.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/ReleaseReadModelEndpointsTests.cs @@ -133,6 +133,27 @@ public sealed class ReleaseReadModelEndpointsTests : IClassFixture 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>( + "/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() diff --git a/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts b/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts index 97a533454..28ef41404 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts @@ -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')) { diff --git a/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts new file mode 100644 index 000000000..baad9c966 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/environment-posture-page.component.spec.ts @@ -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([]); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/testing/global-context-http.interceptor.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/global-context-http.interceptor.spec.ts new file mode 100644 index 000000000..1cec82400 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/global-context-http.interceptor.spec.ts @@ -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: [] }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts index 70c55d950..d5cf79a8f 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts @@ -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 { 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(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>('/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'; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environment-scope-summary.ts b/src/Web/StellaOps.Web/src/app/features/topology/environment-scope-summary.ts new file mode 100644 index 000000000..fb881c5fa --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/environment-scope-summary.ts @@ -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[] { + return [...new Set(values + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value && value.length > 0)))]; +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts index be54f5895..0249b57d3 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts @@ -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);