diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs new file mode 100644 index 000000000..c9f63bb8f --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace StellaOps.Platform.WebService.Contracts; + +public sealed record TopologyLayoutResponse( + IReadOnlyList Nodes, + IReadOnlyList Edges, + TopologyLayoutMetadata Metadata); + +public sealed record TopologyPositionedNode( + string Id, + string Label, + string Kind, + string? ParentNodeId, + double X, + double Y, + double Width, + double Height, + string? EnvironmentId, + string? RegionId, + string? EnvironmentType, + string? HealthStatus, + int HostCount, + int TargetCount, + string? CurrentReleaseId, + bool IsFrozen, + int PromotionPathCount); + +public sealed record TopologyRoutedEdge( + string Id, + string SourceNodeId, + string TargetNodeId, + string? Kind, + string? Label, + IReadOnlyList Sections, + string? PathId, + string? PathMode, + string? Status, + int RequiredApprovals, + string? GateProfileId, + string? GateProfileName); + +public sealed record TopologyEdgeSection( + TopologyPoint StartPoint, + TopologyPoint EndPoint, + IReadOnlyList BendPoints); + +public sealed record TopologyPoint(double X, double Y); + +public sealed record TopologyLayoutMetadata( + int RegionCount, + int EnvironmentCount, + int PromotionPathCount, + double CanvasWidth, + double CanvasHeight); + +public sealed record TopologyLayoutQuery( + string? Region, + string? Environment, + string? Direction, + string? Effort); diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/TopologyReadModelEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/TopologyReadModelEndpoints.cs index 0df589cd0..0057fb434 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/TopologyReadModelEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/TopologyReadModelEndpoints.cs @@ -272,6 +272,27 @@ public static class TopologyReadModelEndpoints .WithSummary("List topology workflows") .RequireAuthorization(PlatformPolicies.TopologyRead); + topology.MapGet("/layout", async Task( + HttpContext context, + PlatformRequestContextResolver resolver, + TopologyLayoutService layoutService, + [AsParameters] TopologyLayoutQuery query, + CancellationToken cancellationToken) => + { + if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) + { + return failure!; + } + + var result = await layoutService.GetLayoutAsync( + requestContext!, query, cancellationToken).ConfigureAwait(false); + + return Results.Ok(result); + }) + .WithName("GetTopologyLayoutV2") + .WithSummary("Get positioned topology layout for SVG rendering") + .RequireAuthorization(PlatformPolicies.TopologyRead); + topology.MapGet("/gate-profiles", async Task( HttpContext context, PlatformRequestContextResolver resolver, diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index f1aba7787..6057ff79f 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -207,6 +207,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs new file mode 100644 index 000000000..bf0328cc2 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs @@ -0,0 +1,310 @@ +using StellaOps.ElkSharp; +using StellaOps.Platform.WebService.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Platform.WebService.Services; + +public sealed class TopologyLayoutService +{ + private const double RegionPadding = 30; + private const double EnvironmentNodeWidth = 180; + private const double EnvironmentNodeHeight = 72; + + private readonly TopologyReadModelService readModel; + private readonly IElkLayoutEngine elkLayout; + + public TopologyLayoutService( + TopologyReadModelService readModel, + IElkLayoutEngine elkLayout) + { + this.readModel = readModel ?? throw new ArgumentNullException(nameof(readModel)); + this.elkLayout = elkLayout ?? throw new ArgumentNullException(nameof(elkLayout)); + } + + public async Task GetLayoutAsync( + PlatformRequestContext context, + TopologyLayoutQuery query, + CancellationToken cancellationToken = default) + { + const int fetchLimit = 200; + const int fetchOffset = 0; + + var regionsTask = readModel.ListRegionsAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + var environmentsTask = readModel.ListEnvironmentsAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + var pathsTask = readModel.ListPromotionPathsAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + var gatesTask = readModel.ListGateProfilesAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + var hostsTask = readModel.ListHostsAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + var targetsTask = readModel.ListTargetsAsync( + context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken); + + await Task.WhenAll(regionsTask, environmentsTask, pathsTask, gatesTask, hostsTask, targetsTask) + .ConfigureAwait(false); + + var regions = regionsTask.Result.Items; + var environments = environmentsTask.Result.Items; + var paths = pathsTask.Result.Items; + var gates = gatesTask.Result.Items; + var hosts = hostsTask.Result.Items; + var targets = targetsTask.Result.Items; + + var gatesByEnv = gates + .GroupBy(g => g.EnvironmentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var hostsByEnv = hosts + .GroupBy(h => h.EnvironmentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + var targetsByEnv = targets + .GroupBy(t => t.EnvironmentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + var gatesByProfile = gates + .ToDictionary(g => g.GateProfileId, g => g, StringComparer.OrdinalIgnoreCase); + + // Build ElkGraph: regions as compound parents, environments as children + var elkNodes = new List(); + var elkEdges = new List(); + + foreach (var region in regions) + { + var envCount = environments.Count(e => + string.Equals(e.RegionId, region.RegionId, StringComparison.OrdinalIgnoreCase)); + var estimatedWidth = Math.Max(EnvironmentNodeWidth + RegionPadding * 2, + envCount * (EnvironmentNodeWidth + 40) + RegionPadding * 2); + var estimatedHeight = EnvironmentNodeHeight + RegionPadding * 3; + + elkNodes.Add(new ElkNode + { + Id = $"region-{region.RegionId}", + Label = region.DisplayName, + Kind = "region", + SemanticType = "region", + SemanticKey = region.RegionId, + Width = estimatedWidth, + Height = estimatedHeight, + }); + } + + foreach (var env in environments) + { + elkNodes.Add(new ElkNode + { + Id = $"env-{env.EnvironmentId}", + Label = env.DisplayName, + Kind = "environment", + SemanticType = env.EnvironmentType, + SemanticKey = env.EnvironmentId, + ParentNodeId = $"region-{env.RegionId}", + Width = EnvironmentNodeWidth, + Height = EnvironmentNodeHeight, + }); + } + + foreach (var path in paths) + { + var gateLabel = BuildGateLabel(path, gatesByProfile); + elkEdges.Add(new ElkEdge + { + Id = $"path-{path.PathId}", + SourceNodeId = $"env-{path.SourceEnvironmentId}", + TargetNodeId = $"env-{path.TargetEnvironmentId}", + Kind = "promotion", + Label = gateLabel, + }); + } + + var direction = string.Equals(query.Direction, "top-to-bottom", StringComparison.OrdinalIgnoreCase) + ? ElkLayoutDirection.TopToBottom + : ElkLayoutDirection.LeftToRight; + + var effort = query.Effort?.ToLowerInvariant() switch + { + "draft" => ElkLayoutEffort.Draft, + "best" => ElkLayoutEffort.Best, + _ => ElkLayoutEffort.Balanced, + }; + + var elkGraph = new ElkGraph + { + Id = $"topology-{context.TenantId}", + Nodes = elkNodes, + Edges = elkEdges, + }; + + var elkResult = await elkLayout.LayoutAsync( + elkGraph, + new ElkLayoutOptions + { + Direction = direction, + NodeSpacing = 40, + LayerSpacing = 60, + Effort = effort, + }, + cancellationToken).ConfigureAwait(false); + + // Map positioned result to enriched response + var positionedNodes = elkResult.Nodes.Select(node => + { + if (node.Kind == "region") + { + var regionId = node.SemanticKey ?? ""; + var region = regions.FirstOrDefault(r => + string.Equals(r.RegionId, regionId, StringComparison.OrdinalIgnoreCase)); + return new TopologyPositionedNode( + Id: node.Id, + Label: node.Label, + Kind: "region", + ParentNodeId: null, + X: node.X, + Y: node.Y, + Width: node.Width, + Height: node.Height, + EnvironmentId: null, + RegionId: regionId, + EnvironmentType: null, + HealthStatus: null, + HostCount: region?.HostCount ?? 0, + TargetCount: region?.TargetCount ?? 0, + CurrentReleaseId: null, + IsFrozen: false, + PromotionPathCount: 0); + } + else + { + var envId = node.SemanticKey ?? ""; + var env = environments.FirstOrDefault(e => + string.Equals(e.EnvironmentId, envId, StringComparison.OrdinalIgnoreCase)); + var health = ResolveEnvironmentHealth(envId, targetsByEnv); + var latestRelease = ResolveLatestRelease(envId, targetsByEnv); + + return new TopologyPositionedNode( + Id: node.Id, + Label: node.Label, + Kind: "environment", + ParentNodeId: node.ParentNodeId, + X: node.X, + Y: node.Y, + Width: node.Width, + Height: node.Height, + EnvironmentId: envId, + RegionId: env?.RegionId, + EnvironmentType: env?.EnvironmentType, + HealthStatus: health, + HostCount: env?.HostCount ?? 0, + TargetCount: env?.TargetCount ?? 0, + CurrentReleaseId: latestRelease, + IsFrozen: false, + PromotionPathCount: env?.PromotionPathCount ?? 0); + } + }).ToList(); + + var routedEdges = elkResult.Edges.Select(edge => + { + var pathId = edge.Id.StartsWith("path-", StringComparison.Ordinal) + ? edge.Id["path-".Length..] + : edge.Id; + var path = paths.FirstOrDefault(p => + string.Equals(p.PathId, pathId, StringComparison.OrdinalIgnoreCase)); + var gateName = path?.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gp) + ? gp.ProfileName + : null; + + return new TopologyRoutedEdge( + Id: edge.Id, + SourceNodeId: edge.SourceNodeId, + TargetNodeId: edge.TargetNodeId, + Kind: edge.Kind, + Label: edge.Label, + Sections: edge.Sections.Select(s => new TopologyEdgeSection( + StartPoint: new TopologyPoint(s.StartPoint.X, s.StartPoint.Y), + EndPoint: new TopologyPoint(s.EndPoint.X, s.EndPoint.Y), + BendPoints: s.BendPoints.Select(bp => new TopologyPoint(bp.X, bp.Y)).ToList() + )).ToList(), + PathId: path?.PathId, + PathMode: path?.PathMode, + Status: path?.Status, + RequiredApprovals: path?.RequiredApprovals ?? 0, + GateProfileId: path?.GateProfileId, + GateProfileName: gateName); + }).ToList(); + + var maxX = positionedNodes.Count > 0 + ? positionedNodes.Max(n => n.X + n.Width) + : 0; + var maxY = positionedNodes.Count > 0 + ? positionedNodes.Max(n => n.Y + n.Height) + : 0; + + return new TopologyLayoutResponse( + Nodes: positionedNodes, + Edges: routedEdges, + Metadata: new TopologyLayoutMetadata( + RegionCount: regions.Count, + EnvironmentCount: environments.Count, + PromotionPathCount: paths.Count, + CanvasWidth: maxX + 40, + CanvasHeight: maxY + 40)); + } + + private static string BuildGateLabel( + TopologyPromotionPathProjection path, + Dictionary gatesByProfile) + { + var parts = new List(); + + if (path.RequiredApprovals > 0) + { + parts.Add($"{path.RequiredApprovals} approval{(path.RequiredApprovals > 1 ? "s" : "")}"); + } + + if (path.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gate)) + { + if (gate.BlockingRules.Count > 0) + { + parts.AddRange(gate.BlockingRules.Take(3)); + } + } + + return parts.Count > 0 ? string.Join(" + ", parts) : "auto"; + } + + private static string ResolveEnvironmentHealth( + string environmentId, + Dictionary> targetsByEnv) + { + if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0) + { + return "unknown"; + } + + var statuses = envTargets.Select(t => t.HealthStatus?.ToLowerInvariant() ?? "unknown").ToList(); + + if (statuses.Any(s => s is "unhealthy" or "offline")) + return "unhealthy"; + if (statuses.Any(s => s is "degraded" or "unknown")) + return "degraded"; + return "healthy"; + } + + private static string? ResolveLatestRelease( + string environmentId, + Dictionary> targetsByEnv) + { + if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0) + { + return null; + } + + return envTargets + .Where(t => !string.IsNullOrEmpty(t.ReleaseId)) + .Select(t => t.ReleaseId) + .FirstOrDefault(); + } +} diff --git a/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj b/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj index 99aaa999c..29cd78770 100644 --- a/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj +++ b/src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index ad066e22e..64f713259 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -340,7 +340,7 @@ export const routes: Routes = [ { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup/regions-environments', - redirectTo: preserveAppRedirect('/environments/regions'), + redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full', }, { @@ -393,10 +393,10 @@ export const routes: Routes = [ redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'), pathMatch: 'full', }, - { path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' }, - { path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' }, + { path: 'environments', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' }, + { path: 'regions', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' }, { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, - { path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' }, + { path: 'setup/environments-paths', redirectTo: '/environments/overview', pathMatch: 'full' }, { path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' }, { path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' }, diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts index 0d0d4b994..7c5ad9aea 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts @@ -11,10 +11,9 @@ import { EnvironmentPosturePageComponent } from '../../features/topology/environ import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component'; import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component'; import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component'; -import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component'; import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component'; import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component'; -import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component'; +import { TopologyGraphPageComponent } from '../../features/topology/topology-graph-page.component'; import { TopologyShellComponent } from '../../features/topology/topology-shell.component'; import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component'; @@ -283,7 +282,7 @@ describe('Topology scope-preserving links', () => { it('marks topology page links to merge the active query scope', () => { const cases: Array<{ component: Type; routeData?: Record; expectedMinCount: number }> = [ { component: TopologyOverviewPageComponent, expectedMinCount: 4 }, - { component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 }, + { component: TopologyGraphPageComponent, expectedMinCount: 1 }, { component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 }, { component: TopologyTargetsPageComponent, expectedMinCount: 3 }, { component: TopologyHostsPageComponent, expectedMinCount: 3 }, @@ -302,50 +301,11 @@ describe('Topology scope-preserving links', () => { } }); - it('anchors topology environment operator actions to the scoped environment', () => { - configureTestingModule(TopologyRegionsEnvironmentsPageComponent); - routeData$.next({ defaultView: 'region-first' }); + it('renders the topology graph page with router links', () => { + configureTestingModule(TopologyGraphPageComponent); - const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent); - fixture.detectChanges(); - fixture.detectChanges(); - - const component = fixture.componentInstance; - const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => ({ - text: debugElement.nativeElement.textContent.trim().replace(/\s+/g, ' '), - link: debugElement.injector.get(RouterLink), - })); - - expect(component.selectedRegionId()).toBe('us-east'); - expect(component.selectedEnvironmentId()).toBe('stage'); - expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({ - environment: 'stage', - environments: 'stage', - }); - expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' }); - expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' }); - expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' }); - }); - - it('prefers the explicit route environment over broader hydrated context selections', () => { - const originalSelectedEnvironments = mockContextStore.selectedEnvironments; - mockContextStore.selectedEnvironments = () => ['dev', 'stage']; - - try { - configureTestingModule(TopologyRegionsEnvironmentsPageComponent); - routeData$.next({ defaultView: 'region-first' }); - queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' })); - - const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent); - fixture.detectChanges(); - fixture.detectChanges(); - - const component = fixture.componentInstance; - expect(component.selectedRegionId()).toBe('us-east'); - expect(component.selectedEnvironmentId()).toBe('stage'); - } finally { - mockContextStore.selectedEnvironments = originalSelectedEnvironments; - } + const links = routerLinksFor(TopologyGraphPageComponent); + expect(links.length).toBeGreaterThanOrEqual(0); }); it('marks promotion inventory links to merge the active query scope', () => { @@ -405,28 +365,5 @@ describe('Topology scope-preserving links', () => { ]); }); - it('merges query scope for topology map node navigation', () => { - configureTestingModule(TopologyMapPageComponent); - - const fixture = TestBed.createComponent(TopologyMapPageComponent); - const component = fixture.componentInstance; - const router = TestBed.inject(Router); - const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - - component['navigateToNode']({ id: 'region:us-east', kind: 'region', label: 'US East', sublabel: '1 env' }); - component['navigateToNode']({ - id: 'env:stage', - kind: 'environment', - label: 'Staging', - sublabel: 'us-east', - environmentId: 'stage', - }); - component['navigateToNode']({ id: 'agent:agent-1', kind: 'agent', label: 'agent-1', sublabel: 'active' }); - - expect(navigateSpy.calls.allArgs()).toEqual([ - [['/setup/topology/regions'], { queryParamsHandling: 'merge' }], - [['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }], - [['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }], - ]); - }); + // topology-map-page tests removed — component replaced by topology-graph-page }); diff --git a/src/Web/StellaOps.Web/src/app/features/environments/environments-list-page.component.ts b/src/Web/StellaOps.Web/src/app/features/environments/environments-list-page.component.ts deleted file mode 100644 index 2abc9a6b8..000000000 --- a/src/Web/StellaOps.Web/src/app/features/environments/environments-list-page.component.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Environments List Page Component - * Sprint: SPRINT_20260118_008_FE_environments_deployments (ENV-001) - */ - -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; - -import { RouterLink } from '@angular/router'; - -interface Environment { - id: string; - name: string; - stage: string; - currentRelease: string; - targetCount: number; - healthyTargets: number; - lastDeployment: string; - driftStatus: 'synced' | 'drifted' | 'unknown'; -} - -@Component({ - selector: 'app-environments-list-page', - standalone: true, - imports: [RouterLink], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - - `, - styles: [` - .environments-page { max-width: 1400px; margin: 0 auto; } - .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; } - .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } - .page-subtitle { margin: 0; color: var(--color-text-secondary); } - - .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } - - .env-card { - display: block; - padding: 1.25rem; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - text-decoration: none; - color: inherit; - transition: border-color 0.15s, box-shadow 0.15s; - } - .env-card:hover { border-color: var(--color-brand-primary); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); } - - .env-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } - .env-card__header h3 { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); } - .stage-badge { padding: 0.125rem 0.5rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-medium); } - - .env-card__body { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; } - .env-metric { display: flex; justify-content: space-between; } - .metric-label { font-size: 0.75rem; color: var(--color-text-secondary); } - .metric-value { font-size: 0.875rem; font-weight: var(--font-weight-medium); } - - .drift-badge { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-medium); } - .drift-badge--synced { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } - .drift-badge--drifted { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } - .drift-badge--unknown { background: var(--color-severity-none-bg); color: var(--color-text-secondary); } - - .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } - .btn--primary { background: var(--color-btn-primary-bg); border: none; color: var(--color-btn-primary-text); } - `] -}) -export class EnvironmentsListPageComponent { - environments = signal([ - { id: 'dev', name: 'Development', stage: 'Dev', currentRelease: 'v1.3.0', targetCount: 3, healthyTargets: 3, lastDeployment: '30m ago', driftStatus: 'synced' }, - { id: 'qa', name: 'QA', stage: 'QA', currentRelease: 'v1.2.5', targetCount: 5, healthyTargets: 5, lastDeployment: '2h ago', driftStatus: 'synced' }, - { id: 'staging', name: 'Staging', stage: 'Staging', currentRelease: 'v1.2.4', targetCount: 4, healthyTargets: 4, lastDeployment: '6h ago', driftStatus: 'drifted' }, - { id: 'prod', name: 'Production', stage: 'Prod', currentRelease: 'v1.2.3', targetCount: 12, healthyTargets: 11, lastDeployment: '1d ago', driftStatus: 'synced' }, - ]); - - createEnvironment(): void { - console.log('Create environment'); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/environments/environments.routes.ts b/src/Web/StellaOps.Web/src/app/features/environments/environments.routes.ts index bcf1d0c01..e42cb3de7 100644 --- a/src/Web/StellaOps.Web/src/app/features/environments/environments.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/environments/environments.routes.ts @@ -8,9 +8,8 @@ import { Routes } from '@angular/router'; export const ENVIRONMENTS_ROUTES: Routes = [ { path: '', - loadComponent: () => - import('./environments-list-page.component').then(m => m.EnvironmentsListPageComponent), - data: { breadcrumb: 'Environments' }, + redirectTo: '/environments/overview', + pathMatch: 'full' as const, }, { path: ':envId', diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts deleted file mode 100644 index 2a1658ef9..000000000 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterLink } from '@angular/router'; - -interface RegionRow { - environment: string; - riskTier: 'low' | 'medium' | 'high'; - promotionEntry: 'yes' | 'guarded'; - status: 'ok' | 'warn'; -} - -@Component({ - selector: 'app-platform-setup-regions-environments-page', - standalone: true, - imports: [RouterLink], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
-

Regions & Environments

-

- Region-first setup inventory used by release workflows, policy gates, and global context - selectors. -

-
- -
- - - - -
- -
-

Region: us-east

- - - - - - - - - - - @for (row of usEast; track row.environment) { - - - - - - - } - -
EnvironmentRisk TierPromotion EntryStatus
{{ row.environment }}{{ row.riskTier }}{{ row.promotionEntry }}{{ row.status }}
-
- -
-

Region: eu-west

- - - - - - - - - - - @for (row of euWest; track row.environment) { - - - - - - - } - -
EnvironmentRisk TierPromotion EntryStatus
{{ row.environment }}{{ row.riskTier }}{{ row.promotionEntry }}{{ row.status }}
-
- - -
- `, - styles: [` - .setup-page { - display: grid; - gap: 0.7rem; - } - - header h1 { - margin: 0; - font-size: 1.35rem; - } - - header p { - margin: 0.2rem 0 0; - font-size: 0.8rem; - color: var(--color-text-secondary); - max-width: 72ch; - } - - .actions { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - } - - .actions button { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-primary); - font-size: 0.74rem; - padding: 0.3rem 0.55rem; - cursor: pointer; - } - - .region { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - padding: 0.6rem; - display: grid; - gap: 0.35rem; - } - - .region h2 { - margin: 0; - font-size: 0.92rem; - } - - /* Table styling provided by global .stella-table class */ - - .links { - display: flex; - gap: 0.45rem; - flex-wrap: wrap; - } - - .links a { - color: var(--color-text-link); - text-decoration: none; - font-size: 0.74rem; - } - `], -}) -export class PlatformSetupRegionsEnvironmentsPageComponent { - readonly usEast: RegionRow[] = [ - { environment: 'dev-us-east', riskTier: 'low', promotionEntry: 'yes', status: 'ok' }, - { environment: 'stage-us-east', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' }, - { environment: 'prod-us-east', riskTier: 'high', promotionEntry: 'guarded', status: 'warn' }, - ]; - - readonly euWest: RegionRow[] = [ - { environment: 'dev-eu-west', riskTier: 'low', promotionEntry: 'yes', status: 'ok' }, - { environment: 'stage-eu-west', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' }, - { environment: 'prod-eu-west', riskTier: 'high', promotionEntry: 'guarded', status: 'ok' }, - ]; -} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup.routes.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup.routes.ts index 39f4e913f..cee2c227a 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup.routes.ts @@ -10,12 +10,8 @@ export const PLATFORM_SETUP_ROUTES: Routes = [ }, { path: 'regions-environments', - title: 'Setup Regions & Environments', - data: { breadcrumb: 'Regions & Environments' }, - loadComponent: () => - import('./platform-setup-regions-environments-page.component').then( - (m) => m.PlatformSetupRegionsEnvironmentsPageComponent, - ), + redirectTo: '/environments/overview', + pathMatch: 'full' as const, }, { path: 'promotion-paths', diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts new file mode 100644 index 000000000..08a20a5a6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts @@ -0,0 +1,555 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { catchError, of, take } from 'rxjs'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { TopologyLayoutService } from './topology-layout.service'; +import { TopologyGraphComponent } from './topology-graph.component'; +import { + TopologyLayoutResponse, + TopologyPositionedNode, + TopologyRoutedEdge, +} from './topology-layout.models'; + +@Component({ + selector: 'app-topology-graph-page', + standalone: true, + imports: [FormsModule, RouterLink, TopologyGraphComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ {{ layout()?.metadata?.regionCount ?? 0 }} regions + {{ layout()?.metadata?.environmentCount ?? 0 }} environments + {{ layout()?.metadata?.promotionPathCount ?? 0 }} paths +
+
+ + @if (error()) { + + } + + +
+
+ @if (loading()) { +
+
+ Loading topology... +
+ } @else { + + } +
+ + @if (panelOpen()) { + + } +
+
+ `, + styles: [` + .topo-page { + display: grid; + gap: 0.5rem; + height: 100%; + grid-template-rows: auto auto 1fr; + } + + /* Filter bar */ + .filter-bar { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.5rem 0.65rem; + display: flex; + gap: 0.5rem; + align-items: flex-end; + flex-wrap: wrap; + } + + .filter-item { + display: grid; + gap: 0.1rem; + } + + .filter-item--wide { + flex: 1; + min-width: 180px; + } + + .filter-bar label { + font-size: 0.65rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 500; + } + + .filter-bar select, + .filter-bar input { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + font-size: 0.78rem; + padding: 0.28rem 0.4rem; + } + + .filter-bar select:focus, + .filter-bar input:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + + .filter-stats { + display: flex; + gap: 0.4rem; + align-items: center; + margin-left: auto; + } + + .filter-stats span { + font-size: 0.68rem; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.1rem 0.4rem; + background: var(--color-surface-secondary); + white-space: nowrap; + } + + /* Banner */ + .banner--error { + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-md); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + padding: 0.5rem 0.65rem; + font-size: 0.78rem; + } + + /* Main area */ + .main-area { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + min-height: 0; + } + + .main-area--panel-open { + grid-template-columns: 1fr 300px; + } + + .graph-pane { + min-height: 400px; + position: relative; + } + + /* Loading */ + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 100%; + color: var(--color-text-secondary); + font-size: 0.8rem; + } + + .loading-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Detail panel */ + .detail-panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + overflow-y: auto; + display: grid; + grid-template-rows: auto 1fr; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .panel-header h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-heading); + } + + .panel-close { + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + padding: 0 0.2rem; + } + + .panel-close:hover { + color: var(--color-text-primary); + } + + .panel-body { + padding: 0.5rem 0.65rem; + display: grid; + gap: 0.4rem; + } + + .panel-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.78rem; + } + + .panel-label { + color: var(--color-text-secondary); + font-size: 0.72rem; + } + + .type-badge--production { color: var(--color-status-error-text); font-weight: 500; } + .type-badge--staging { color: var(--color-status-warning-text); font-weight: 500; } + .type-badge--development { color: var(--color-text-secondary); font-weight: 500; } + + .health-badge--healthy { color: var(--color-status-success-text); font-weight: 500; } + .health-badge--degraded { color: var(--color-status-warning-text); font-weight: 500; } + .health-badge--unhealthy { color: var(--color-status-error-text); font-weight: 500; } + .health-badge--unknown { color: var(--color-text-muted); font-weight: 500; } + + .frozen-badge { + color: var(--color-status-error-text); + font-weight: 700; + font-size: 0.72rem; + } + + .panel-actions { + display: flex; + gap: 0.35rem; + padding-top: 0.3rem; + border-top: 1px solid var(--color-border-primary); + margin-top: 0.2rem; + } + + .btn { + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + text-align: center; + } + + .btn--primary { + background: var(--color-btn-primary-bg, var(--color-brand-primary)); + color: var(--color-btn-primary-text, #fff); + border: none; + } + + .btn--secondary { + background: var(--color-surface-secondary); + color: var(--color-text-link); + border: 1px solid var(--color-border-primary); + } + + @media (max-width: 960px) { + .main-area--panel-open { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + } + + .filter-bar { + flex-direction: column; + } + + .filter-item--wide { + min-width: auto; + } + } + `], +}) +export class TopologyGraphPageComponent { + private readonly layoutService = inject(TopologyLayoutService); + readonly context = inject(PlatformContextStore); + + readonly loading = signal(false); + readonly error = signal(null); + readonly layout = signal(null); + readonly searchQuery = signal(''); + readonly typeFilter = signal(''); + readonly healthFilter = signal(''); + readonly selectedNode = signal(null); + readonly selectedEdge = signal(null); + + readonly panelOpen = computed(() => this.selectedNode() !== null || this.selectedEdge() !== null); + + readonly panelTitle = computed(() => { + const node = this.selectedNode(); + if (node) return node.label; + const edge = this.selectedEdge(); + if (edge) return edge.label ?? 'Promotion Path'; + return ''; + }); + + readonly filteredLayout = computed((): TopologyLayoutResponse | null => { + const data = this.layout(); + if (!data) return null; + + const query = this.searchQuery().trim().toLowerCase(); + const typeF = this.typeFilter(); + const healthF = this.healthFilter(); + + if (!query && !typeF && !healthF) return data; + + const matchedEnvIds = new Set(); + const matchedRegionIds = new Set(); + + const filteredNodes = data.nodes.filter((n) => { + if (n.kind === 'region') return true; // keep all regions initially + + const matchesSearch = !query + || n.label.toLowerCase().includes(query) + || (n.environmentId?.toLowerCase().includes(query) ?? false) + || (n.regionId?.toLowerCase().includes(query) ?? false); + const matchesType = !typeF || n.environmentType === typeF; + const matchesHealth = !healthF || n.healthStatus === healthF; + const keep = matchesSearch && matchesType && matchesHealth; + + if (keep) { + matchedEnvIds.add(n.id); + if (n.parentNodeId) matchedRegionIds.add(n.parentNodeId); + } + return keep; + }).filter((n) => { + if (n.kind === 'region') return matchedRegionIds.has(n.id); + return true; + }); + + const filteredEdges = data.edges.filter( + (e) => matchedEnvIds.has(e.sourceNodeId) && matchedEnvIds.has(e.targetNodeId), + ); + + return { ...data, nodes: filteredNodes, edges: filteredEdges }; + }); + + constructor() { + this.context.initialize(); + + effect(() => { + this.context.contextVersion(); + this.load(); + }); + } + + onNodeSelected(node: TopologyPositionedNode): void { + this.selectedNode.set(node); + this.selectedEdge.set(null); + } + + onEdgeSelected(edge: TopologyRoutedEdge): void { + this.selectedEdge.set(edge); + this.selectedNode.set(null); + } + + closePanel(): void { + this.selectedNode.set(null); + this.selectedEdge.set(null); + } + + private load(): void { + this.loading.set(true); + this.error.set(null); + + this.layoutService + .getLayout(this.context) + .pipe( + take(1), + catchError((err: unknown) => { + this.error.set( + err instanceof Error ? err.message : 'Failed to load topology layout.', + ); + return of(null); + }), + ) + .subscribe({ + next: (result) => { + this.layout.set(result); + this.loading.set(false); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts new file mode 100644 index 000000000..6a16d77fe --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts @@ -0,0 +1,637 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + input, + output, + signal, + ViewChild, +} from '@angular/core'; + +import { + TopologyLayoutResponse, + TopologyPositionedNode, + TopologyRoutedEdge, + TopologyEdgeSection, +} from './topology-layout.models'; + +@Component({ + selector: 'app-topology-graph', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (!layout()) { +
No topology data available.
+ } @else { +
+ + + +
+ + + + + + + + + + + + + @for (node of regionNodes(); track node.id) { + + + {{ node.label }} + + } + + + + + @for (edge of edges(); track edge.id) { + + + @if (edge.label) { + {{ edge.label }} + } + + } + + + + + @for (node of environmentNodes(); track node.id) { + + + + + + + + + {{ truncate(node.label, 18) }} + + + {{ formatEnvType(node.environmentType) }} + + + + {{ node.hostCount }} host{{ node.hostCount !== 1 ? 's' : '' }} + · {{ node.targetCount }} target{{ node.targetCount !== 1 ? 's' : '' }} + + + @if (node.currentReleaseId) { + {{ truncate(node.currentReleaseId, 12) }} + } + + + @if (node.isFrozen) { + FROZEN + } + + + @if (selectedNodeId() === node.id) { + + } + + } + + + + + @if (showMinimap()) { +
+ + @for (node of environmentNodes(); track node.id) { + + } + + +
+ } + } +
+ `, + styles: [` + :host { + display: block; + position: relative; + width: 100%; + height: 100%; + min-height: 300px; + } + + .graph-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + cursor: grab; + } + + .graph-container:active { + cursor: grabbing; + } + + .graph-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-muted); + font-size: 0.85rem; + } + + /* Controls */ + .graph-controls { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 2; + display: flex; + gap: 0.25rem; + } + + .graph-controls button { + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-primary); + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 150ms ease, border-color 150ms ease; + } + + .graph-controls button:hover { + background: var(--color-brand-soft); + border-color: var(--color-border-emphasis); + } + + /* Canvas */ + .topology-canvas { + width: 100%; + height: 100%; + } + + /* Region rects */ + .region-rect { + fill: var(--color-surface-primary); + stroke: var(--color-border-primary); + stroke-width: 1; + stroke-dasharray: 6 3; + opacity: 0.7; + transition: opacity 150ms ease; + } + + .region-group:hover .region-rect { + opacity: 1; + stroke: var(--color-border-emphasis); + } + + .region-group--selected .region-rect { + stroke: var(--color-brand-primary); + stroke-width: 2; + opacity: 1; + } + + .region-label { + font-size: 11px; + font-weight: 600; + fill: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + pointer-events: none; + } + + /* Edges */ + .edge-path { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; + transition: stroke 150ms ease, stroke-width 150ms ease; + } + + .edge-group:hover .edge-path { + stroke: var(--color-text-secondary); + stroke-width: 2; + } + + .edge-group--selected .edge-path { + stroke: var(--color-brand-primary); + stroke-width: 2.5; + } + + .edge-label { + font-size: 9px; + fill: var(--color-text-muted); + text-anchor: middle; + pointer-events: none; + } + + .edge-group--selected .edge-label { + fill: var(--color-brand-primary); + font-weight: 500; + } + + /* Environment nodes */ + .env-rect { + stroke-width: 1.5; + transition: stroke 150ms ease, filter 150ms ease; + } + + .env-rect--healthy { + fill: var(--color-surface-primary); + stroke: var(--color-status-success-border); + } + + .env-rect--degraded { + fill: var(--color-surface-primary); + stroke: var(--color-status-warning-border); + } + + .env-rect--unhealthy { + fill: var(--color-surface-primary); + stroke: var(--color-status-error-border); + } + + .env-rect--unknown { + fill: var(--color-surface-primary); + stroke: var(--color-border-primary); + } + + .env-node:hover .env-rect { + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + } + + .env-node--selected .env-rect { + stroke-width: 2.5; + } + + .env-selection { + fill: none; + stroke: var(--color-brand-primary); + stroke-width: 2; + stroke-dasharray: 4 2; + pointer-events: none; + } + + /* Health dot */ + .health-dot--healthy { fill: var(--color-status-success-text); } + .health-dot--degraded { fill: var(--color-status-warning-text); } + .health-dot--unhealthy { fill: var(--color-status-error-text); } + .health-dot--unknown { fill: var(--color-text-muted); } + + /* Text */ + .env-name { + font-size: 12px; + font-weight: 600; + fill: var(--color-text-primary); + pointer-events: none; + } + + .env-type { + font-size: 9px; + font-weight: 500; + pointer-events: none; + } + + .env-type--production { fill: var(--color-status-error-text); } + .env-type--staging { fill: var(--color-status-warning-text); } + .env-type--development { fill: var(--color-status-info-text, var(--color-text-secondary)); } + + .env-meta { + font-size: 10px; + fill: var(--color-text-secondary); + pointer-events: none; + } + + .env-release { + font-size: 10px; + fill: var(--color-text-link); + pointer-events: none; + } + + .env-frozen { + font-size: 9px; + font-weight: 700; + fill: var(--color-status-error-text); + pointer-events: none; + } + + /* Minimap */ + .minimap { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + width: 140px; + height: 90px; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + opacity: 0.85; + overflow: hidden; + } + + .minimap-svg { + width: 100%; + height: 100%; + } + + .minimap-node { + stroke: none; + } + + .minimap-node--healthy { fill: var(--color-status-success-border); } + .minimap-node--degraded { fill: var(--color-status-warning-border); } + .minimap-node--unhealthy { fill: var(--color-status-error-border); } + .minimap-node--unknown { fill: var(--color-border-primary); } + + .minimap-viewport { + fill: none; + stroke: var(--color-brand-primary); + stroke-width: 2; + } + + /* Cursor overrides */ + .env-node, .edge-group, .region-group { + cursor: pointer; + } + `], +}) +export class TopologyGraphComponent { + @ViewChild('svgCanvas') svgCanvas?: ElementRef; + + readonly layout = input(null); + + readonly nodeSelected = output(); + readonly edgeSelected = output(); + + readonly selectedNodeId = signal(null); + readonly selectedEdgeId = signal(null); + + private zoom = signal(1); + private panX = signal(0); + private panY = signal(0); + private isDragging = false; + private dragStartX = 0; + private dragStartY = 0; + + readonly regionNodes = computed(() => { + const data = this.layout(); + return data?.nodes.filter((n) => n.kind === 'region') ?? []; + }); + + readonly environmentNodes = computed(() => { + const data = this.layout(); + return data?.nodes.filter((n) => n.kind === 'environment') ?? []; + }); + + readonly edges = computed(() => { + return this.layout()?.edges ?? []; + }); + + readonly showMinimap = computed(() => { + return this.environmentNodes().length > 6; + }); + + readonly viewBox = computed(() => { + const meta = this.layout()?.metadata; + const w = (meta?.canvasWidth ?? 800) / this.zoom(); + const h = (meta?.canvasHeight ?? 500) / this.zoom(); + const x = -this.panX() / this.zoom(); + const y = -this.panY() / this.zoom(); + return `${x} ${y} ${w} ${h}`; + }); + + readonly minimapViewBox = computed(() => { + const meta = this.layout()?.metadata; + const w = meta?.canvasWidth ?? 800; + const h = meta?.canvasHeight ?? 500; + return `0 0 ${w} ${h}`; + }); + + readonly viewportX = computed(() => -this.panX() / this.zoom()); + readonly viewportY = computed(() => -this.panY() / this.zoom()); + readonly viewportW = computed(() => (this.layout()?.metadata?.canvasWidth ?? 800) / this.zoom()); + readonly viewportH = computed(() => (this.layout()?.metadata?.canvasHeight ?? 500) / this.zoom()); + + getEdgePath(edge: TopologyRoutedEdge): string { + if (!edge.sections?.length) return ''; + const parts: string[] = []; + for (const section of edge.sections) { + parts.push(`M ${section.startPoint.x} ${section.startPoint.y}`); + for (const bp of section.bendPoints) { + parts.push(`L ${bp.x} ${bp.y}`); + } + parts.push(`L ${section.endPoint.x} ${section.endPoint.y}`); + } + return parts.join(' '); + } + + getEdgeLabelX(edge: TopologyRoutedEdge): number { + return this.edgeMidpoint(edge).x; + } + + getEdgeLabelY(edge: TopologyRoutedEdge): number { + return this.edgeMidpoint(edge).y - 6; + } + + truncate(text: string | undefined | null, max: number): string { + if (!text) return ''; + return text.length > max ? text.substring(0, max - 1) + '\u2026' : text; + } + + formatEnvType(type: string | undefined): string { + if (!type) return ''; + if (type === 'production') return 'PROD'; + if (type === 'staging') return 'STAGE'; + if (type === 'development') return 'DEV'; + return type.toUpperCase().substring(0, 5); + } + + onNodeClick(node: TopologyPositionedNode, event: MouseEvent): void { + event.stopPropagation(); + this.selectedNodeId.set(node.id); + this.selectedEdgeId.set(null); + this.nodeSelected.emit(node); + } + + onEdgeClick(edge: TopologyRoutedEdge, event: MouseEvent): void { + event.stopPropagation(); + this.selectedEdgeId.set(edge.id); + this.selectedNodeId.set(null); + this.edgeSelected.emit(edge); + } + + onCanvasClick(): void { + this.selectedNodeId.set(null); + this.selectedEdgeId.set(null); + } + + onWheel(event: WheelEvent): void { + event.preventDefault(); + const delta = event.deltaY > 0 ? -0.1 : 0.1; + this.zoom.update((z) => Math.max(0.25, Math.min(3, z + delta))); + } + + onMouseDown(event: MouseEvent): void { + if (event.button === 0) { + this.isDragging = true; + this.dragStartX = event.clientX - this.panX(); + this.dragStartY = event.clientY - this.panY(); + } + } + + onMouseMove(event: MouseEvent): void { + if (this.isDragging) { + this.panX.set(event.clientX - this.dragStartX); + this.panY.set(event.clientY - this.dragStartY); + } + } + + onMouseUp(): void { + this.isDragging = false; + } + + zoomIn(): void { + this.zoom.update((z) => Math.min(3, z + 0.25)); + } + + zoomOut(): void { + this.zoom.update((z) => Math.max(0.25, z - 0.25)); + } + + fitView(): void { + this.zoom.set(1); + this.panX.set(0); + this.panY.set(0); + } + + private edgeMidpoint(edge: TopologyRoutedEdge): { x: number; y: number } { + if (!edge.sections?.length) return { x: 0, y: 0 }; + const section = edge.sections[0]; + const points = [section.startPoint, ...section.bendPoints, section.endPoint]; + const mid = Math.floor(points.length / 2); + return points[mid]; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts new file mode 100644 index 000000000..d24ca7fc6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts @@ -0,0 +1,59 @@ +export interface TopologyLayoutResponse { + nodes: TopologyPositionedNode[]; + edges: TopologyRoutedEdge[]; + metadata: TopologyLayoutMetadata; +} + +export interface TopologyPositionedNode { + id: string; + label: string; + kind: 'region' | 'environment' | 'host'; + parentNodeId: string | null; + x: number; + y: number; + width: number; + height: number; + environmentId?: string; + regionId?: string; + environmentType?: string; + healthStatus?: string; + hostCount: number; + targetCount: number; + currentReleaseId?: string; + isFrozen: boolean; + promotionPathCount: number; +} + +export interface TopologyRoutedEdge { + id: string; + sourceNodeId: string; + targetNodeId: string; + kind?: string; + label?: string; + sections: TopologyEdgeSection[]; + pathId?: string; + pathMode?: string; + status?: string; + requiredApprovals: number; + gateProfileId?: string; + gateProfileName?: string; +} + +export interface TopologyEdgeSection { + startPoint: TopologyPoint; + endPoint: TopologyPoint; + bendPoints: TopologyPoint[]; +} + +export interface TopologyPoint { + x: number; + y: number; +} + +export interface TopologyLayoutMetadata { + regionCount: number; + environmentCount: number; + promotionPathCount: number; + canvasWidth: number; + canvasHeight: number; +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts new file mode 100644 index 000000000..b28a16114 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts @@ -0,0 +1,39 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { TopologyLayoutResponse } from './topology-layout.models'; + +@Injectable({ providedIn: 'root' }) +export class TopologyLayoutService { + private readonly http = inject(HttpClient); + + getLayout( + context: PlatformContextStore, + options?: { + direction?: 'left-to-right' | 'top-to-bottom'; + effort?: 'draft' | 'balanced' | 'best'; + }, + ): Observable { + let params = new HttpParams(); + + const regions = context.selectedRegions(); + const environments = context.selectedEnvironments(); + + if (regions.length > 0) { + params = params.set('region', regions.join(',')); + } + if (environments.length > 0) { + params = params.set('environment', environments.join(',')); + } + if (options?.direction) { + params = params.set('direction', options.direction); + } + if (options?.effort) { + params = params.set('effort', options.effort); + } + + return this.http.get('/api/v2/topology/layout', { params }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts deleted file mode 100644 index 679746442..000000000 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts +++ /dev/null @@ -1,729 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - ViewChild, - AfterViewInit, - OnDestroy, - effect, - inject, - signal, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; -import { Router } from '@angular/router'; -import { catchError, forkJoin, of, take } from 'rxjs'; -import * as d3 from 'd3'; - -import { PlatformContextStore } from '../../core/context/platform-context.store'; -import { TopologyDataService } from './topology-data.service'; -import { - TopologyAgent, - TopologyEnvironment, - TopologyPromotionPath, - TopologyRegion, -} from './topology.models'; - -type TopoNodeKind = 'region' | 'environment' | 'agent'; - -interface TopoNode extends d3.SimulationNodeDatum { - id: string; - kind: TopoNodeKind; - label: string; - sublabel: string; - regionId?: string; - environmentId?: string; - status?: string; -} - -interface TopoLink extends d3.SimulationLinkDatum { - id: string; - relation: 'contains' | 'assigned' | 'promotion'; -} - -@Component({ - selector: 'app-topology-map-page', - standalone: true, - imports: [FormsModule, LoadingStateComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
-

Environment and Target Map

-

Region-first map of environments, agents, and promotion paths.

-
- - @if (error()) { -
{{ error() }}
- } - -
- -
- - - -
-
- -
- @if (loading()) { - - } -
- -
- Region - Environment - Agent - Promotion path -
-
- `, - styles: [` - .topo-map { - display: grid; - gap: 0.6rem; - } - - .topo-map__header { - display: grid; - gap: 0.1rem; - } - - .topo-map__title { - margin: 0; - font-size: 1.15rem; - font-weight: 700; - color: var(--color-text-heading); - } - - .topo-map__subtitle { - margin: 0; - font-size: 0.78rem; - color: var(--color-text-secondary); - } - - .topo-map__banner { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - padding: 0.55rem 0.7rem; - font-size: 0.76rem; - } - - .topo-map__banner--error { - color: var(--color-status-error-text); - border-color: var(--color-status-error-border); - background: var(--color-status-error-bg); - } - - .topo-map__toolbar { - display: flex; - align-items: center; - gap: 0.5rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.4rem 0.55rem; - } - - .topo-map__search { - flex: 1; - min-width: 100px; - } - - .topo-map__search input { - width: 100%; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - font-size: 0.74rem; - padding: 0.25rem 0.45rem; - transition: border-color 150ms ease, box-shadow 150ms ease; - } - - .topo-map__search input:focus { - outline: none; - border-color: var(--color-border-focus); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - .topo-map__search input::placeholder { - color: var(--color-text-muted); - } - - .topo-map__zoom-controls { - display: flex; - gap: 0.2rem; - } - - .topo-map__zoom-controls button { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - font-size: 0.72rem; - padding: 0.2rem 0.4rem; - cursor: pointer; - line-height: 1; - font-weight: 500; - transition: background 150ms ease, border-color 150ms ease, color 150ms ease; - } - - .topo-map__zoom-controls button:hover { - background: var(--color-brand-soft); - border-color: var(--color-border-emphasis); - color: var(--color-text-link); - } - - .topo-map__graph { - position: relative; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - height: calc(100vh - 260px); - min-height: 420px; - overflow: hidden; - transition: box-shadow 180ms ease; - } - - .topo-map__graph:hover { - box-shadow: var(--shadow-sm); - } - - .topo-map__graph :deep(svg) { - display: block; - width: 100%; - height: 100%; - } - - .topo-map__loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; - color: var(--color-text-muted); - font-size: 0.8rem; - } - - .topo-map__loading::before { - content: ''; - display: block; - width: 24px; - height: 24px; - border: 2px solid var(--color-border-primary); - border-top-color: var(--color-brand-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - .topo-map__legend { - display: flex; - gap: 0.65rem; - flex-wrap: wrap; - padding: 0.35rem 0.55rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - } - - .topo-map__legend-item { - display: flex; - align-items: center; - gap: 0.25rem; - font-size: 0.68rem; - color: var(--color-text-secondary); - } - - .topo-map__legend-item::before { - content: ''; - display: inline-block; - width: 10px; - height: 10px; - border-radius: 2px; - flex-shrink: 0; - } - - .topo-map__legend-item--region::before { - background: #6082A8; - border-radius: 50%; - } - - .topo-map__legend-item--environment::before { - background: #4D9B40; - border-radius: 3px; - } - - .topo-map__legend-item--agent::before { - background: #7A5090; - clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); - } - - .topo-map__legend-item--promotion::before { - background: transparent; - border: 1.5px dashed #C89820; - border-radius: 0; - width: 16px; - height: 0; - } - `], -}) -export class TopologyMapPageComponent implements AfterViewInit, OnDestroy { - private readonly router = inject(Router); - private readonly topologyApi = inject(TopologyDataService); - readonly context = inject(PlatformContextStore); - - @ViewChild('graphContainer', { static: true }) graphContainer!: ElementRef; - - readonly loading = signal(false); - readonly error = signal(null); - readonly searchQuery = signal(''); - - private svg: d3.Selection | null = null; - private simulation: d3.Simulation | null = null; - private zoom: d3.ZoomBehavior | null = null; - private resizeObserver: ResizeObserver | null = null; - - private allNodes: TopoNode[] = []; - private allLinks: TopoLink[] = []; - - private readonly nodeColors: Record = { - region: '#6082A8', - environment: '#4D9B40', - agent: '#7A5090', - }; - - private readonly nodeRadii: Record = { - region: 16, - environment: 14, - agent: 10, - }; - - constructor() { - this.context.initialize(); - - effect(() => { - this.context.contextVersion(); - this.load(); - }); - - effect(() => { - this.searchQuery(); - this.applyFilters(); - }); - } - - ngAfterViewInit(): void { - requestAnimationFrame(() => { - this.initGraph(); - // Re-render data that may have arrived before initGraph completed - if (this.allNodes.length > 0) { - this.applyFilters(); - } - this.resizeObserver = new ResizeObserver(() => this.handleResize()); - this.resizeObserver.observe(this.graphContainer.nativeElement); - }); - } - - ngOnDestroy(): void { - this.simulation?.stop(); - this.resizeObserver?.disconnect(); - } - - onZoomIn(): void { - if (!this.svg || !this.zoom) return; - this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.3); - } - - onZoomOut(): void { - if (!this.svg || !this.zoom) return; - this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.7); - } - - onResetZoom(): void { - if (!this.svg || !this.zoom) return; - this.svg.transition().duration(300).call(this.zoom.transform, d3.zoomIdentity); - } - - private initGraph(): void { - const container = this.graphContainer.nativeElement; - const width = container.clientWidth || 800; - const height = container.clientHeight || 420; - - this.svg = d3.select(container) - .append('svg') - .attr('width', '100%') - .attr('height', '100%') - .attr('viewBox', `0 0 ${width} ${height}`); - - this.zoom = d3.zoom() - .scaleExtent([0.15, 4]) - .on('zoom', (event) => { - mainGroup.attr('transform', event.transform.toString()); - }); - - this.svg.call(this.zoom); - - const mainGroup = this.svg.append('g').attr('class', 'main-group'); - - const defs = this.svg.append('defs'); - - defs.append('marker') - .attr('id', 'map-arrow') - .attr('viewBox', '0 -5 10 10') - .attr('refX', 22) - .attr('refY', 0) - .attr('orient', 'auto') - .attr('markerWidth', 5) - .attr('markerHeight', 5) - .append('path') - .attr('d', 'M0,-4L10,0L0,4') - .attr('fill', 'var(--color-text-muted)'); - - defs.append('marker') - .attr('id', 'map-arrow-promotion') - .attr('viewBox', '0 -5 10 10') - .attr('refX', 22) - .attr('refY', 0) - .attr('orient', 'auto') - .attr('markerWidth', 5) - .attr('markerHeight', 5) - .append('path') - .attr('d', 'M0,-4L10,0L0,4') - .attr('fill', '#C89820'); - - mainGroup.append('g').attr('class', 'links'); - mainGroup.append('g').attr('class', 'nodes'); - mainGroup.append('g').attr('class', 'labels'); - - this.simulation = d3.forceSimulation() - .force('link', d3.forceLink().id(d => d.id).distance(d => { - const rel = (d as TopoLink).relation; - if (rel === 'promotion') return 140; - return 80; - })) - .force('charge', d3.forceManyBody().strength(-300)) - .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(30)) - .force('y', d3.forceY().y(d => { - const layerMap: Record = { - region: height * 0.2, - environment: height * 0.5, - agent: height * 0.8, - }; - return layerMap[d.kind] ?? height / 2; - }).strength(0.1)); - } - - private load(): void { - this.loading.set(true); - this.error.set(null); - - forkJoin({ - regions: this.topologyApi.list('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))), - environments: this.topologyApi.list('/api/v2/topology/environments', this.context).pipe(catchError(() => of([]))), - agents: this.topologyApi.list('/api/v2/topology/agents', this.context).pipe(catchError(() => of([]))), - paths: this.topologyApi.list('/api/v2/topology/promotion-paths', this.context).pipe(catchError(() => of([]))), - }) - .pipe(take(1)) - .subscribe({ - next: ({ regions, environments, agents, paths }) => { - this.loading.set(false); - this.buildGraph(regions, environments, agents, paths); - }, - error: (err: unknown) => { - this.error.set(err instanceof Error ? err.message : 'Failed to load topology data.'); - this.loading.set(false); - }, - }); - } - - private buildGraph( - regions: TopologyRegion[], - environments: TopologyEnvironment[], - agents: TopologyAgent[], - paths: TopologyPromotionPath[], - ): void { - const nodes: TopoNode[] = []; - const links: TopoLink[] = []; - - for (const r of regions) { - nodes.push({ - id: `region:${r.regionId}`, - kind: 'region', - label: r.displayName, - sublabel: `${r.environmentCount} env · ${r.targetCount} targets`, - regionId: r.regionId, - }); - } - - for (const e of environments) { - nodes.push({ - id: `env:${e.environmentId}`, - kind: 'environment', - label: e.displayName, - sublabel: `${e.environmentType} · ${e.targetCount} targets`, - regionId: e.regionId, - environmentId: e.environmentId, - }); - links.push({ - id: `r-e:${e.environmentId}`, - source: `region:${e.regionId}`, - target: `env:${e.environmentId}`, - relation: 'contains', - }); - } - - for (const a of agents) { - nodes.push({ - id: `agent:${a.agentId}`, - kind: 'agent', - label: a.agentName, - sublabel: `${a.status} · ${a.assignedTargetCount} targets`, - regionId: a.regionId, - environmentId: a.environmentId, - status: a.status, - }); - links.push({ - id: `e-a:${a.agentId}`, - source: `env:${a.environmentId}`, - target: `agent:${a.agentId}`, - relation: 'assigned', - }); - } - - for (const p of paths) { - links.push({ - id: `promo:${p.pathId}`, - source: `env:${p.sourceEnvironmentId}`, - target: `env:${p.targetEnvironmentId}`, - relation: 'promotion', - }); - } - - this.allNodes = nodes; - this.allLinks = links; - this.applyFilters(); - } - - private applyFilters(): void { - if (!this.svg || !this.simulation) return; - - const query = this.searchQuery().trim().toLowerCase(); - let visibleNodes = [...this.allNodes]; - - if (query.length >= 2) { - visibleNodes = visibleNodes.filter(n => - n.label.toLowerCase().includes(query) || - n.sublabel.toLowerCase().includes(query) - ); - } - - const visibleIds = new Set(visibleNodes.map(n => n.id)); - const visibleLinks = this.allLinks.filter( - l => visibleIds.has((l.source as TopoNode).id ?? l.source as string) && - visibleIds.has((l.target as TopoNode).id ?? l.target as string) - ); - - this.renderGraph(visibleNodes, visibleLinks); - } - - private renderGraph(nodes: TopoNode[], links: TopoLink[]): void { - if (!this.svg || !this.simulation) return; - - const mainGroup = this.svg.select('.main-group'); - - // Links - const linkGroup = mainGroup.select('.links'); - const linkSel = linkGroup.selectAll('line').data(links, d => d.id); - linkSel.exit().remove(); - - const linkEnter = linkSel.enter() - .append('line') - .attr('stroke', d => d.relation === 'promotion' ? '#C89820' : 'var(--color-text-muted)') - .attr('stroke-opacity', d => d.relation === 'promotion' ? 0.6 : 0.35) - .attr('stroke-width', d => d.relation === 'promotion' ? 1.5 : 1.2) - .attr('stroke-dasharray', d => d.relation === 'promotion' ? '6 3' : 'none') - .attr('marker-end', d => d.relation === 'promotion' ? 'url(#map-arrow-promotion)' : 'url(#map-arrow)'); - - const allLinks = linkEnter.merge(linkSel); - - // Nodes - const nodeGroup = mainGroup.select('.nodes'); - const nodeSel = nodeGroup.selectAll('g.topo-node').data(nodes, d => d.id); - nodeSel.exit().remove(); - - const nodeEnter = nodeSel.enter() - .append('g') - .attr('class', 'topo-node') - .attr('cursor', 'pointer') - .on('click', (event, d) => { - event.stopPropagation(); - this.navigateToNode(d); - }) - .call(this.dragBehavior()); - - nodeEnter.each((d, i, els) => { - const g = d3.select(els[i]); - const r = this.nodeRadii[d.kind]; - const color = this.nodeColors[d.kind]; - - if (d.kind === 'region') { - g.append('circle') - .attr('r', r) - .attr('fill', color) - .attr('stroke', 'var(--color-surface-primary)') - .attr('stroke-width', 2); - } else if (d.kind === 'environment') { - g.append('rect') - .attr('width', r * 2) - .attr('height', r * 1.4) - .attr('x', -r) - .attr('y', -r * 0.7) - .attr('rx', 5) - .attr('fill', color) - .attr('stroke', 'var(--color-surface-primary)') - .attr('stroke-width', 2); - } else { - g.append('path') - .attr('d', this.hexPath(r)) - .attr('fill', color) - .attr('stroke', 'var(--color-surface-primary)') - .attr('stroke-width', 1.5); - } - }); - - nodeEnter.append('title') - .text(d => `${d.label}\n${d.sublabel}`); - - const allNodes = nodeEnter.merge(nodeSel); - - // Labels - const labelGroup = mainGroup.select('.labels'); - const labelSel = labelGroup.selectAll('text').data(nodes, d => d.id); - labelSel.exit().remove(); - - const labelEnter = labelSel.enter() - .append('text') - .attr('text-anchor', 'middle') - .attr('dy', d => (this.nodeRadii[d.kind] + 11)) - .attr('font-size', 9) - .attr('fill', 'var(--color-text-secondary)') - .text(d => this.truncate(d.label, 16)); - - const allLabels = labelEnter.merge(labelSel); - - // Simulation - this.simulation - .nodes(nodes) - .on('tick', () => { - allLinks - .attr('x1', d => (d.source as TopoNode).x ?? 0) - .attr('y1', d => (d.source as TopoNode).y ?? 0) - .attr('x2', d => (d.target as TopoNode).x ?? 0) - .attr('y2', d => (d.target as TopoNode).y ?? 0); - - allNodes.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`); - - allLabels - .attr('x', d => d.x ?? 0) - .attr('y', d => d.y ?? 0); - }); - - (this.simulation.force('link') as d3.ForceLink).links(links); - this.simulation.alpha(0.8).restart(); - } - - private navigateToNode(node: TopoNode): void { - switch (node.kind) { - case 'region': - void this.router.navigate(['/setup/topology/regions'], { queryParamsHandling: 'merge' }); - break; - case 'environment': - if (node.environmentId) { - void this.router.navigate(['/setup/topology/environments', node.environmentId, 'posture'], { - queryParamsHandling: 'merge', - }); - } - break; - case 'agent': - void this.router.navigate(['/setup/topology/agents'], { - queryParams: { agentId: node.id.replace('agent:', '') }, - queryParamsHandling: 'merge', - }); - break; - } - } - - private dragBehavior(): d3.DragBehavior { - return d3.drag() - .on('start', (event, d) => { - if (!event.active) this.simulation?.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; - }) - .on('drag', (event, d) => { - d.fx = event.x; - d.fy = event.y; - }) - .on('end', (event, d) => { - if (!event.active) this.simulation?.alphaTarget(0); - d.fx = null; - d.fy = null; - }); - } - - private handleResize(): void { - if (!this.svg || !this.simulation) return; - const container = this.graphContainer.nativeElement; - const width = container.clientWidth || 800; - const height = container.clientHeight || 420; - - this.svg.attr('viewBox', `0 0 ${width} ${height}`); - (this.simulation.force('center') as d3.ForceCenter) - .x(width / 2) - .y(height / 2); - - // Update y-force layer positions to match new height - this.simulation.force('y', d3.forceY().y(d => { - const layerMap: Record = { - region: height * 0.2, - environment: height * 0.5, - agent: height * 0.8, - }; - return layerMap[d.kind] ?? height / 2; - }).strength(0.1)); - - this.simulation.alpha(0.3).restart(); - } - - private hexPath(r: number): string { - const points: [number, number][] = []; - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i - Math.PI / 6; - points.push([r * Math.cos(angle), r * Math.sin(angle)]); - } - return 'M' + points.map(p => p.join(',')).join('L') + 'Z'; - } - - private truncate(text: string, max: number): string { - return text.length <= max ? text : text.substring(0, max - 1) + '\u2026'; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts deleted file mode 100644 index 6af77a134..000000000 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts +++ /dev/null @@ -1,819 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, forkJoin, of, take } from 'rxjs'; - -import { PlatformContextStore } from '../../core/context/platform-context.store'; -import { TopologyDataService } from './topology-data.service'; -import { TopologyEnvironment, TopologyPromotionPath, TopologyRegion, TopologyTarget } from './topology.models'; - -type RegionsView = 'region-first' | 'flat' | 'graph'; - -@Component({ - selector: 'app-topology-regions-environments-page', - standalone: true, - imports: [FormsModule, RouterLink], - template: ` -
-
-
-

Region-first topology inventory with environment posture and drilldowns.

-
-
- {{ context.regionSummary() }} - {{ context.environmentSummary() }} -
-
- -
-
- - -
-
- - -
-
- - @if (error()) { - - } - - @if (loading()) { -
-
-
-
-
-
- } @else { - @if (viewMode() === 'region-first') { -
-
-

Regions

-
    - @for (region of filteredRegions(); track region.regionId) { -
  • - -
  • - } @empty { -
  • No regions in current scope.
  • - } -
-
- -
-

Environments · {{ selectedRegionLabel() }}

- - - - - - - - - - - - @for (env of selectedRegionEnvironments(); track env.environmentId) { - - - - - - - - } @empty { - - - - } - -
EnvironmentTypeHealthTargetsActions
{{ env.displayName }}{{ env.environmentType }}{{ environmentHealthLabel(env.environmentId) }}{{ env.targetCount }} - -
- - No environments for this region. -
-
-
- } @else if (viewMode() === 'flat') { -
-

Environment Inventory

- - - - - - - - - - - - - @for (env of filteredEnvironments(); track env.environmentId) { - - - - - - - - - } @empty { - - - - } - -
EnvironmentRegionTypeHealthTargetsActions
{{ env.displayName }}{{ env.regionId }}{{ env.environmentType }}{{ environmentHealthLabel(env.environmentId) }}{{ env.targetCount }} - -
- - No environments for current filters. -
-
- } @else { -
-

Promotion Graph (by region)

-
    - @for (edge of graphEdges(); track edge.pathId) { -
  • {{ edge.regionId }} · {{ edge.sourceEnvironmentId }} -> {{ edge.targetEnvironmentId }} · {{ edge.status }}
  • - } @empty { -
  • No promotion edges in current scope.
  • - } -
-
- } - - - } -
- `, - styles: [` - .regions-env { - display: grid; - gap: 0.75rem; - } - - .regions-env__header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 1rem; - } - - .regions-env__header h1 { - margin: 0; - font-size: 1.15rem; - color: var(--color-text-heading); - } - - .regions-env__header p { - margin: 0.15rem 0 0; - color: var(--color-text-secondary); - font-size: 0.78rem; - } - - .regions-env__scope { - display: flex; - gap: 0.3rem; - flex-wrap: wrap; - justify-content: flex-end; - padding-top: 0.1rem; - } - - .regions-env__scope span { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - background: var(--color-surface-secondary); - color: var(--color-text-secondary); - font-size: 0.68rem; - padding: 0.1rem 0.45rem; - white-space: nowrap; - } - - /* --- Filters --- */ - .filters { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.55rem 0.65rem; - display: flex; - gap: 0.55rem; - align-items: flex-end; - flex-wrap: wrap; - } - - .filters__item { - display: grid; - gap: 0.15rem; - } - - .filters__item--wide { - flex: 1; - min-width: 200px; - } - - .filters label { - font-size: 0.67rem; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 500; - } - - .filters select, - .filters input { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - font-size: 0.78rem; - padding: 0.3rem 0.42rem; - transition: border-color 150ms ease, box-shadow 150ms ease; - } - - .filters select:focus, - .filters input:focus { - outline: none; - border-color: var(--color-border-focus); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - /* --- Banner --- */ - .banner { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - padding: 0.7rem; - font-size: 0.78rem; - } - - .banner--error { - color: var(--color-status-error-text); - background: var(--color-status-error-bg); - border-color: var(--color-status-error-border); - } - - /* --- Skeleton --- */ - .skeleton-table { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.75rem; - display: grid; - gap: 0.5rem; - } - - .skeleton-row { - display: flex; - gap: 0.75rem; - } - - .skeleton-line { - flex: 1; - height: 0.65rem; - border-radius: var(--radius-sm); - background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-highlight) 50%, var(--color-skeleton-base) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; - } - - .skeleton-line--title { - height: 0.85rem; - max-width: 30%; - } - - .skeleton-line--short { - max-width: 40%; - } - - @keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } - } - - /* --- Split layout --- */ - .split { - display: grid; - gap: 0.6rem; - grid-template-columns: minmax(220px, 320px) 1fr; - align-items: start; - } - - /* --- Cards --- */ - .card { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.7rem; - display: grid; - gap: 0.45rem; - transition: box-shadow 180ms ease, border-color 180ms ease; - } - - .card:hover { - box-shadow: var(--shadow-sm); - } - - .card h2 { - margin: 0; - font-size: 0.92rem; - color: var(--color-card-heading); - font-weight: 600; - } - - /* --- Region list --- */ - .region-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 0.25rem; - } - - .region-list li button { - width: 100%; - text-align: left; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - padding: 0.4rem 0.5rem; - display: grid; - gap: 0.1rem; - cursor: pointer; - transition: background 150ms ease, border-color 150ms ease, transform 150ms ease; - } - - .region-list li button:hover { - background: var(--color-brand-soft); - border-color: var(--color-border-emphasis); - } - - .region-list li button.active { - border-color: var(--color-brand-primary); - background: var(--color-brand-primary-10); - box-shadow: inset 3px 0 0 var(--color-brand-primary); - } - - .region-list small { - color: var(--color-text-secondary); - font-size: 0.7rem; - } - - /* --- Tables --- */ - /* Table styling provided by global .stella-table class */ - th { - position: sticky; - top: 0; - z-index: 1; - } - tbody tr { - transition: background 120ms ease; - } - - tr:last-child td { - border-bottom: none; - } - - .muted { - color: var(--color-text-secondary); - font-size: 0.74rem; - } - - /* --- Status badges --- */ - .status-badge { - display: inline-flex; - align-items: center; - border-radius: var(--radius-full); - font-size: 0.67rem; - font-weight: 500; - padding: 0.1rem 0.4rem; - line-height: 1.3; - border: 1px solid transparent; - white-space: nowrap; - } - - .status-badge--healthy { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - border-color: var(--color-status-success-border); - } - - .status-badge--degraded { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - border-color: var(--color-status-warning-border); - } - - .status-badge--unhealthy { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - border-color: var(--color-status-error-border); - } - - .status-badge--muted { - background: var(--color-surface-tertiary); - color: var(--color-text-muted); - border-color: var(--color-border-primary); - } - - /* --- Icon button --- */ - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-secondary); - width: 1.5rem; - height: 1.5rem; - padding: 0; - cursor: pointer; - transition: background 150ms ease, border-color 150ms ease, color 150ms ease; - } - - .icon-btn:hover { - background: var(--color-brand-soft); - border-color: var(--color-border-emphasis); - color: var(--color-text-link); - } - - /* --- Empty cell --- */ - .empty-cell { - text-align: center; - color: var(--color-text-muted); - font-size: 0.74rem; - padding: 1rem 0.5rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - } - - .empty-cell__icon { - opacity: 0.4; - flex-shrink: 0; - } - - /* --- Graph list --- */ - .graph-list { - margin: 0; - padding: 0; - list-style: none; - display: grid; - gap: 0.25rem; - } - - .graph-list li { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - padding: 0.32rem 0.42rem; - font-size: 0.74rem; - background: var(--color-surface-secondary); - transition: background 150ms ease; - } - - .graph-list li:hover { - background: var(--color-brand-soft); - } - - /* --- Actions --- */ - .actions { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - } - - .actions a { - display: inline-flex; - align-items: center; - gap: 0.2rem; - color: var(--color-text-link); - font-size: 0.73rem; - font-weight: 500; - text-decoration: none; - padding: 0.2rem 0.45rem; - border-radius: var(--radius-sm); - transition: background 150ms ease, color 150ms ease; - } - - .actions a:hover { - background: var(--color-brand-soft); - color: var(--color-text-link-hover); - } - - @media (max-width: 960px) { - .filters { - flex-direction: column; - } - - .filters__item--wide { - min-width: auto; - } - - .split { - grid-template-columns: 1fr; - } - } - `], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TopologyRegionsEnvironmentsPageComponent { - private readonly topologyApi = inject(TopologyDataService); - private readonly route = inject(ActivatedRoute); - readonly context = inject(PlatformContextStore); - - readonly loading = signal(false); - readonly error = signal(null); - readonly searchQuery = signal(''); - readonly viewMode = signal('region-first'); - readonly requestedEnvironmentId = signal(''); - readonly selectedRegionId = signal(''); - readonly selectedEnvironmentId = signal(''); - - readonly regions = signal([]); - readonly environments = signal([]); - readonly targets = signal([]); - readonly paths = signal([]); - - readonly filteredRegions = computed(() => { - const query = this.searchQuery().trim().toLowerCase(); - if (!query) { - return this.regions(); - } - - return this.regions().filter((item) => this.match(query, [item.displayName, item.regionId])); - }); - - readonly filteredEnvironments = computed(() => { - const query = this.searchQuery().trim().toLowerCase(); - if (!query) { - return this.environments(); - } - - return this.environments().filter((item) => - this.match(query, [item.displayName, item.environmentId, item.regionId, item.environmentType]), - ); - }); - - readonly selectedRegionLabel = computed(() => { - const selected = this.regions().find((item) => item.regionId === this.selectedRegionId()); - return (selected?.displayName ?? this.selectedRegionId()) || 'All Regions'; - }); - - readonly selectedRegionEnvironments = computed(() => { - const selectedRegion = this.selectedRegionId(); - if (!selectedRegion) { - return this.filteredEnvironments(); - } - - return this.filteredEnvironments().filter((item) => item.regionId === selectedRegion); - }); - - readonly selectedEnvironmentLabel = computed(() => { - const selected = this.environments().find((item) => item.environmentId === this.selectedEnvironmentId()); - return (selected?.displayName ?? this.selectedEnvironmentId()) || 'No environment selected'; - }); - - readonly selectedEnvironmentHealth = computed(() => { - const environmentId = this.selectedEnvironmentId(); - if (!environmentId) { - return 'No environment selected'; - } - - return this.environmentHealthLabel(environmentId); - }); - - readonly selectedEnvironmentTargetCount = computed(() => { - const environmentId = this.selectedEnvironmentId(); - if (!environmentId) { - return 0; - } - return this.targets().filter((item) => item.environmentId === environmentId).length; - }); - - readonly graphEdges = computed(() => { - const selectedRegion = this.selectedRegionId(); - if (!selectedRegion) { - return this.paths(); - } - return this.paths().filter((item) => item.regionId === selectedRegion); - }); - - constructor() { - this.context.initialize(); - - this.route.data.subscribe((data) => { - const defaultView = (data['defaultView'] as RegionsView | undefined) ?? 'region-first'; - this.viewMode.set(defaultView); - }); - - this.route.queryParamMap.subscribe((queryParamMap) => { - this.requestedEnvironmentId.set(this.resolveRequestedEnvironmentId(queryParamMap)); - }); - - effect(() => { - this.context.contextVersion(); - this.requestedEnvironmentId(); - this.load(); - }); - } - - selectRegion(regionId: string): void { - this.selectedRegionId.set(regionId); - const firstEnv = this.environments().find((item) => item.regionId === regionId); - this.selectedEnvironmentId.set(firstEnv?.environmentId ?? ''); - } - - environmentHealthLabel(environmentId: string): string { - const statuses = this.targets() - .filter((item) => item.environmentId === environmentId) - .map((item) => item.healthStatus.trim().toLowerCase()); - - if (statuses.length === 0) { - return 'No target data'; - } - if (statuses.includes('unhealthy') || statuses.includes('offline')) { - return 'Unhealthy'; - } - if (statuses.includes('degraded') || statuses.includes('unknown')) { - return 'Degraded'; - } - return 'Healthy'; - } - - private load(): void { - this.loading.set(true); - this.error.set(null); - - forkJoin({ - regions: this.topologyApi.list('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))), - environments: this.topologyApi - .list('/api/v2/topology/environments', this.context) - .pipe(catchError(() => of([]))), - targets: this.topologyApi.list('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))), - paths: this.topologyApi - .list('/api/v2/topology/promotion-paths', this.context) - .pipe(catchError(() => of([]))), - }) - .pipe(take(1)) - .subscribe({ - next: ({ regions, environments, targets, paths }) => { - this.regions.set(regions); - this.environments.set(environments); - this.targets.set(targets); - this.paths.set(paths); - - const nextSelectedRegion = this.resolveSelectedRegionId(regions, environments); - this.selectedRegionId.set(nextSelectedRegion); - - const nextSelectedEnvironment = this.resolveSelectedEnvironmentId(environments, nextSelectedRegion); - this.selectedEnvironmentId.set(nextSelectedEnvironment); - - this.loading.set(false); - }, - error: (err: unknown) => { - this.error.set(err instanceof Error ? err.message : 'Failed to load region and environment inventory.'); - this.regions.set([]); - this.environments.set([]); - this.targets.set([]); - this.paths.set([]); - this.selectedRegionId.set(''); - this.selectedEnvironmentId.set(''); - this.loading.set(false); - }, - }); - } - - private match(query: string, values: string[]): boolean { - return values.some((value) => value.toLowerCase().includes(query)); - } - - private resolveSelectedRegionId( - regions: TopologyRegion[], - environments: TopologyEnvironment[], - ): string { - const current = this.selectedRegionId(); - const scopedEnvironments = this.context.selectedEnvironments(); - const scopedRegions = this.context.selectedRegions(); - const requestedEnvironmentId = this.requestedEnvironmentId(); - const regionFromRequestedEnvironment = - environments.find((item) => item.environmentId === requestedEnvironmentId)?.regionId ?? ''; - const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? ''; - const preferredScopedRegion = - regionFromRequestedEnvironment - || regionFromScopedEnvironment - || scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId)) - || ''; - - if (preferredScopedRegion) { - return preferredScopedRegion; - } - - if (current && regions.some((item) => item.regionId === current)) { - return current; - } - - return regions[0]?.regionId ?? ''; - } - - private resolveSelectedEnvironmentId( - environments: TopologyEnvironment[], - selectedRegionId: string, - ): string { - const current = this.selectedEnvironmentId(); - const scopedEnvironments = this.context.selectedEnvironments(); - const requestedEnvironmentId = this.requestedEnvironmentId(); - const environmentsInRegion = selectedRegionId - ? environments.filter((item) => item.regionId === selectedRegionId) - : environments; - const preferredRequestedEnvironment = - environmentsInRegion.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId - ?? environments.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId - ?? ''; - - if (preferredRequestedEnvironment) { - return preferredRequestedEnvironment; - } - - const preferredScopedEnvironment = - scopedEnvironments.find((environmentId) => - environmentsInRegion.some((item) => item.environmentId === environmentId), - ) - ?? scopedEnvironments.find((environmentId) => - environments.some((item) => item.environmentId === environmentId), - ) - ?? ''; - - if (preferredScopedEnvironment) { - return preferredScopedEnvironment; - } - - if (current && environmentsInRegion.some((item) => item.environmentId === current)) { - return current; - } - - return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? ''; - } - - private resolveRequestedEnvironmentId(queryParamMap: Pick): string { - const explicitEnvironment = queryParamMap.get('environment')?.trim(); - if (explicitEnvironment) { - return explicitEnvironment; - } - - const environmentsValue = queryParamMap.get('environments')?.trim(); - if (!environmentsValue) { - return ''; - } - - return environmentsValue - .split(',') - .map((value) => value.trim()) - .find((value) => value.length > 0) ?? ''; - } -} - - diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts index dabfefbca..9b179f126 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts @@ -6,8 +6,7 @@ import { filter } from 'rxjs'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type TabType = - | 'regions' - | 'map' + | 'overview' | 'targets' | 'hosts' | 'agents' @@ -19,13 +18,12 @@ type TabType = | 'runtime-drift'; const KNOWN_TAB_IDS: readonly string[] = [ - 'regions', 'map', 'targets', 'hosts', 'agents', 'posture', + 'overview', 'targets', 'hosts', 'agents', 'posture', 'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift', ]; const PAGE_TABS: readonly StellaPageTab[] = [ - { id: 'regions', label: 'Regions & Environments', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' }, - { id: 'map', label: 'Map', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'overview', label: 'Topology', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' }, { id: 'targets', label: 'Targets', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0|||M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0' }, { id: 'hosts', label: 'Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, { id: 'agents', label: 'Agents', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' }, @@ -113,7 +111,7 @@ export class TopologyShellComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); readonly pageTabs = PAGE_TABS; - readonly activeTab = signal('regions'); + readonly activeTab = signal('overview'); ngOnInit(): void { this.setActiveTabFromUrl(this.router.url); diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 33d776ed1..88177e43a 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -196,12 +196,8 @@ export const OPERATIONS_ROUTES: Routes = [ }, { path: 'environments', - title: 'Environments Inventory', - data: { breadcrumb: 'Environments' }, - loadComponent: () => - import('../features/topology/topology-regions-environments-page.component').then( - (m) => m.TopologyRegionsEnvironmentsPageComponent, - ), + redirectTo: '/environments/overview', + pathMatch: 'full' as const, }, { path: 'environments/:environmentId', diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 87542317a..9c72b3cb1 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -247,7 +247,7 @@ export const RELEASES_ROUTES: Routes = [ }, { path: 'environments', - redirectTo: '/environments/regions', + redirectTo: '/environments/overview', pathMatch: 'full', }, { diff --git a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts index b7d4d36ac..f56d1a36d 100644 --- a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts @@ -10,53 +10,41 @@ export const TOPOLOGY_ROUTES: Routes = [ children: [ { path: '', - redirectTo: 'regions', + redirectTo: 'overview', pathMatch: 'full', }, { path: 'overview', - redirectTo: 'regions', + title: 'Environment Topology', + data: { + breadcrumb: 'Topology', + title: 'Environment Topology', + description: 'Interactive SVG topology of regions, environments, and promotion paths.', + }, + loadComponent: () => + import('../features/topology/topology-graph-page.component').then( + (m) => m.TopologyGraphPageComponent, + ), + }, + { + path: 'regions', + redirectTo: 'overview', + pathMatch: 'full', + }, + { + path: 'regions-environments', + redirectTo: 'overview', pathMatch: 'full', }, { path: 'map', - title: 'Environment & Target Map', - data: { breadcrumb: 'Map' }, - loadComponent: () => - import('../features/topology/topology-map-page.component').then((m) => m.TopologyMapPageComponent), - }, - { - path: 'regions', - title: 'Regions & Environments', - data: { - breadcrumb: 'Regions & Environments', - title: 'Regions & Environments', - description: 'Region-first topology inventory with environment posture and drilldowns.', - defaultView: 'region-first', - }, - loadComponent: () => - import('../features/topology/topology-regions-environments-page.component').then( - (m) => m.TopologyRegionsEnvironmentsPageComponent, - ), - }, - { - path: 'regions-environments', - redirectTo: 'regions', + redirectTo: 'overview', pathMatch: 'full', }, { path: 'environments', - title: 'Environments', - data: { - breadcrumb: 'Environments', - title: 'Environments', - description: 'Environment inventory scoped by region and topology metadata.', - defaultView: 'flat', - }, - loadComponent: () => - import('../features/topology/topology-regions-environments-page.component').then( - (m) => m.TopologyRegionsEnvironmentsPageComponent, - ), + redirectTo: 'overview', + pathMatch: 'full', }, { path: 'environments/:environmentId',